MCP Servers

A collection of Model Context Protocol servers, templates, tools and more.

AsyncLocalStorage tenant + actor context propagation for MCP servers. Zero runtime dependencies, ESM-only, TypeScript.

Created 5/31/2026
Updated about 20 hours ago
Repository documentation and setup instructions

Part of the StudioMeyer MCP Stack — Built in Mallorca 🌴 · ⭐ if you use it

mcp-tenant-context

CI npm version npm downloads License Last commit GitHub stars

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 tenantSlug before building a context (non-empty, expected charset, e.g. ^[a-z0-9][a-z0-9-]{0,62}$). An empty or attacker-controlled slug handed to WHERE tenant_slug = $1 is a cross-tenant risk.
  • Parameterise SQLtenantSlug is a plain string, never interpolate it.
  • Verify email in your identity layer and lowercase it before oauthActor(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 await boundaries.
  • Detached setTimeout callbacks — ctx propagates through the timer.
  • Synchronous-vs-async fn return type — preserved.
  • Optional traceId + sessionId fields.
  • oauthActor with 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 for exec/spawn in 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)

Quick Setup
Installation guide for this server

Install Package (if required)

npx @modelcontextprotocol/server-mcp-tenant-context

Cursor configuration (mcp.json)

{ "mcpServers": { "studiomeyer-io-mcp-tenant-context": { "command": "npx", "args": [ "studiomeyer-io-mcp-tenant-context" ] } } }