Topic: mcp server zero-knowledge

MCP server zero-knowledge design — building servers that cannot leak what they never see

Most MCP server security discussions focus on preventing the server from leaking data it holds (SSRF, credential echoes, prompt injection). Zero-knowledge design takes a different approach: build the server so it never holds the plaintext in the first place. A server that only processes encrypted blobs cannot leak your users' data even under full compromise — there is no plaintext to exfiltrate. This matters most for MCP servers that store personal data, financial records, healthcare information, or any content where user confidentiality is a product guarantee.

Pattern 1 — client-side encryption before transmission

In this pattern, the agent (running on the user's machine) encrypts content before sending it to the MCP server. The server stores the ciphertext; only the agent's local key can decrypt it. The server never sees the plaintext — not even in transit (the payload is encrypted above TLS).

// Client-side (runs in the agent environment, not on the server)
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';

// Key is stored only in the agent's local key store — never sent to server
const KEY = Buffer.from(process.env.USER_ENCRYPTION_KEY!, 'hex');  // 32-byte AES-256 key

export function encrypt(plaintext: string): string {
  const iv = randomBytes(12);
  const cipher = createCipheriv('aes-256-gcm', KEY, iv);
  const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
  const tag = cipher.getAuthTag();
  // Encode as iv:tag:ciphertext for storage
  return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`;
}

export function decrypt(payload: string): string {
  const [ivHex, tagHex, ciphertextHex] = payload.split(':');
  const iv = Buffer.from(ivHex, 'hex');
  const tag = Buffer.from(tagHex, 'hex');
  const ciphertext = Buffer.from(ciphertextHex, 'hex');
  const decipher = createDecipheriv('aes-256-gcm', KEY, iv);
  decipher.setAuthTag(tag);
  return decipher.update(ciphertext).toString('utf8') + decipher.final('utf8');
}

// Usage: agent encrypts before calling the MCP tool
const encryptedNote = encrypt(noteContent);
await mcpClient.callTool('store_note', { content: encryptedNote, id: noteId });

// And decrypts after retrieving:
const retrieved = await mcpClient.callTool('get_note', { id: noteId });
const plaintext = decrypt(retrieved.content[0].text);

The server-side store_note tool stores whatever string it receives — it never sees the plaintext. The server operator can read the database, dump the storage, or be compromised — there is no plaintext content to extract. The cryptographic guarantee is that AES-256-GCM encryption with a per-note random IV and a key that never leaves the agent environment is computationally infeasible to break.

Pattern 2 — blind indexing for searchable encrypted fields

Pure client-side encryption breaks search: you cannot query an encrypted database for records that match a keyword, because the server does not know the keyword's plaintext. Blind indexing solves this by storing an HMAC of the plaintext alongside the ciphertext — the server can match HMAC(search_term) against stored HMACs without ever seeing the search term.

import { createHmac } from 'crypto';

// Separate key from the encryption key — compromise of one doesn't compromise the other
const INDEX_KEY = Buffer.from(process.env.USER_INDEX_KEY!, 'hex');  // 32-byte HMAC key

// Client generates the blind index for a term before storing
function blindIndex(term: string): string {
  // Normalize: lowercase, trim whitespace for consistent matching
  const normalized = term.toLowerCase().trim();
  return createHmac('sha256', INDEX_KEY).update(normalized).digest('hex');
}

// Storing an encrypted note with a searchable title:
const encryptedContent = encrypt(noteContent);
const titleIndex = blindIndex(noteTitle);   // HMAC, not the title

await mcpClient.callTool('store_note', {
  content: encryptedContent,
  title_index: titleIndex,   // stored for search
  id: noteId
});

// Searching by title — the server matches blind indexes:
const searchIndex = blindIndex(searchQuery);
const results = await mcpClient.callTool('search_notes', { title_index: searchIndex });
// Server returns encrypted content for matching records; client decrypts

The limitation: blind indexing only works for exact-match and prefix-match queries. Substring search and fuzzy matching require different approaches (homomorphic encryption for exact, or client-side retrieval and search for fuzzy). For most personal-knowledge-base use cases, exact-match on indexed fields plus full-text search over client-decrypted content is sufficient.

Pattern 3 — ephemeral processing (no plaintext retention)

For MCP servers that transform or process content (document conversion, image processing, text analysis) without needing to store it, the zero-knowledge guarantee is simpler: never persist the input or output beyond the lifetime of the request.

// Server-side: ephemeral document processing
server.tool('analyze_document', async ({ content }) => {
  // Process in memory — never write to disk, never log content
  const analysis = await runAnalysis(content);

  // Return result immediately — do not cache content or result
  const result = {
    word_count: analysis.wordCount,
    language: analysis.language,
    sensitivity_flags: analysis.sensitivityFlags  // metadata only, not content
  };

  // Explicit cleanup: if analysis allocated large buffers, clear them
  analysis.dispose?.();

  return { content: [{ type: 'text', text: JSON.stringify(result) }] };
});

// What NOT to do:
// - Writing content to a temp file that persists after the request
// - Caching analysis results keyed by content hash (the hash leaks content shape)
// - Logging the content value in any structured log field
// - Passing content to a third-party analytics API

Ephemeral processing does not protect against a compromised server reading content in memory during processing — that requires Trusted Execution Environments (TEEs) or equivalent, which is beyond the scope of MCP server security. The guarantee is narrower: content is not recoverable after the request completes from any persistent store the operator controls (database, logs, cache).

What SkillAudit's documentation axis checks for ZK pattern

SkillAudit does not directly verify zero-knowledge properties (they require runtime analysis of cryptographic key handling). But the documentation axis checks that the server's README explicitly documents its data retention and encryption model — because a server that does not document whether it stores user content plaintext is a trust gap that buyers cannot bridge without reading every line of source code. If your MCP server stores user data, the README should state: what is encrypted, with whose key, and what the server operator can and cannot access.

Check your server's documentation grade at skillaudit.dev.