arrow_backBack to blog
·12 min·Lobbi

How to Build Secure MCP Tools Using Zero-Knowledge Vaults

A practical guide to building MCP tools that handle sensitive data securely. Learn how to integrate zero-knowledge encrypted vaults into your MCP server so AI agents can store and retrieve secrets without exposing plaintext to the server.

MCP securityzero-knowledge vaultsecure MCP toolsAI agent encryptionMCP server best practicesclient-side encryption MCPbuild secure AI tools

Most MCP servers have a dirty secret: they trust the server with everything.

When an AI agent calls a tool to store a file, fetch credentials, or log sensitive output, that data typically lands on the server in plaintext. The server operator — whether that's you, your cloud provider, or a third-party SaaS — can read it all. For agentic workflows handling medical records, financial data, API keys, or personal documents, this is a non-starter.

In this guide, we'll walk through how to build MCP tools that use zero-knowledge vaults — encrypted storage where the server never sees the plaintext. Your agent encrypts before upload and decrypts after download. The server is just a dumb pipe for ciphertext.

The Trust Problem in MCP

The Model Context Protocol gives AI agents a standardized way to call external tools. A typical MCP tool for file storage looks like this:

server.tool("store_file", {
  name: z.string(),
  content: z.string(),
}, async ({ name, content }) => {
  // ❌ Server receives plaintext content
  await storage.put(name, content);
  return { success: true };
});

This works, but now the server has the file contents. If the server is compromised, all stored data is exposed. If a rogue operator reads the logs, they see everything. There's no cryptographic guarantee of privacy.

Zero-knowledge changes this. The encryption and decryption happen entirely on the client side (inside the MCP server process running on the user's machine). The remote storage backend only ever receives ciphertext.

Architecture: Where Encryption Lives

Here's the key insight: in an MCP setup, the MCP server runs locally on the user's machine (or in a trusted environment). The remote storage API is the untrusted party. So we encrypt in the MCP server before sending data to the API.

┌─────────────────────────────────────────────────┐
│  User's Machine (trusted boundary)              │
│                                                 │
│  ┌──────────┐    ┌──────────────────────┐       │
│  │ AI Agent │───▶│ MCP Server           │       │
│  │ (Claude, │    │  ┌────────────────┐  │       │
│  │  Cursor) │    │  │ Encryption SDK │  │       │
│  └──────────┘    │  │ (AES-256-GCM)  │  │       │
│                  │  └───────┬────────┘  │       │
│                  └──────────┼───────────┘       │
└─────────────────────────────┼───────────────────┘
                              │ ciphertext only
                              ▼
                    ┌───────────────────┐
                    │  Remote Storage   │
                    │  (BitAtlas API)   │
                    │  🔒 zero-knowledge│
                    └───────────────────┘

The remote API never receives encryption keys. It stores opaque blobs. Even if the API database is fully dumped, attackers get nothing useful.

Step 1: Set Up Client-Side Encryption

We'll use the Web Crypto API (available in Node.js 18+) for AES-256-GCM encryption. This is the same primitive BitAtlas uses in production.

import { webcrypto } from 'node:crypto';

const { subtle } = webcrypto;

// Derive an encryption key from a passphrase
async function deriveKey(
  passphrase: string,
  salt: Uint8Array
): Promise<CryptoKey> {
  const encoder = new TextEncoder();
  const keyMaterial = await subtle.importKey(
    'raw',
    encoder.encode(passphrase),
    'PBKDF2',
    false,
    ['deriveKey']
  );

  return subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt,
      iterations: 600_000, // OWASP 2024 recommendation
      hash: 'SHA-256',
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}

Key points:

  • 600,000 PBKDF2 iterations — follows OWASP's 2024 guidance for password-derived keys
  • AES-256-GCM — authenticated encryption that detects tampering
  • The CryptoKey object never leaves the local process

Step 2: Encrypt Before Storage

Every file gets a unique initialization vector (IV) and salt. The IV and salt are prepended to the ciphertext — they're not secret, but they must be unique per encryption operation.

async function encrypt(
  plaintext: string,
  passphrase: string
): Promise<Uint8Array> {
  const encoder = new TextEncoder();
  const salt = webcrypto.getRandomValues(new Uint8Array(16));
  const iv = webcrypto.getRandomValues(new Uint8Array(12));
  const key = await deriveKey(passphrase, salt);

  const ciphertext = await subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    encoder.encode(plaintext)
  );

  // Pack: [salt(16) | iv(12) | ciphertext(...)]
  const packed = new Uint8Array(
    salt.length + iv.length + ciphertext.byteLength
  );
  packed.set(salt, 0);
  packed.set(iv, salt.length);
  packed.set(new Uint8Array(ciphertext), salt.length + iv.length);

  return packed;
}

