MCP Server Multi-Tenancy for ChatGPT Apps
Building multi-tenant MCP servers enables you to serve multiple customers with a single ChatGPT application deployment while maintaining complete data isolation. Whether you're building a SaaS platform with ChatGPT integrations or managing multiple client deployments, understanding multi-tenancy patterns is critical for scalability, security, and cost optimization. This guide provides production-ready strategies for implementing tenant isolation, authentication, data partitioning, and performance optimization in MCP servers.
Multi-tenancy architecture allows you to share infrastructure while guaranteeing that each tenant's data, configuration, and resources remain completely isolated. Unlike single-tenant deployments where each customer gets their own server instance, multi-tenant systems use intelligent partitioning strategies to segregate data at the database level, application level, or both. For ChatGPT applications using the Model Context Protocol, this means designing your MCP server to identify tenants from incoming requests, enforce strict access controls, and partition cached state appropriately.
The key challenges in multi-tenant MCP servers include: tenant identification from ChatGPT requests (which don't include explicit tenant headers), preventing cross-tenant data leakage, managing per-tenant configuration and feature flags, optimizing database queries with tenant filters, and ensuring fair resource allocation across tenants. This article provides battle-tested solutions with TypeScript code you can deploy immediately.
Tenant Identification Strategies
Identifying which tenant is making a request to your MCP server is the foundation of multi-tenancy. Since ChatGPT doesn't automatically pass tenant identifiers, you must embed tenant information in the authentication flow. The three primary strategies are subdomain routing, JWT claims, and custom OAuth scopes.
Subdomain Routing is the most common approach. Each tenant gets a unique subdomain (e.g., acme.yourmcpserver.com, globex.yourmcpserver.com), and your MCP server extracts the tenant ID from the Host header. This approach requires wildcard SSL certificates and DNS configuration but provides excellent tenant isolation and makes debugging easier.
JWT Claims embed the tenant ID directly in the OAuth access token. When ChatGPT authenticates with your OAuth server, you include a tenant_id claim in the returned token. Your MCP server validates the token and extracts the tenant ID from the claims. This approach works well when tenants authenticate through a central identity provider.
Custom Headers allow you to pass tenant identifiers in HTTP headers like X-Tenant-ID, but this requires ChatGPT to support custom header configuration in the connector definition. As of December 2026, this approach has limited support and should be used as a fallback.
Here's a production-ready tenant identification middleware that supports all three strategies:
// src/middleware/tenant-identification.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { createHash } from 'crypto';
interface TenantContext {
tenantId: string;
tenantSlug: string;
subscriptionTier: 'free' | 'starter' | 'professional' | 'business';
features: string[];
quotas: {
maxToolCalls: number;
maxStorage: number;
maxUsers: number;
};
}
interface DecodedToken {
sub: string;
tenant_id?: string;
tenant_slug?: string;
scope?: string;
}
// Extend Express Request to include tenant context
declare global {
namespace Express {
interface Request {
tenant?: TenantContext;
}
}
}
export class TenantIdentificationMiddleware {
private tenantCache: Map<string, TenantContext>;
private cacheExpiry: Map<string, number>;
private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes
constructor(
private jwtSecret: string,
private defaultTenant: string = 'default'
) {
this.tenantCache = new Map();
this.cacheExpiry = new Map();
}
/**
* Main middleware function - identifies tenant from multiple sources
*/
public identify = async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
try {
// Strategy 1: Subdomain routing
let tenantId = this.extractFromSubdomain(req);
// Strategy 2: JWT claims
if (!tenantId) {
tenantId = await this.extractFromJWT(req);
}
// Strategy 3: Custom header (fallback)
if (!tenantId) {
tenantId = this.extractFromHeader(req);
}
// Strategy 4: Query parameter (development only)
if (!tenantId && process.env.NODE_ENV === 'development') {
tenantId = req.query.tenant_id as string;
}
if (!tenantId) {
res.status(401).json({
error: 'Tenant identification failed',
message: 'No tenant identifier found in request',
supported_methods: [
'subdomain routing (tenant.yourdomain.com)',
'JWT claims (tenant_id in access token)',
'X-Tenant-ID header'
]
});
return;
}
// Load tenant context (with caching)
const tenantContext = await this.loadTenantContext(tenantId);
if (!tenantContext) {
res.status(404).json({
error: 'Tenant not found',
message: `Tenant ${tenantId} does not exist or is disabled`
});
return;
}
// Attach tenant context to request
req.tenant = tenantContext;
next();
} catch (error) {
console.error('Tenant identification error:', error);
res.status(500).json({
error: 'Internal server error',
message: 'Failed to identify tenant'
});
}
};
/**
* Extract tenant ID from subdomain
* Example: acme.mcpserver.com → acme
*/
private extractFromSubdomain(req: Request): string | null {
const host = req.headers.host || '';
const parts = host.split('.');
// Subdomain must be at least 3 parts: tenant.domain.tld
if (parts.length < 3) {
return null;
}
const subdomain = parts[0];
// Exclude common subdomains
if (['www', 'api', 'app', 'admin'].includes(subdomain)) {
return null;
}
return subdomain;
}
/**
* Extract tenant ID from JWT access token
*/
private async extractFromJWT(req: Request): Promise<string | null> {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return null;
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, this.jwtSecret) as DecodedToken;
return decoded.tenant_id || decoded.tenant_slug || null;
} catch (error) {
console.warn('JWT verification failed:', error);
return null;
}
}
/**
* Extract tenant ID from custom header
*/
private extractFromHeader(req: Request): string | null {
return (req.headers['x-tenant-id'] as string) || null;
}
/**
* Load tenant configuration with caching
*/
private async loadTenantContext(
tenantId: string
): Promise<TenantContext | null> {
// Check cache
const cached = this.tenantCache.get(tenantId);
const expiry = this.cacheExpiry.get(tenantId);
if (cached && expiry && expiry > Date.now()) {
return cached;
}
// Load from database (replace with your actual database query)
const context = await this.fetchTenantFromDatabase(tenantId);
if (context) {
// Cache the result
this.tenantCache.set(tenantId, context);
this.cacheExpiry.set(tenantId, Date.now() + this.CACHE_TTL);
}
return context;
}
/**
* Fetch tenant configuration from database
* Replace this with your actual database implementation
*/
private async fetchTenantFromDatabase(
tenantId: string
): Promise<TenantContext | null> {
// This is a placeholder - implement your database query here
// Example with Firestore:
// const doc = await admin.firestore().collection('tenants').doc(tenantId).get();
// return doc.exists ? doc.data() as TenantContext : null;
// Mock implementation for demonstration
return {
tenantId,
tenantSlug: tenantId,
subscriptionTier: 'professional',
features: ['advanced_analytics', 'custom_branding', 'api_access'],
quotas: {
maxToolCalls: 50000,
maxStorage: 10737418240, // 10GB
maxUsers: 100
}
};
}
/**
* Clear cache for specific tenant (useful after updates)
*/
public invalidateCache(tenantId: string): void {
this.tenantCache.delete(tenantId);
this.cacheExpiry.delete(tenantId);
}
/**
* Clear all cached tenant data
*/
public clearCache(): void {
this.tenantCache.clear();
this.cacheExpiry.clear();
}
}
For detailed authentication patterns, see our guide on OAuth Security for Advanced ChatGPT Applications.
Data Isolation Strategies
Once you've identified the tenant, you must ensure their data remains completely isolated from other tenants. The three primary data isolation strategies are: database per tenant, schema per tenant, and row-level security with shared tables.
Database Per Tenant provides the strongest isolation by giving each tenant their own physical database. This approach simplifies compliance (GDPR, HIPAA), makes backups and restores trivial, and allows per-tenant performance tuning. However, it increases operational complexity (managing hundreds of databases) and can be cost-prohibitive for large tenant counts.
Schema Per Tenant uses a single database but creates separate schemas (namespaces) for each tenant. PostgreSQL, MySQL 8.0+, and SQL Server support this pattern natively. Each tenant's tables live in their own schema (tenant_acme.users, tenant_globex.users), providing logical isolation while sharing the same database instance. This balances isolation with operational simplicity.
Row-Level Security (RLS) stores all tenants in shared tables with a tenant_id column on every row. Database-level security policies enforce that queries only access rows matching the current tenant. This approach maximizes resource sharing and simplifies schema migrations but requires careful implementation to prevent cross-tenant leaks.
Here's a production-ready implementation of row-level security with PostgreSQL:
// src/database/multi-tenant-database.ts
import { Pool, PoolClient, QueryResult } from 'pg';
export class MultiTenantDatabase {
private pool: Pool;
constructor(connectionString: string) {
this.pool = new Pool({
connectionString,
max: 20, // Connection pool size
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000
});
}
/**
* Execute query with automatic tenant filtering
*/
public async query<T = any>(
tenantId: string,
sql: string,
params: any[] = []
): Promise<QueryResult<T>> {
const client = await this.pool.connect();
try {
// Set tenant context for row-level security
await this.setTenantContext(client, tenantId);
// Execute query (RLS policies automatically filter by tenant)
const result = await client.query<T>(sql, params);
return result;
} finally {
// Reset context and release connection
await this.clearTenantContext(client);
client.release();
}
}
/**
* Execute transaction with tenant isolation
*/
public async transaction<T>(
tenantId: string,
callback: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
await this.setTenantContext(client, tenantId);
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
await this.clearTenantContext(client);
client.release();
}
}
/**
* Set PostgreSQL session variable for RLS
*/
private async setTenantContext(
client: PoolClient,
tenantId: string
): Promise<void> {
// PostgreSQL uses session variables for RLS context
await client.query('SET app.current_tenant_id = $1', [tenantId]);
}
/**
* Clear tenant context (important for connection pooling)
*/
private async clearTenantContext(client: PoolClient): Promise<void> {
await client.query('RESET app.current_tenant_id');
}
/**
* Initialize row-level security policies (run once during setup)
*/
public async initializeRLS(): Promise<void> {
const client = await this.pool.connect();
try {
// Enable RLS on all tenant-scoped tables
await client.query(`
-- Apps table
ALTER TABLE apps ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_apps ON apps
USING (tenant_id = current_setting('app.current_tenant_id')::text)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::text);
-- Users table
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_users ON users
USING (tenant_id = current_setting('app.current_tenant_id')::text)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::text);
-- Analytics events table
ALTER TABLE analytics_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_analytics ON analytics_events
USING (tenant_id = current_setting('app.current_tenant_id')::text)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::text);
-- File storage table
ALTER TABLE files ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_files ON files
USING (tenant_id = current_setting('app.current_tenant_id')::text)
WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::text);
`);
console.log('✅ Row-level security policies initialized');
} finally {
client.release();
}
}
/**
* Create indexes for tenant-scoped queries
*/
public async optimizeForMultiTenancy(): Promise<void> {
const client = await this.pool.connect();
try {
await client.query(`
-- Composite indexes with tenant_id as first column
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_apps_tenant_created
ON apps(tenant_id, created_at DESC);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_tenant_email
ON users(tenant_id, email);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_analytics_tenant_timestamp
ON analytics_events(tenant_id, timestamp DESC);
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_files_tenant_app
ON files(tenant_id, app_id);
`);
console.log('✅ Multi-tenancy indexes created');
} finally {
client.release();
}
}
/**
* Cleanup and close all connections
*/
public async close(): Promise<void> {
await this.pool.end();
}
}
For database architecture patterns, see our guide on Database Sharding Strategies for ChatGPT Applications.
Tenant Configuration Management
Each tenant requires custom configuration: feature flags, branding settings, API quotas, and integration credentials. Managing this configuration securely and efficiently is critical for SaaS operations.
Feature Flags allow you to enable or disable features per tenant without code deployments. For example, professional-tier tenants might have access to advanced analytics, while starter-tier tenants only see basic metrics. Feature flags should be evaluated server-side to prevent tampering.
Branding Configuration includes logos, color schemes, custom domains, and email templates. Store branding assets in tenant-scoped storage buckets (e.g., Firebase Storage with path tenants/{tenantId}/assets/) and apply CORS policies appropriately.
Resource Quotas prevent individual tenants from consuming excessive resources. Track metrics like tool calls per month, storage usage, and API requests. Implement quota enforcement at the application layer and database constraints where appropriate.
Integration Credentials such as third-party API keys, OAuth tokens, and webhook URLs must be encrypted at rest and scoped to the tenant. Never expose one tenant's credentials to another tenant's context.
Here's a production-ready tenant configuration manager:
// src/services/tenant-config-manager.ts
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto';
interface TenantConfig {
tenantId: string;
features: {
advancedAnalytics: boolean;
customBranding: boolean;
apiAccess: boolean;
exportData: boolean;
whitelabelMode: boolean;
};
quotas: {
maxToolCallsPerMonth: number;
maxStorageBytes: number;
maxAppsCount: number;
maxTeamMembers: number;
};
branding: {
primaryColor: string;
logoUrl: string;
customDomain?: string;
companyName: string;
};
integrations: {
stripeCustomerId?: string;
slackWebhookUrl?: string;
analyticsTrackingId?: string;
};
encryptedSecrets?: {
[key: string]: string; // Encrypted values
};
}
export class TenantConfigManager {
private configCache: Map<string, TenantConfig>;
private cacheExpiry: Map<string, number>;
private readonly CACHE_TTL = 10 * 60 * 1000; // 10 minutes
private encryptionKey: Buffer;
constructor(
private database: any, // Replace with your database client
encryptionSecret: string
) {
this.configCache = new Map();
this.cacheExpiry = new Map();
// Derive 32-byte key from secret
this.encryptionKey = scryptSync(encryptionSecret, 'salt', 32);
}
/**
* Load tenant configuration with caching
*/
public async getConfig(tenantId: string): Promise<TenantConfig> {
// Check cache
const cached = this.configCache.get(tenantId);
const expiry = this.cacheExpiry.get(tenantId);
if (cached && expiry && expiry > Date.now()) {
return cached;
}
// Load from database
const config = await this.loadFromDatabase(tenantId);
// Cache result
this.configCache.set(tenantId, config);
this.cacheExpiry.set(tenantId, Date.now() + this.CACHE_TTL);
return config;
}
/**
* Check if feature is enabled for tenant
*/
public async hasFeature(
tenantId: string,
feature: keyof TenantConfig['features']
): Promise<boolean> {
const config = await this.getConfig(tenantId);
return config.features[feature] || false;
}
/**
* Check if tenant is within quota
*/
public async checkQuota(
tenantId: string,
quotaType: keyof TenantConfig['quotas'],
currentUsage: number
): Promise<{ allowed: boolean; limit: number; remaining: number }> {
const config = await this.getConfig(tenantId);
const limit = config.quotas[quotaType];
const remaining = Math.max(0, limit - currentUsage);
return {
allowed: currentUsage < limit,
limit,
remaining
};
}
/**
* Store encrypted secret for tenant
*/
public async setSecret(
tenantId: string,
key: string,
value: string
): Promise<void> {
const encrypted = this.encrypt(value);
await this.database.query(
`INSERT INTO tenant_secrets (tenant_id, key, encrypted_value, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (tenant_id, key)
DO UPDATE SET encrypted_value = $3, updated_at = NOW()`,
[tenantId, key, encrypted]
);
// Invalidate cache
this.invalidateCache(tenantId);
}
/**
* Retrieve and decrypt secret for tenant
*/
public async getSecret(
tenantId: string,
key: string
): Promise<string | null> {
const result = await this.database.query(
`SELECT encrypted_value FROM tenant_secrets
WHERE tenant_id = $1 AND key = $2`,
[tenantId, key]
);
if (result.rows.length === 0) {
return null;
}
return this.decrypt(result.rows[0].encrypted_value);
}
/**
* Encrypt sensitive data using AES-256-GCM
*/
private encrypt(plaintext: string): string {
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-gcm', this.encryptionKey, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Format: iv:authTag:encrypted
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
}
/**
* Decrypt sensitive data
*/
private decrypt(ciphertext: string): string {
const [ivHex, authTagHex, encrypted] = ciphertext.split(':');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = createDecipheriv('aes-256-gcm', this.encryptionKey, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* Load configuration from database
*/
private async loadFromDatabase(tenantId: string): Promise<TenantConfig> {
const result = await this.database.query(
`SELECT * FROM tenant_configs WHERE tenant_id = $1`,
[tenantId]
);
if (result.rows.length === 0) {
throw new Error(`Tenant ${tenantId} configuration not found`);
}
return result.rows[0] as TenantConfig;
}
/**
* Invalidate cache for tenant
*/
public invalidateCache(tenantId: string): void {
this.configCache.delete(tenantId);
this.cacheExpiry.delete(tenantId);
}
}
Security and Isolation Enforcement
Multi-tenant applications are prime targets for cross-tenant attacks. Implement defense-in-depth with multiple layers of security validation.
Input Validation must reject any tenant ID that doesn't match the authenticated tenant. Never trust client-provided tenant identifiers—always derive them from authenticated tokens or subdomains.
Query Filtering should automatically inject tenant filters into every database query. Never rely on developers remembering to add WHERE tenant_id = ?—automate it with query builders or RLS policies.
Audit Logging tracks every cross-tenant access attempt. Log the requesting tenant, target resource, timestamp, and action. Review audit logs regularly for suspicious patterns.
Penetration Testing should include cross-tenant attack scenarios: JWT token manipulation, subdomain spoofing, SQL injection to bypass tenant filters, and timing attacks to infer other tenants' data.
Here's a production-ready security validator:
// src/security/tenant-access-validator.ts
import { Request, Response, NextFunction } from 'express';
export class TenantAccessValidator {
private auditLog: Array<{
timestamp: Date;
tenantId: string;
userId: string;
resource: string;
action: string;
allowed: boolean;
reason?: string;
}>;
constructor(private database: any) {
this.auditLog = [];
}
/**
* Middleware to validate tenant access to resources
*/
public validateAccess = (resourceType: string) => {
return async (
req: Request,
res: Response,
next: NextFunction
): Promise<void> => {
const tenant = req.tenant;
const userId = (req as any).user?.uid;
if (!tenant) {
this.logAccess({
timestamp: new Date(),
tenantId: 'unknown',
userId: userId || 'anonymous',
resource: resourceType,
action: req.method,
allowed: false,
reason: 'No tenant context'
});
res.status(401).json({
error: 'Unauthorized',
message: 'Tenant context required'
});
return;
}
// Extract resource ID from path
const resourceId = this.extractResourceId(req);
if (resourceId) {
// Validate that resource belongs to tenant
const ownerTenantId = await this.getResourceOwner(
resourceType,
resourceId
);
if (ownerTenantId !== tenant.tenantId) {
this.logAccess({
timestamp: new Date(),
tenantId: tenant.tenantId,
userId: userId || 'anonymous',
resource: `${resourceType}:${resourceId}`,
action: req.method,
allowed: false,
reason: `Cross-tenant access attempt: resource belongs to ${ownerTenantId}`
});
res.status(403).json({
error: 'Forbidden',
message: 'Access denied to resource'
});
return;
}
}
// Log successful access
this.logAccess({
timestamp: new Date(),
tenantId: tenant.tenantId,
userId: userId || 'anonymous',
resource: resourceId ? `${resourceType}:${resourceId}` : resourceType,
action: req.method,
allowed: true
});
next();
};
};
/**
* Extract resource ID from request path
*/
private extractResourceId(req: Request): string | null {
// Extract ID from paths like /api/apps/:id or /api/users/:userId
const match = req.path.match(/\/([a-zA-Z0-9_-]+)(?:\/|$)/g);
return match && match.length > 0 ? match[match.length - 1].replace(/\//g, '') : null;
}
/**
* Get tenant ID that owns a resource
*/
private async getResourceOwner(
resourceType: string,
resourceId: string
): Promise<string | null> {
try {
const result = await this.database.query(
`SELECT tenant_id FROM ${resourceType} WHERE id = $1`,
[resourceId]
);
return result.rows.length > 0 ? result.rows[0].tenant_id : null;
} catch (error) {
console.error('Error fetching resource owner:', error);
return null;
}
}
/**
* Log access attempt to audit trail
*/
private logAccess(entry: typeof this.auditLog[0]): void {
this.auditLog.push(entry);
// Log to console in development
if (process.env.NODE_ENV === 'development') {
console.log(
`[AUDIT] ${entry.allowed ? '✅' : '❌'} ${entry.tenantId} → ${entry.resource} (${entry.action})${entry.reason ? ` - ${entry.reason}` : ''}`
);
}
// Persist to database asynchronously
this.persistAuditLog(entry).catch(err =>
console.error('Failed to persist audit log:', err)
);
// Alert on failed access attempts
if (!entry.allowed && entry.reason?.includes('Cross-tenant')) {
this.alertSecurityTeam(entry);
}
}
/**
* Persist audit log to database
*/
private async persistAuditLog(
entry: typeof this.auditLog[0]
): Promise<void> {
await this.database.query(
`INSERT INTO audit_logs (timestamp, tenant_id, user_id, resource, action, allowed, reason)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
entry.timestamp,
entry.tenantId,
entry.userId,
entry.resource,
entry.action,
entry.allowed,
entry.reason || null
]
);
}
/**
* Alert security team of suspicious activity
*/
private alertSecurityTeam(entry: typeof this.auditLog[0]): void {
// Implement your alerting mechanism (Slack, PagerDuty, etc.)
console.error('🚨 SECURITY ALERT: Cross-tenant access attempt detected', entry);
}
/**
* Get recent audit logs for tenant
*/
public async getAuditLogs(
tenantId: string,
limit: number = 100
): Promise<typeof this.auditLog> {
const result = await this.database.query(
`SELECT * FROM audit_logs
WHERE tenant_id = $1
ORDER BY timestamp DESC
LIMIT $2`,
[tenantId, limit]
);
return result.rows;
}
}
For advanced security patterns, see our guide on Firestore Security Rules for Advanced ChatGPT Applications.
Performance Optimization for Multi-Tenant Systems
Multi-tenant architectures introduce unique performance challenges. Tenant-scoped queries require careful indexing, connection pooling must account for tenant context, and caching strategies need tenant partitioning.
Connection Pooling per tenant prevents noisy neighbors from exhausting shared connection pools. Allocate a minimum number of connections per tenant and implement fair scheduling for connection acquisition.
Cache Partitioning ensures that cached data is always tenant-scoped. Use cache keys like tenant:{tenantId}:resource:{resourceId} to prevent cross-tenant cache pollution. Implement cache eviction policies that prioritize high-value tenants.
Query Optimization requires composite indexes with tenant_id as the first column. PostgreSQL and MySQL query planners can efficiently filter by tenant when indexes are structured correctly. Analyze slow query logs regularly and add indexes for common tenant-scoped queries.
Here's a production-ready tenant cache manager:
// src/cache/tenant-cache-manager.ts
import Redis from 'ioredis';
export class TenantCacheManager {
private redis: Redis;
private readonly DEFAULT_TTL = 60 * 60; // 1 hour
constructor(redisUrl: string) {
this.redis = new Redis(redisUrl, {
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
maxRetriesPerRequest: 3
});
}
/**
* Generate tenant-scoped cache key
*/
private key(tenantId: string, resource: string): string {
return `tenant:${tenantId}:${resource}`;
}
/**
* Get cached value for tenant
*/
public async get<T>(tenantId: string, resource: string): Promise<T | null> {
const key = this.key(tenantId, resource);
const value = await this.redis.get(key);
return value ? JSON.parse(value) : null;
}
/**
* Set cached value for tenant
*/
public async set<T>(
tenantId: string,
resource: string,
value: T,
ttl: number = this.DEFAULT_TTL
): Promise<void> {
const key = this.key(tenantId, resource);
await this.redis.setex(key, ttl, JSON.stringify(value));
}
/**
* Delete cached value for tenant
*/
public async delete(tenantId: string, resource: string): Promise<void> {
const key = this.key(tenantId, resource);
await this.redis.del(key);
}
/**
* Delete all cached data for tenant
*/
public async clearTenant(tenantId: string): Promise<void> {
const pattern = this.key(tenantId, '*');
const keys = await this.redis.keys(pattern);
if (keys.length > 0) {
await this.redis.del(...keys);
}
}
/**
* Get cache statistics for tenant
*/
public async getStats(tenantId: string): Promise<{
keyCount: number;
memoryUsage: number;
}> {
const pattern = this.key(tenantId, '*');
const keys = await this.redis.keys(pattern);
let memoryUsage = 0;
for (const key of keys) {
const usage = await this.redis.memory('USAGE', key);
memoryUsage += usage || 0;
}
return {
keyCount: keys.length,
memoryUsage
};
}
/**
* Implement cache-aside pattern with automatic tenant scoping
*/
public async getOrFetch<T>(
tenantId: string,
resource: string,
fetchFn: () => Promise<T>,
ttl?: number
): Promise<T> {
// Try cache first
const cached = await this.get<T>(tenantId, resource);
if (cached !== null) {
return cached;
}
// Fetch from source
const value = await fetchFn();
// Store in cache
await this.set(tenantId, resource, value, ttl);
return value;
}
/**
* Close Redis connection
*/
public async close(): Promise<void> {
await this.redis.quit();
}
}
Monitoring and Metrics
Track per-tenant metrics to identify resource hogs, optimize performance, and enforce quotas. Here's a production-ready metrics collector:
// src/monitoring/tenant-metrics-collector.ts
export class TenantMetricsCollector {
private metrics: Map<string, {
toolCalls: number;
storageBytes: number;
apiRequests: number;
errors: number;
lastActivity: Date;
}>;
constructor(private database: any) {
this.metrics = new Map();
}
/**
* Record tool call for tenant
*/
public async recordToolCall(tenantId: string): Promise<void> {
const current = this.metrics.get(tenantId) || this.initMetrics();
current.toolCalls++;
current.lastActivity = new Date();
this.metrics.set(tenantId, current);
// Persist to database asynchronously
await this.persistMetrics(tenantId, 'tool_calls', 1);
}
/**
* Record storage usage for tenant
*/
public async recordStorageUsage(
tenantId: string,
bytes: number
): Promise<void> {
const current = this.metrics.get(tenantId) || this.initMetrics();
current.storageBytes += bytes;
this.metrics.set(tenantId, current);
await this.persistMetrics(tenantId, 'storage_bytes', bytes);
}
/**
* Get current metrics for tenant
*/
public async getMetrics(tenantId: string): Promise<typeof this.metrics extends Map<string, infer T> ? T : never> {
return this.metrics.get(tenantId) || this.initMetrics();
}
private initMetrics() {
return {
toolCalls: 0,
storageBytes: 0,
apiRequests: 0,
errors: 0,
lastActivity: new Date()
};
}
private async persistMetrics(
tenantId: string,
metricType: string,
value: number
): Promise<void> {
await this.database.query(
`INSERT INTO tenant_metrics (tenant_id, metric_type, value, timestamp)
VALUES ($1, $2, $3, NOW())`,
[tenantId, metricType, value]
);
}
}
Conclusion: Build Scalable Multi-Tenant ChatGPT Apps
Multi-tenancy enables you to serve thousands of customers from a single MCP server deployment while maintaining complete data isolation and security. By implementing subdomain routing or JWT-based tenant identification, enforcing row-level security with database policies, managing per-tenant configuration with encrypted secrets, and optimizing performance with tenant-scoped caching, you create a production-grade SaaS architecture.
The code examples in this guide are production-ready and follow industry best practices for security, performance, and maintainability. Adapt them to your specific database (PostgreSQL, MySQL, Firestore) and infrastructure (Docker, Kubernetes, serverless) while maintaining the core principles of tenant isolation.
Ready to build multi-tenant ChatGPT applications without writing complex MCP server code? MakeAIHQ provides automated tenant management, database isolation, and quota enforcement—transforming your ChatGPT app idea into a scalable SaaS platform in 48 hours. Start your free trial and deploy your first multi-tenant MCP server today.
For more advanced topics, explore our complete guide on Building Production ChatGPT Applications.
Related Resources: