Topic: mcp server multi-tenant security

MCP server multi-tenant security — tenant isolation and data segregation

A multi-tenant MCP server — one instance serving many organizations or users — has a cross-tenant data leakage risk that single-tenant deployments don't. The standard web application isolation patterns (database row-level security, API scoping by organization ID) apply, but MCP adds two unique failure modes: tool output leakage into shared caches (the response to tenant A's query is cached and returned to tenant B's identical query) and LLM context contamination (if conversation context from one tenant persists across sessions and is included in another tenant's tool call, data crosses tenancy boundaries without any database access control failure). This page covers the four isolation layers and their implementation.

Layer 1: Data access isolation

Every tool that reads or writes tenant-scoped data must enforce a tenant boundary derived from the authenticated session — not from LLM arguments. The tenant ID the LLM passes in a tool argument cannot be trusted as an authorization claim.

// WRONG: tenant ID from LLM argument — prompt injection can access any tenant's data
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { tenantId, noteId } = request.params.arguments
  // LLM can set tenantId = "other-org-123" — no verification
  const note = await db.query('SELECT * FROM notes WHERE id = ? AND tenant_id = ?', [noteId, tenantId])
  return { content: [{ type: 'text', text: JSON.stringify(note) }] }
})

// CORRECT: tenant ID from authenticated session token — not from LLM arguments
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
  // Session context established at connection time from the auth token
  const tenantId = extra.sessionContext.tenantId  // from verified JWT, not from args
  const { noteId } = request.params.arguments     // only resource identifiers from args

  const note = await db.query(
    'SELECT * FROM notes WHERE id = ? AND tenant_id = ?',
    [noteId, tenantId]  // tenantId always from session, never from LLM
  )
  return { content: [{ type: 'text', text: JSON.stringify(note) }] }
})

Where your database supports it, row-level security (Postgres RLS) provides a defense-in-depth layer: even if application code fails to include the tenant filter, the database rejects the query. Set the tenant context for each connection via SET app.tenant_id = '...' and define RLS policies that enforce the filter at the database level.

Layer 2: Tool output cache isolation

Caching tool results — to reduce latency or upstream API costs — is a common optimization. The isolation failure mode: a cache keyed only on the tool name and arguments will return tenant A's data to tenant B if they call the same tool with the same arguments.

// WRONG: cache key doesn't include tenant — cross-tenant cache hit possible
async function getCachedToolResult(toolName: string, args: Record<string, unknown>) {
  const cacheKey = `tool:${toolName}:${JSON.stringify(args)}`
  const cached = await redis.get(cacheKey)
  if (cached) return JSON.parse(cached)
  const result = await executeTool(toolName, args)
  await redis.setex(cacheKey, 300, JSON.stringify(result))
  return result
}

// CORRECT: tenant ID is always part of the cache key
async function getCachedToolResult(
  toolName: string,
  args: Record<string, unknown>,
  tenantId: string  // from session, not from args
) {
  const cacheKey = `tool:${tenantId}:${toolName}:${JSON.stringify(args)}`
  const cached = await redis.get(cacheKey)
  if (cached) return JSON.parse(cached)
  const result = await executeTool(toolName, args, tenantId)
  await redis.setex(cacheKey, 300, JSON.stringify(result))
  return result
}

Layer 3: Session isolation

MCP sessions carry context: the current tool call state, any resources the LLM has accessed, and the conversation history that informs subsequent tool calls. In a multi-tenant server, sessions must be completely isolated — no shared mutable state between sessions of different tenants, even if they run concurrently on the same process.

// WRONG: shared module-level state accumulates across sessions
const sharedToolResults: Record<string, any> = {}  // all sessions write here

// CORRECT: per-session state stored in the session context, cleaned up on disconnect
const sessions = new Map<string, SessionContext>()

interface SessionContext {
  tenantId: string
  toolResults: Record<string, any>
  createdAt: Date
}

server.on('connect', (transport, sessionId) => {
  const tenantId = extractTenantFromTransportAuth(transport)
  sessions.set(sessionId, { tenantId, toolResults: {}, createdAt: new Date() })
})

server.on('disconnect', (sessionId) => {
  sessions.delete(sessionId)  // critical: clear all session state on disconnect
})

Layer 4: Preventing LLM context contamination

The subtlest multi-tenant isolation failure: if a tool caches or logs its outputs in a shared location that another tenant's LLM can access — through resource listing, search, or context injection — data crosses tenancy boundaries without any database security failure. The attack vector is the LLM's context window, not the data store.

// WRONG: tool results written to shared log accessible to all tenants' LLMs
async function executeToolWithLogging(toolName: string, args: any, result: any) {
  // Any tenant's LLM that calls list_recent_tool_outputs sees all tenants' results
  await sharedToolLog.append({ toolName, args, result, timestamp: new Date() })
  return result
}

// CORRECT: tenant-scoped log — LLMs can only access their own tenant's history
async function executeToolWithLogging(toolName: string, args: any, result: any, tenantId: string) {
  await redis.lpush(
    `tool-log:${tenantId}`,   // tenant-scoped key
    JSON.stringify({ toolName, args, result: RESULT_SUMMARY_ONLY, timestamp: new Date() })
    // store a summary, not the full result — reduces blast radius of any key access error
  )
  await redis.expire(`tool-log:${tenantId}`, 86400)  // TTL: log entries expire after 24h
  return result
}

The additional defense here — storing a result summary rather than the full result — is important: even with tenant-scoped keys, a Redis misconfiguration that exposes all keys leaks summaries (tool names + timestamps) rather than the full sensitive output. Defense in depth applies to the log content itself, not just the key structure.

What SkillAudit checks

See also

Check your multi-tenant server for isolation and access control findings.

Run a free audit → How grading works →