Step 3: Build the MCP Tool

Now wrap this in an MCP tool definition. The agent calls secure_store and secure_retrieve — encryption is invisible to the LLM.

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

const server = new McpServer({
  name: 'secure-vault',
  version: '1.0.0',
});

// The passphrase is loaded from a local env var — never sent to the API
const VAULT_KEY = process.env.VAULT_PASSPHRASE;

server.tool(
  'secure_store',
  'Encrypt and store a file in the zero-knowledge vault',
  {
    path: z.string().describe('File path in the vault'),
    content: z.string().describe('File content to encrypt and store'),
  },
  async ({ path, content }) => {
    const encrypted = await encrypt(content, VAULT_KEY);
    
    // Upload ciphertext to remote API
    await fetch('https://api.bitatlas.com/v1/files', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.BITATLAS_API_KEY}`,
        'Content-Type': 'application/octet-stream',
        'X-File-Path': path,
      },
      body: encrypted,
    });

    return {
      content: [{ type: 'text', text: `✅ Encrypted and stored: ${path}` }],
    };
  }
);

server.tool(
  'secure_retrieve',
  'Retrieve and decrypt a file from the zero-knowledge vault',
  {
    path: z.string().describe('File path to retrieve'),
  },
  async ({ path }) => {
    const res = await fetch(
      `https://api.bitatlas.com/v1/files?path=${encodeURIComponent(path)}`,
      {
        headers: {
          'Authorization': `Bearer ${process.env.BITATLAS_API_KEY}`,
        },
      }
    );
    
    const ciphertext = new Uint8Array(await res.arrayBuffer());
    const plaintext = await decrypt(ciphertext, VAULT_KEY);

    return {
      content: [{ type: 'text', text: plaintext }],
    };
  }
);

The agent sees simple secure_store and secure_retrieve tools. It has no idea encryption is happening. That's the point — security should be invisible to the AI.

Step 4: Decrypt on Retrieval

async function decrypt(
  packed: Uint8Array,
  passphrase: string
): Promise<string> {
  const salt = packed.slice(0, 16);
  const iv = packed.slice(16, 28);
  const ciphertext = packed.slice(28);

  const key = await deriveKey(passphrase, salt);

  const plaintext = await subtle.decrypt(
    { name: 'AES-GCM', iv },
    key,
    ciphertext
  );

  return new TextDecoder().decode(plaintext);
}

GCM mode gives us authenticated decryption — if a single bit of the ciphertext has been tampered with, decrypt throws an error. This protects against both data corruption and malicious modification by a compromised server.

Step 5: Key Management Best Practices

The passphrase (or master key) is the crown jewel. Here's how to handle it:

Option A: Environment Variable (Simple)

# .env (never committed to git)
VAULT_PASSPHRASE=your-256-bit-secret-here

Option B: OS Keychain (Better)

import keytar from 'keytar';

async function getVaultKey(): Promise<string> {
  let key = await keytar.getPassword('bitatlas-vault', 'master');
  if (!key) {
    key = webcrypto.randomUUID() + webcrypto.randomUUID();
    await keytar.setPassword('bitatlas-vault', 'master', key);
  }
  return key;
}

Option C: Hardware Key (Best)

For production agent deployments, derive keys from a hardware security module (HSM) or a YubiKey via PKCS#11. This ensures the key material never exists in extractable form.

