Guide · Vector Databases

MCP Tools for ChromaDB — embedded vs client/server mode, no-auth local, string IDs, distance metrics, embedding functions

ChromaDB has three properties that make it unusual among vector databases: it runs in two fundamentally different operational modes that share the same Python API — embedded in-process (no network, no server, direct file access) and client/server (network calls to a running Chroma server) — and code that runs fine locally against the embedded client will silently fail if deployed against a server that requires authentication; all vector IDs in ChromaDB must be strings — unlike Qdrant (which accepts uint64 or UUID) or Pinecone (which accepts any string), Chroma will raise a validation error if you pass integers, which affects migration from numeric-ID databases; and ChromaDB ships with a default embedding function (sentence-transformers/all-MiniLM-L6-v2, runs locally) that pulls a 90MB model on first use — in a Docker container or serverless MCP server this causes a slow first-call surprise if you haven't pre-downloaded the model or switched to an API-backed embedding function.

TL;DR

Local embedded: chromadb.PersistentClient(path="./chroma"). Remote: chromadb.HttpClient(host, port, headers={"Authorization": "Bearer {token}"}). Create collection: client.get_or_create_collection(name, embedding_function=ef, metadata={"hnsw:space": "cosine"}). Add: collection.add(ids=[...], embeddings=[...], metadatas=[...], documents=[...]). Query: collection.query(query_embeddings=[...], n_results=10, where={"category": "article"}). Health: GET /api/v1/heartbeat{"nanosecond heartbeat": N}.

Client modes — embedded vs HttpClient

ChromaDB's three client types have distinct operational characteristics. chromadb.EphemeralClient() stores all data in memory — data is lost when the process exits. chromadb.PersistentClient(path="./chroma") stores data on disk at the given path — fast for development, but only one process can have the database open at a time (exclusive write lock). chromadb.HttpClient(host, port) connects to a separate Chroma server process — required for multi-process access and production deployments.

import chromadb
from chromadb.config import Settings

# Development: persistent local storage
# WARNING: only one process can open this path at a time
client = chromadb.PersistentClient(path="./chroma-db")

# Production: remote server (chromadb run --host 0.0.0.0 --port 8000)
client = chromadb.HttpClient(
    host=os.environ["CHROMA_HOST"],
    port=int(os.environ.get("CHROMA_PORT", "8000")),
    # Default Chroma server has no auth — add token auth if you've configured it:
    # headers={"Authorization": f"Bearer {os.environ['CHROMA_TOKEN']}"}
    # settings=Settings(chroma_client_auth_provider="chromadb.auth.TokenAuthClientProvider",
    #                   chroma_client_auth_credentials=os.environ["CHROMA_TOKEN"])
)

# JavaScript / Node.js client
import { ChromaClient } from 'chromadb';
const client = new ChromaClient({
  path: process.env.CHROMA_URL ?? 'http://localhost:8000',
  // auth: { provider: 'TokenAuthClientProvider', credentials: process.env.CHROMA_TOKEN }
});

The same collection.add(), collection.query(), and collection.delete() calls work identically against all three client types — the operational mode is encapsulated in the client object. This makes it straightforward to develop locally against PersistentClient and deploy against HttpClient by swapping the client initialization.

Embedding functions — default model, OpenAI, and bring-your-own

ChromaDB collections are created with an embedding function that automatically generates vectors when you call collection.add(documents=[...]) or collection.query(query_texts=[...]). The default function is DefaultEmbeddingFunction, which downloads and runs the sentence-transformers/all-MiniLM-L6-v2 model locally (90MB, 384 dimensions). For MCP servers that connect to external embedding APIs, use OpenAIEmbeddingFunction or CohereEmbeddingFunction to avoid the local model dependency.

from chromadb.utils import embedding_functions

