Skip to content

Multi-Tenancy Support

Priority: 🚫 NOT PLANNED Estimated Time: 40-60 hours Status: Not Planned

← Back to Enhancements


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.com

Option 2: Path-based

seed.example.com/tenant1/mcp
seed.example.com/tenant2/mcp

Option 3: Header-based

X-Tenant-ID: tenant1

Implementation 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:

typescript
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:

typescript
// 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:

typescript
// 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:

typescript
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:

typescript
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 configuration

Tenant Metadata

typescript
{
  "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:

bash
# Multi-Tenancy
MULTI_TENANT_ENABLED=false
TENANT_IDENTIFICATION=subdomain  # subdomain | path | header
TENANT_ADMIN_API_KEY=secret-key-for-tenant-management

API 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:

bash
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

bash
# Script to migrate existing Redis keys
for key in $(redis-cli keys "client:*"); do
  redis-cli rename "$key" "tenant:default:$key"
done

Security 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.com
  • staging.seed.example.com
  • prod.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

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

typescript
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

typescript
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

  1. Billing Integration: How to track usage per tenant for billing?
  2. Tenant Limits: Should we enforce per-tenant quotas?
  3. Cross-Tenant Features: Any scenarios where tenants need to interact?
  4. Tenant Deletion: How to handle cascade deletion of tenant data?
  5. Metrics: Should Prometheus metrics be per-tenant or global?

  • Enhanced Rate Limiting - Per-tenant rate limits
  • Audit Logging - Tenant-scoped audit trails
  • RBAC - Tenant admin roles

← Back to Enhancements

Released under the MIT License.