Never:

  • Hardcode keys in source
  • Send the passphrase to the remote API
  • Log decrypted content to server-side telemetry
  • Store keys alongside the encrypted data

Common Pitfalls

1. Encrypting metadata but not filenames

If you store a file at path /medical/diagnosis-2026.pdf, the filename itself leaks information. Encrypt the path too, or use opaque UUIDs and maintain a local encrypted index.

// Bad: path visible to server
await api.store('/medical/diagnosis-2026.pdf', ciphertext);

// Good: opaque path, encrypted index
const fileId = crypto.randomUUID();
await api.store(fileId, ciphertext);
await localIndex.set(fileId, { originalPath: '/medical/diagnosis-2026.pdf' });

2. Trusting the server's "encrypted" label

If a storage provider says "we encrypt your data," ask: who holds the key? If they do, it's encryption theater. True zero-knowledge means the provider mathematically cannot decrypt your data, because they never have the key.

3. Reusing IVs

AES-GCM with a reused IV is catastrophically broken — it leaks the XOR of two plaintexts. Always generate a fresh random IV for every encryption operation. Our encrypt() function above does this correctly.

4. Skipping authentication

AES-CBC without HMAC allows bit-flipping attacks. GCM includes authentication by default. If you must use CBC for compatibility, always pair it with HMAC-SHA256 in an Encrypt-then-MAC construction.

Testing Your Secure MCP Tool

Write a round-trip test to verify the encryption pipeline:

import { describe, it, expect } from 'vitest';

describe('zero-knowledge vault', () => {
  it('encrypts and decrypts round-trip', async () => {
    const original = 'sensitive medical record: patient #12345';
    const passphrase = 'test-key-not-for-production';
    
    const encrypted = await encrypt(original, passphrase);
    
    // Verify it's not plaintext
    const asString = new TextDecoder().decode(encrypted);
    expect(asString).not.toContain('sensitive');
    expect(asString).not.toContain('medical');
    
    // Verify round-trip
    const decrypted = await decrypt(encrypted, passphrase);
    expect(decrypted).toBe(original);
  });

  it('fails with wrong passphrase', async () => {
    const encrypted = await encrypt('secret', 'correct-key');
    await expect(
      decrypt(encrypted, 'wrong-key')
    ).rejects.toThrow();
  });

  it('detects tampering', async () => {
    const encrypted = await encrypt('secret', 'my-key');
    // Flip a bit in the ciphertext
    encrypted[30] ^= 0xff;
    await expect(
      decrypt(encrypted, 'my-key')
    ).rejects.toThrow();
  });
});

Using BitAtlas as Your Zero-Knowledge Backend

If you'd rather not build the storage backend yourself, BitAtlas provides a ready-made zero-knowledge vault with an MCP server you can install in one command:

npm install -g @bitatlas/mcp-server

Add it to your Claude Desktop config:

{
  "mcpServers": {
    "bitatlas": {
      "command": "bitatlas-mcp",
      "env": {
        "BITATLAS_API_KEY": "your-api-key",
        "BITATLAS_ENCRYPTION_KEY": "your-local-encryption-key"
      }
    }
  }
}

The BitAtlas MCP server handles all the encryption, key derivation, folder management, and API communication. Your agent gets vault_upload, vault_download, vault_list, and vault_delete tools out of the box — all zero-knowledge encrypted.

The Bottom Line

Building secure MCP tools isn't hard. The cryptographic primitives are built into every modern runtime. The architecture is straightforward: encrypt locally, store remotely, never share keys.

What's hard is remembering to do it. The default path — sending plaintext to a server — is easier, faster, and works fine until someone breaches the server. By the time you find out, every file your agent ever stored is in the wild.

Zero-knowledge vaults make "the server got hacked" a non-event for your data. Build it in from day one.


BitAtlas is a zero-knowledge cloud vault for humans and AI agents. Get started free or check out the MCP server on npm.

Encrypt your agent's data today

BitAtlas gives your AI agents AES-256-GCM encrypted storage with zero-knowledge guarantees. Free tier, no credit card required.

Get Started Free