# Option 1: OpenAI embeddings (text-embedding-3-small = 1536-dim, text-embedding-3-large = 3072-dim)
openai_ef = embedding_functions.OpenAIEmbeddingFunction(
    api_key=os.environ["OPENAI_API_KEY"],
    model_name="text-embedding-3-small",
)

# Option 2: provide embeddings yourself and skip the embedding function
# Pass embeddings= directly to add() and query() — embedding_function becomes a no-op
# This is the most flexible approach for MCP servers that pre-compute embeddings

# Option 3: default local model (development only — avoid in production containers)
# from chromadb.utils.embedding_functions import DefaultEmbeddingFunction
# default_ef = DefaultEmbeddingFunction()

# Create collection with embedding function
collection = client.get_or_create_collection(
    name="documents",
    embedding_function=openai_ef,
    metadata={
        "hnsw:space": "cosine",    # l2 (default) | cosine | ip (inner product)
    },
)

# Embedding function is used automatically when you pass documents= or query_texts=
collection.add(
    ids=["doc-001", "doc-002"],
    documents=["Authentication bearer tokens...", "OAuth2 client credentials..."],
    metadatas=[{"category": "security"}, {"category": "security"}],
)

# OR provide embeddings manually — embedding_function is ignored when embeddings= is present
collection.add(
    ids=["doc-003"],
    embeddings=[[0.1, 0.2, ...]],  # your pre-computed vector
    documents=["Optional: store the raw text alongside the vector"],
    metadatas=[{"category": "security"}],
)

The distance metric (hnsw:space) must be set at collection creation time and cannot be changed. l2 (Euclidean distance) is the default. For embeddings from OpenAI or Cohere — which are normalized to unit length — cosine and ip (inner product) give equivalent results and are faster. Choose cosine for a consistent 0–2 distance range in returned scores.

String IDs — not integers, not UUIDs

ChromaDB requires all IDs to be strings. Unlike Qdrant (which natively accepts uint64 and UUID) or Pinecone (which accepts arbitrary strings), passing a Python integer or a list with mixed types raises a validation error. When migrating from a database that uses numeric primary keys, convert IDs to strings before inserting: str(record.id).

# WRONG — integer IDs raise ValueError: Expected IDs to be strings
collection.add(ids=[1, 2, 3], embeddings=[...])

# CORRECT — convert integers to strings
record_ids = [101, 102, 103]
collection.add(
    ids=[str(rid) for rid in record_ids],  # ["101", "102", "103"]
    embeddings=[...],
)

# IDs must be unique within a collection — attempting to add a duplicate ID
# without calling update() first raises a DuplicateIDError
try:
    collection.add(ids=["doc-001"], embeddings=[...])
except chromadb.errors.DuplicateIDError:
    # Use upsert instead of add when IDs may already exist
    collection.upsert(ids=["doc-001"], embeddings=[...])

# Fetch by ID
results = collection.get(ids=["doc-001", "doc-002"], include=["embeddings", "documents", "metadatas"])

ChromaDB's add() raises DuplicateIDError if any ID already exists — it is not idempotent. Use upsert() instead of add() when IDs may already exist (e.g., reprocessing pipelines, retry logic). upsert() creates new entries or overwrites existing ones, making it safe to call multiple times with the same data.

Query — where filters, n_results, and return fields

ChromaDB's query() method supports both vector search and metadata filtering. The where filter applies to metadata fields using a subset of MongoDB-style operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $and, and $or. The where_document filter applies to the stored document text using $contains and $not_contains.

# Query with metadata filter and document text filter
results = collection.query(
    query_embeddings=[query_vector],   # list of query vectors (one per query)
    n_results=10,
    where={
        "$and": [
            {"category": {"$eq": "security"}},
            {"score": {"$gte": 0.5}},
            {"language": {"$in": ["en", "de"]}},
        ]
    },
    where_document={"$contains": "OAuth2"},  # filter by document text content
    include=["documents", "metadatas", "distances", "embeddings"],
    # include: list of what to return — defaults to ["documents", "metadatas", "distances"]
    # omit "embeddings" to reduce response size unless you need the raw vectors
)

