Multi-Tenancy Support
Priority: 🚫 NOT PLANNED Estimated Time: 40-60 hours Status: Not Planned
Overview
Add support for multiple isolated tenants within a single Seed MCP Server deployment. Each tenant would have their own isolated resources, client registrations, and session management while sharing the same infrastructure.
Current Gap
Currently, Seed operates as a single-tenant system:
- All registered clients share the same namespace
- All sessions are stored in a shared Redis namespace
- No isolation between different organizations or teams
- Single OIDC configuration for all users
Proposed Solution
Tenant Identification
Option 1: Subdomain-based
tenant1.seed.example.com
tenant2.seed.example.comOption 2: Path-based
seed.example.com/tenant1/mcp
seed.example.com/tenant2/mcpOption 3: Header-based
X-Tenant-ID: tenant1Implementation Components
Note: Redis is included in the local development environment when using ./scripts/local (part of the Docker stack).
1. Tenant Management
Create src/services/tenant-store.ts:
interface Tenant {
id: string;
name: string;
domain?: string;
oidcIssuer: string;
oidcAudience: string;
createdAt: Date;
status: 'active' | 'suspended';
}
export class TenantStore {
async getTenant(tenantId: string): Promise<Tenant | null>;
async createTenant(tenant: Tenant): Promise<void>;
async listTenants(): Promise<Tenant[]>;
}2. Tenant-Scoped Client Registration
Update src/services/client-store.ts:
// Current: redis:client:{clientId}
// Proposed: redis:tenant:{tenantId}:client:{clientId}
export class ClientStore {
async register(tenantId: string, clientId: string, metadata: ClientMetadata): Promise<void> {
const key = `tenant:${tenantId}:client:${clientId}`;
await this.redis.setex(key, this.ttl, JSON.stringify(metadata));
}
}3. Tenant-Scoped Sessions
Update src/services/session-store.ts:
// Current: redis:session:{sessionId}
// Proposed: redis:tenant:{tenantId}:session:{sessionId}
export class SessionStore {
async createSession(tenantId: string, sessionId: string, userId: string): Promise<void> {
const key = `tenant:${tenantId}:session:${sessionId}`;
await this.redis.setex(key, this.sessionTtl, JSON.stringify({ userId, tenantId }));
}
}4. Tenant Middleware
Create src/middleware/tenant.ts:
import { Request, Response, NextFunction } from 'express';
export function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
// Extract tenant from subdomain, path, or header
const tenantId = extractTenantId(req);
if (!tenantId) {
return res.status(400).json({ error: 'Missing tenant identifier' });
}
// Validate tenant exists and is active
const tenant = await tenantStore.getTenant(tenantId);
if (!tenant || tenant.status !== 'active') {
return res.status(404).json({ error: 'Tenant not found or inactive' });
}
// Attach tenant to request
req.tenant = tenant;
next();
}5. Per-Tenant OIDC Configuration
Each tenant can have their own OIDC provider:
interface TenantOIDCConfig {
issuer: string;
jwksUri: string;
audience: string;
tokenUrl: string;
authorizationUrl: string;
}
// Cache JWKS per tenant
const jwksCache = new Map<string, JwksClient>();Database Schema
Redis Structure
tenant:{tenantId}:client:{clientId} → Client metadata
tenant:{tenantId}:session:{sessionId} → Session data
tenant:{tenantId}:rate-limit:{key} → Rate limit counters
tenant:metadata:{tenantId} → Tenant configurationTenant Metadata
{
"id": "acme-corp",
"name": "ACME Corporation",
"domain": "acme.seed.example.com",
"oidc": {
"issuer": "https://auth.acme.com",
"audience": "seed-mcp-acme",
"jwksUri": "https://auth.acme.com/.well-known/jwks.json"
},
"limits": {
"maxClients": 100,
"maxSessions": 1000,
"rateLimit": 1000
},
"status": "active",
"createdAt": "2026-01-05T00:00:00Z"
}Configuration
Add to .env.example:
# Multi-Tenancy
MULTI_TENANT_ENABLED=false
TENANT_IDENTIFICATION=subdomain # subdomain | path | header
TENANT_ADMIN_API_KEY=secret-key-for-tenant-managementAPI Endpoints
Tenant Management API
POST /admin/tenants Create new tenant
GET /admin/tenants List all tenants
GET /admin/tenants/:id Get tenant details
PATCH /admin/tenants/:id Update tenant
DELETE /admin/tenants/:id Delete tenant (cascade)Example:
curl -X POST https://seed.example.com/admin/tenants \
-H "X-Admin-API-Key: secret" \
-H "Content-Type: application/json" \
-d '{
"id": "acme-corp",
"name": "ACME Corporation",
"domain": "acme.seed.example.com",
"oidc": {
"issuer": "https://auth.acme.com",
"audience": "seed-mcp-acme"
}
}'Migration Strategy
Phase 1: Add Tenant Support (Backward Compatible)
- Add tenant middleware (default tenant:
default) - Update Redis keys to include tenant prefix
- Existing deployments use
tenant:default:*namespace
Phase 2: Enable Multi-Tenancy
- Enable
MULTI_TENANT_ENABLED=true - Create additional tenants via admin API
- Route requests based on subdomain/path/header
Phase 3: Migrate Existing Data
# Script to migrate existing Redis keys
for key in $(redis-cli keys "client:*"); do
redis-cli rename "$key" "tenant:default:$key"
doneSecurity Considerations
Tenant Isolation
- Data Isolation: Redis namespaces prevent cross-tenant access
- Session Isolation: Sessions cannot be shared between tenants
- Client Isolation: OAuth clients scoped to tenant
- Rate Limit Isolation: Each tenant has independent rate limits
Tenant Validation
- Validate tenant on every request
- Prevent tenant enumeration via timing attacks
- Audit tenant access attempts
Admin API Security
- Require strong API key authentication
- Rate limit admin endpoints aggressively
- Log all tenant management operations
Challenges & Trade-offs
Complexity
- Adds significant complexity to codebase
- More error scenarios to handle
- Harder to debug issues
Performance
- Additional Redis lookups per request (tenant validation)
- JWKS cache per tenant (memory usage)
- More complex rate limiting logic
Maintenance
- Schema migrations more complex
- Testing requires multi-tenant scenarios
- Monitoring needs per-tenant metrics
Use Cases
SaaS Deployment
Multiple organizations using a shared Seed instance:
- Company A uses
companya.seed.example.com - Company B uses
companyb.seed.example.com - Each has their own OIDC provider
Department Isolation
Single organization with department-level isolation:
- Engineering:
eng.seed.corp.com - Sales:
sales.seed.corp.com - Finance:
finance.seed.corp.com
Development Environments
Separate tenants for different environments:
dev.seed.example.comstaging.seed.example.comprod.seed.example.com
Alternative Approaches
Multiple Deployments
Instead of multi-tenancy, deploy separate Seed instances:
Pros:
- Complete isolation
- Simpler codebase
- Independent scaling
Cons:
- Higher infrastructure costs
- More operational overhead
- Harder to manage at scale
Recommended Approach
For most use cases, deploy separate instances rather than implementing multi-tenancy unless:
- You're building a SaaS platform
- You need to support 10+ tenants
- Infrastructure cost is a primary concern
Testing
Unit Tests
describe('Tenant Middleware', () => {
it('should extract tenant from subdomain', () => {
const req = { hostname: 'acme.seed.example.com' };
expect(extractTenantId(req)).toBe('acme');
});
it('should return 404 for unknown tenant', async () => {
const req = { hostname: 'unknown.seed.example.com' };
const res = await tenantMiddleware(req, mockRes, mockNext);
expect(res.status).toBe(404);
});
});Integration Tests
describe('Multi-Tenant Client Registration', () => {
it('should isolate clients between tenants', async () => {
// Register client for tenant A
await clientStore.register('tenant-a', 'client-1', metadata);
// Verify tenant B cannot access it
const client = await clientStore.get('tenant-b', 'client-1');
expect(client).toBeNull();
});
});Open Questions
- Billing Integration: How to track usage per tenant for billing?
- Tenant Limits: Should we enforce per-tenant quotas?
- Cross-Tenant Features: Any scenarios where tenants need to interact?
- Tenant Deletion: How to handle cascade deletion of tenant data?
- Metrics: Should Prometheus metrics be per-tenant or global?
Related Enhancements
- Enhanced Rate Limiting - Per-tenant rate limits
- Audit Logging - Tenant-scoped audit trails
- RBAC - Tenant admin roles