Tenant Context について
mcp-tenant-context は、Node.js サービスの非同期ハンドラー間でテナントと実行者の識別情報を扱う TypeScript ライブラリです。AsyncLocalStorage により関数への引数渡しを減らし、ミドルウェアやコールバック用の機能も備えています。
主な機能
- 01テナントと実行者の識別情報を伝播
- 02非同期呼び出し間でコンテキストを参照
- 03スコープ外コールバックへのコンテキスト適用
- 04ミドルウェアによるハンドラーのラップ
- 05OAuth、APIキー、匿名の実行者を作成
活用シーン
- 01テナント単位のデータベースクエリ
- 02書き込み操作の実行者を記録
- 03イベントリスナーでコンテキストを保持
MCP 対応
MCP サーバーでの利用を想定し、mcp-session-id ヘッダーに対応する sessionId をコンテキストに含められます。MCP サーバーそのものではなく、他の Node.js リクエストサービスでも使えます。
設定
ローカルサーバーの設定はどのクライアントでも共通です。コピーしてクライアントの MCP 設定ファイルに貼り付けてください。
このサーバーのパッケージやリモートエンドポイントはまだ取得できていません。プロジェクトの README の手順に従ってください。
ツール
ローカルサーバーのツールはマシン上で起動して初めて取得できます。メンテナーはまだツール一覧を提供していません。
詳細
StudioMeyer MCP Stack の一部 — マヨルカ島で開発 🌴 · ご利用いただけたら ⭐ をお願いします
mcp-tenant-context
AsyncLocalStorage を使って、リクエストごとの テナントとアクター の識別情報をハンドラースタック全体に伝播させます。これにより、tenantSlug とリクエストのアクターをすべての関数シグネチャに含めずに済みます。実行時の依存関係はゼロで、Node の標準ライブラリのみを使用します。
Node: 22+ · ライセンス: MIT · 種類: ライブラリ(サーバーなし、CLI なし)
私たちからひとこと
私たちはこの2年間、自分たちのためにツールやシステムを開発してきました。 このリポジトリが小さく、スターが少ないのは、新しいからではなく、すでに作っていたものを公開することにしたばかりだからです。実際に使われているコードで、本番環境で稼働しており、イシューにも回答しています。
ものづくりと共有が大好きです。成長のための小手先の手法や、スター集めは好きではありません。だから、このリポジトリは小規模です。コードを見て判断してください。役に立ったら、スターやテスト、イシューで応援していただけるとうれしいです。このライブラリを使って何か作ったら、[email protected] までぜひお知らせください。本当にうれしいです。
パルマ・デ・マヨルカの小さなスタジオより。
このライブラリが作られた理由
マルチテナントサーバーでは、リクエストごとの2つの識別情報をハンドラースタック全体に渡す必要があります。
tenantSlug— リクエストがどのテナントに属するかを示します。スコープを限定したすべての DB クエリ(WHERE tenant_slug = $1)で使われ、テナント間の分離の要となります。actor— リクエストを実行した人間(OAuth ユーザー)またはサービスプリンシパル(API キー)です。監査ログの記録や書き込み元の特定に使われます。
これらをすべてのハンドラーの関数シグネチャに渡すのは定型作業であり、引数をひとつ渡し忘れると、気づきにくいバグになります。AsyncLocalStorage を使えば、シンプルな構成を保てます。エントリーポイントでコンテキストを一度設定し、コールスタックのどこからでも読み取れます。このライブラリは、そのパターンをパッケージ化したものです。約115行で、依存関係はなく、型付けも完全です。
MCP サーバーを念頭に置いて作られており(TenantContext は mcp-session-id ヘッダーに対応するオプションの sessionId を保持します)、MCP に特化したものではありません。リクエスト単位のコンテキストを扱うあらゆる Node サービスで利用できます。
インストール
npm install mcp-tenant-context
Node 22+ が必要です。
クイックスタート
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],
);
}
パブリック API
| エクスポート | 用途 |
|---|---|
runWithTenantContext<T>(ctx, fn) | ctx を設定した状態で fn を実行します。同期/非同期の戻り値の型を保持します。 |
getTenantContext() | 現在の ctx を返します。コンテキストがない場合は NoTenantContextError をスローします。 |
getTenantContextOrUndefined() | 現在の ctx を返します。ない場合は undefined を返します。 |
bindTenantContext<F>(fn) | アクティブな ctx のスナップショットを fn に設定し、切り離された EventEmitter/ストリームのコールバックでもコンテキストを保持します。入力と同じシグネチャで、同じ値を返します。 |
withTenantContextHandler<TArgs, TResult>(handler) | ctx を抽出して最初の引数として渡すミドルウェアです。 |
oauthActor(email) | OAuth によって認証されたアクターです。表示名はメールアドレスのローカル部分から生成されます。 |
apiKeyActor(tenantSlug) | 合成された service-account@<slug>.invalid アクターです(RFC 2606 で予約された TLD)。 |
anonymousActor() | 認証されていない経路用の、メールアドレスが空のアクターです。 |
TenantContext(型) | { tenantSlug, actor, traceId?, sessionId? }。 |
ActorIdentity(型) | { email, name, source }。 |
ActorSource(型) | "oauth" | "api_key" | "anonymous"。 |
NoTenantContextError(クラス) | 個別に捕捉できる専用のエラーサブクラスです。 |
ミドルウェア
withTenantContextHandler は、getTenantContext() の定型コードを共通化します。
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 }));
runWithTenantContext の外部で呼び出されたハンドラーは、
NoTenantContextError で拒否されます(拒否されたプロミスとして返されるため、
すべての呼び出し元で一律に .catch() できます)。
利用側の責任
このライブラリは、設計上、入力の検証やサニタイズを行いません。汎用性を保つためです。セキュリティに関わる部分は呼び出し側の責任です。
- コンテキストを作成する前に、
tenantSlugを検証してください(空でないこと、想定される文字セットに合致すること。例:^[a-z0-9][a-z0-9-]{0,62}$)。空のスラッグや攻撃者が制御するスラッグをWHERE tenant_slug = $1に渡すと、テナント間のリスクになります。 - SQL のパラメーター化を行ってください。
tenantSlugは単なる文字列であり、文字列に埋め込んではいけません。 - ID レイヤーで
emailを検証し、oauthActor(email)に渡す前に小文字に変換してください。 - テナントが必須のハンドラーでは、匿名アクターを拒否してください。
ライブラリが保証するのは、コンテキストが並行するスコープ間で漏れないことだけです。コンテキストより上位のことは、すべて利用側の責任です。
コールバックやイベントエミッターでのコンテキスト消失
AsyncLocalStorage は、async/await、プロミス、setTimeout、
setInterval、setImmediate、process.nextTick、queueMicrotask、および
Promise.all/race/allSettled コンビネーターに自動的に追従します(いずれもテストスイートでカバーされています)。別の非同期コンテキストで登録されたコールバックには追従しません。よくある例は、テナントのスコープ外で登録された EventEmitter リスナー(stream.on("data", ...)、req.on("close", ...))です。このようなコールバック内では getTenantContext() は例外をスローします。
この場合は、登録時に bindTenantContext を使ってコールバックを現在のコンテキストにバインドしてください。
import { runWithTenantContext, bindTenantContext, getTenantContext } from "mcp-tenant-context";
runWithTenantContext(ctx, () => {
// Captured now → still resolves when `data` fires later, outside the scope.
stream.on("data", bindTenantContext((chunk: Buffer) => {
const { tenantSlug } = getTenantContext(); // resolves to ctx
audit(tenantSlug, chunk.length);
}));
});
bindTenantContext(fn) は、fn と同じシグネチャの関数を返します(引数と戻り値は変更されずに渡されます)。また、アンビエントな非同期コンテキスト全体のスナップショットを取得するため、テナント、トレース、その他の AsyncLocalStorage がまとめて復元されます。これは Node の AsyncLocalStorage.bind を基盤にしています。バインド時にアクティブなコンテキストがない場合、バインド後の関数はバインド前とまったく同じように動作します(そのため、getTenantContext() は引き続き例外をスローします)。バインドが害になることはありません。
コンテキストは worker_threads には伝播しません。設計上、生成されたワーカーは空のストアから開始します。ワーカーとの境界を越えて tenantSlug を明示的に渡してください。
パフォーマンス
AsyncLocalStorage は、await ごとに一定かつ小さな管理コストを追加します。実際の処理(DB への往復や LLM の呼び出し)を行うハンドラーでは、このコストは無視できる程度です。レイテンシーを左右するのはコンテキストの参照ではなく I/O です。Node のドキュメントでは、この実装を「パフォーマンスに優れ、メモリーセーフ」と説明しています。計測が必要なのは、極めて高頻度で、メモリー割り当てに敏感な処理経路がある場合に限られます。
モジュールインスタンス
ストアはモジュールレベルのシングルトンです。同じプロセス内で mcp-tenant-context をインポートするたびに、同じストアが共有されます(意図した動作です。コンテキストはプロセス全体で共有されます)。依存関係ツリーにこのパッケージの異なるインストール版が2つ含まれている場合、ストアは共有されません。そのため、複数の依存パッケージで使用する場合は、単一のバージョンに統一してください(npm dedupe)。
テストスイートでカバーしているエッジケース
runWithTenantContextの入れ子 — 内側のコンテキストで上書きされ、再開時に外側のコンテキストに戻ります。awaitの後や、並行する子スコープ間でも同様です。- 並列実行される兄弟スコープ — 並列コンテキスト間で混線しません。
- 非同期イテレーターのパススルー —
for awaitの境界を越えても ctx が維持されます。 - すべてのタイマーとマイクロタスクの境界 —
setTimeout(解決・拒否の両方の経路)、setInterval、setImmediate、process.nextTick、queueMicrotask。 - Promise コンビネーター —
Promise.all、Promise.race、Promise.allSettledでは、各分岐と結合後も ctx が維持されます。 - 切り離された
EventEmitter/ストリームのリスナー — ctx は正しく存在しない状態になります(AsyncLocalStorageが追跡できない境界です)。その場所ではgetTenantContext()が例外をスローします。bindTenantContextを使うと、run()の終了後や、別の有効なコンテキスト内からイベントが発行された場合でも ctx が復元されます。 - 同期/非同期
fnの戻り値の型 — 維持されます。 traceIdとsessionIdのフィールドは省略可能です。- 病的な入力を持つ
oauthActor:ローカル部が空(@host)、@がない、区切り文字のみのローカル部、長すぎるローカル部(派生名は上限内に収まり、メールアドレスはそのまま保持されます)。いずれも例外をスローせず、安全な値を返します。
4ファイルにわたり47件のテストがあります。
バージョニング
厳密な SemVer に従います。破壊的変更にはメジャーバージョンの更新と、CHANGELOG.md の移行に関する注記が必要です。キャレット("mcp-tenant-context": "^0.2.0")を付けて固定してください。
関連プロジェクト
StudioMeyer MCP スタックの一部です。多層防御を備えたマルチテナント MCP サーバーの構築時に、あわせてインストールするのがおすすめです。
mcp-tenant-pair— コンシューマー向け MCP サーバーのマルチユーザーテナンシー(カップル、家族、小規模グループ向け)。mcp-stdio-shellguard— MCP サーバーのexec/spawnに対するデフォルト拒否ガード。mcp-rce-guard— MCP のサブプロセス向け、プロセス分離と CVE リプレイ防御。
コントリビューション
Issue や PR を歓迎します。CONTRIBUTING.md をご覧ください。セキュリティに関する報告は、SECURITY.md の手順に従ってください。
ライセンス
MIT © Matthias Meyer (StudioMeyer)
コメント
AI アプリケーションが外部のツールやデータソースに統一的な方法で接続するためのオープンなプロトコルです。クライアントはモデルとの対話を担い、MCP サーバーはツール・リソース・プロンプトテンプレートを提供します。標準メッセージでやり取りするため、1 つのサーバーを複数のクライアントで再利用できます。