# results structure:
# {
#   "ids": [["doc-003", "doc-007", ...]],         # list of lists (one per query)
#   "distances": [[0.12, 0.18, ...]],             # lower = more similar for l2
#   "documents": [["OAuth2 flow...", ...]],
#   "metadatas": [[{"category": "security"}, ...]],
# }

# Query by text (uses embedding_function to convert to vector automatically)
text_results = collection.query(
    query_texts=["how to implement bearer token auth"],
    n_results=5,
)

The distances field in ChromaDB results is a raw distance value, not a similarity score. For l2 space: smaller distance = more similar, minimum 0. For cosine space: smaller distance = more similar, range 0–2 (0 = identical, 2 = opposite). For ip space: smaller (more negative) = more similar. This is the opposite convention from Pinecone's score (higher = more similar) and Qdrant's score (higher = more similar) — normalize when building cross-database abstractions.

client.reset() — destroys all data, never call in production

ChromaDB has a client.reset() method that deletes all collections and all data in the current client's database. It exists for testing convenience — resetting state between test runs. In production, calling reset() is catastrophic and unrecoverable. The method is enabled on all client types including HttpClient pointing to production servers. Guard against accidental calls with an environment check.

# NEVER call in production — destroys all collections and data immediately
# Wrap any reset() call with an explicit environment guard:
def safe_reset(client: chromadb.ClientAPI) -> None:
    if os.environ.get("ALLOW_CHROMA_RESET") != "true":
        raise RuntimeError(
            "client.reset() is disabled. Set ALLOW_CHROMA_RESET=true to enable (test environments only)"
        )
    client.reset()

# For test cleanup, prefer deleting specific collections instead:
# client.delete_collection("test-collection")
# This is targeted and does not affect other collections

ChromaDB's HttpClient exposes reset() over the network — if your Chroma server is accessible to your MCP server, it is accessible to any code that can instantiate an HttpClient with the same credentials. Add server-side protection by setting CHROMA_SERVER_AUTHN_CREDENTIALS and restricting reset in your deployment's access control if multiple tenants or services share a Chroma instance.

Health probe — /api/v1/heartbeat and collection count

Chroma's server exposes GET /api/v1/heartbeat which returns a JSON body with a nanosecond timestamp — a 200 response confirms the server is alive. The endpoint does not require authentication even when auth is configured. For deeper health, check that specific collections exist and are non-empty.

import fetch from 'node-fetch';

async function checkChromaHealth(chromaUrl: string): Promise<{
  alive: boolean;
  version?: string;
  collectionCount?: number;
}> {
  // Liveness: no auth required
  const heartbeatRes = await fetch(`${chromaUrl}/api/v1/heartbeat`);
  if (!heartbeatRes.ok) return { alive: false };

  // Version check
  const versionRes = await fetch(`${chromaUrl}/api/v1/version`);
  const version = versionRes.ok ? (await versionRes.text()).trim() : undefined;

  // Collection count (requires auth if auth is enabled)
  const listRes = await fetch(`${chromaUrl}/api/v1/collections`, {
    headers: process.env.CHROMA_TOKEN
      ? { Authorization: `Bearer ${process.env.CHROMA_TOKEN}` }
      : {},
  });
  const collections = listRes.ok ? await listRes.json() : [];

  return {
    alive: true,
    version,
    collectionCount: collections.length,
  };
}

ChromaDB's REST API is versioned at /api/v1. The Python client and JavaScript client both translate method calls into these REST endpoints — you can use curl to debug any Chroma operation by inspecting the client's HTTP calls. Run the Chroma server with CHROMA_LOG_LEVEL=DEBUG to see the full request/response log, which is useful for diagnosing filter syntax errors that the client library error messages describe opaquely.

Related guides