Guide · Vector Databases

MCP Tools for Weaviate — OIDC and API key auth, class schema design, GraphQL vs REST, hybrid search with BM25, tenant isolation

Weaviate has three architectural decisions that shape MCP tool design: authentication mode is set at instance startup, not per-request — a Weaviate instance runs in anonymous, API key, or OIDC mode, and you cannot mix modes at runtime; the data model is class-based, not collection-based — every object belongs to a class, the class definition specifies the vectorizer module (e.g., text2vec-openai) and property data types at creation time, and the vectorizer module must be enabled in the Weaviate instance's module configuration or you get a startup error when creating the class; and Weaviate exposes two distinct query interfaces — a GraphQL API for semantic and hybrid search (/v1/graphql) and a REST API for object CRUD (/v1/objects, /v1/batch) — they are not interchangeable and each has operations the other cannot perform.

TL;DR

Auth: Authorization: Bearer {token} for OIDC; X-Weaviate-Api-Key: {key} for API key auth (Weaviate Cloud). Batch import: POST /v1/batch/objects with array of objects. GraphQL search: POST /v1/graphql with Get { ClassName(nearVector: {vector: [...], certainty: 0.7}) { prop1 prop2 _additional { certainty id } } }. Health: GET /v1/.well-known/ready → HTTP 200 when ready. Hybrid search: combine nearVector with bm25 using hybrid operator and alpha weighting (0=pure BM25, 1=pure vector).

Authentication — three distinct modes, set at instance level

Weaviate instances run in one of three authentication modes configured at startup via environment variables. Anonymous mode: no auth headers needed, all requests are accepted — this is the default for self-hosted Docker deployments. API key mode: set AUTHENTICATION_APIKEY_ENABLED=true and AUTHENTICATION_APIKEY_ALLOWED_KEYS in the instance config; clients send X-Weaviate-Api-Key: {key}. OIDC mode: instance validates JWT bearer tokens against an OIDC provider (e.g., Auth0, Okta); clients send Authorization: Bearer {token}.

import fetch from 'node-fetch';

const WEAVIATE_URL = process.env.WEAVIATE_URL!;         // e.g. https://my-cluster.weaviate.network
const WEAVIATE_API_KEY = process.env.WEAVIATE_API_KEY;  // for Weaviate Cloud / API key mode
const WEAVIATE_OIDC_TOKEN = process.env.WEAVIATE_OIDC_TOKEN; // for OIDC mode

function getAuthHeaders(): Record<string, string> {
  // Weaviate Cloud (managed) uses X-Weaviate-Api-Key
  if (WEAVIATE_API_KEY) {
    return { 'X-Weaviate-Api-Key': WEAVIATE_API_KEY };
  }
  // Self-hosted with OIDC uses Authorization: Bearer
  if (WEAVIATE_OIDC_TOKEN) {
    return { 'Authorization': `Bearer ${WEAVIATE_OIDC_TOKEN}` };
  }
  // Anonymous mode — no auth headers
  return {};
}

// When using modules that call external APIs (text2vec-openai, text2vec-cohere),
// pass the inference API key in the module-specific header on each request:
// 'X-OpenAI-Api-Key': process.env.OPENAI_API_KEY  (for text2vec-openai)
// 'X-Cohere-Api-Key': process.env.COHERE_API_KEY  (for text2vec-cohere)

Weaviate Cloud (WCS) uses API key mode. When using vectorizer modules that call external embedding APIs (like text2vec-openai), the inference service API key must be forwarded in a module-specific request header — it is not stored in Weaviate's configuration. Omitting the inference key returns HTTP 500 with "API key not provided" during any operation that triggers vectorization.

Class schema — vectorizer modules and property data types

Every Weaviate class specifies a vectorizer module at creation time. The vectorizer is responsible for automatically generating embedding vectors when objects are inserted. If you set vectorizer: "none", you must provide vectors manually on every insert. The vectorizer module must be enabled in the instance's ENABLE_MODULES configuration — attempting to create a class with a module that isn't enabled returns HTTP 422.

// Create a class with text2vec-openai vectorizer (automatic embedding on insert)
const createClass = await fetch(`${WEAVIATE_URL}/v1/schema`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    ...getAuthHeaders(),
    'X-OpenAI-Api-Key': process.env.OPENAI_API_KEY!, // required for text2vec-openai
  },
  body: JSON.stringify({
    class: 'Document',        // class name MUST start with uppercase
    vectorizer: 'text2vec-openai',
    moduleConfig: {
      'text2vec-openai': {
        model: 'text-embedding-3-small',
        dimensions: 1536,
        type: 'text',
      },
    },
    properties: [
      { name: 'title',    dataType: ['text'],    moduleConfig: { 'text2vec-openai': { skip: false } } },
      { name: 'content',  dataType: ['text'],    moduleConfig: { 'text2vec-openai': { skip: false } } },
      { name: 'source',   dataType: ['text'],    moduleConfig: { 'text2vec-openai': { skip: true  } } }, // exclude from vectorization
      { name: 'publishedAt', dataType: ['date'] },
      { name: 'score',    dataType: ['number'] },
    ],
  }),
});

// Class names are case-sensitive and must start with uppercase — "document" returns 422
// Property names must start with lowercase — "Title" returns 422

Properties marked skip: true in the vectorizer's moduleConfig are stored but excluded from the text that gets embedded. This is useful for metadata fields (URLs, IDs, timestamps) that shouldn't influence semantic similarity. Changing vectorizer settings after class creation requires deleting and recreating the class — there is no schema migration path for vectorizer changes.

GraphQL queries — Get, Aggregate, and the _additional field

Weaviate's primary search interface is GraphQL. The Get operation retrieves objects with optional vector, text, or hybrid search operators. The Aggregate operation counts and groups objects without returning individual records. The _additional field on every Get query can return system metadata: id (Weaviate internal UUID), certainty (cosine similarity 0–1), distance (raw distance), vector (the embedding itself), and creationTimeUnix.

// GraphQL nearVector search — find most similar documents to a query embedding
async function nearVectorSearch(
  queryVector: number[],
  limit: number,
  certaintyThreshold = 0.7
) {
  const query = `{
    Get {
      Document(
        nearVector: {
          vector: [${queryVector.join(',')}]
          certainty: ${certaintyThreshold}
        }
        limit: ${limit}
      ) {
        title
        content
        source
        _additional {
          id
          certainty
          distance
        }
      }
    }
  }`;

  const res = await fetch(`${WEAVIATE_URL}/v1/graphql`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
    body: JSON.stringify({ query }),
  });
  const data = await res.json();
  if (data.errors) throw new Error(data.errors.map((e: any) => e.message).join('; '));
  return data.data.Get.Document;
}

// Count objects in a class with Aggregate
const countQuery = `{
  Aggregate {
    Document {
      meta { count }
    }
  }
}`;

GraphQL errors in Weaviate are returned as HTTP 200 responses with an errors array in the JSON body — not as HTTP 4xx/5xx. Always check for data.errors after a successful HTTP call. The error messages often contain actionable information about schema mismatches, missing properties, or filter syntax errors.

Hybrid search — combining vector and BM25 with alpha weighting

Weaviate's hybrid search operator combines dense vector similarity with BM25 sparse keyword search. The alpha parameter controls the weighting: alpha: 0 is pure BM25 (keyword), alpha: 1 is pure vector search, and alpha: 0.5 gives equal weight to both. Hybrid search requires the class to have both a vectorizer and BM25 indexing enabled (BM25 is enabled by default unless indexSearchable: false is set on a property).

// Hybrid search: query text drives both the embedding generation and BM25 lookup
// alpha: 0.75 = 75% vector weight, 25% BM25 weight
const hybridQuery = `{
  Get {
    Document(
      hybrid: {
        query: "authentication patterns for REST APIs"
        alpha: 0.75
        properties: ["title", "content"]   // BM25 searches only these properties
      }
      limit: 10
    ) {
      title
      content
      _additional {
        id
        score                   // hybrid fusion score (0–1)
        explainScore            # breakdown of vector vs keyword contribution
      }
    }
  }
}`;

// nearText: uses the configured vectorizer to embed the query text automatically
// useful when you don't want to generate embeddings in your MCP server
const nearTextQuery = `{
  Get {
    Document(
      nearText: {
        concepts: ["machine learning infrastructure"]
        certainty: 0.6
      }
      limit: 10
    ) {
      title
      _additional { id certainty }
    }
  }
}`;

The difference between nearText and hybrid: nearText uses the class's vectorizer to embed your query text and does pure vector search; hybrid runs both vector search (using the same vectorizer) and BM25 keyword search in parallel, then fuses the ranked lists. For most production use cases, hybrid with alpha: 0.7–0.8 outperforms pure vector search because BM25 catches exact-match keywords that semantic similarity tends to miss.

Batch import — REST API for bulk inserts

Weaviate's REST batch endpoint (POST /v1/batch/objects) is the correct path for bulk imports — individual REST POST /v1/objects calls would trigger a separate vectorization request per object and are too slow for bulk loads. The batch endpoint accepts up to 1000 objects per request for self-hosted Weaviate (lower limits may apply on Weaviate Cloud depending on tier).

// Batch import with manual vectors (vectorizer: "none" class)
async function batchImportWithVectors(
  objects: Array<{ title: string; content: string; source: string }>,
  vectors: number[][]
) {
  const batchObjects = objects.map((obj, i) => ({
    class: 'Document',
    vector: vectors[i],     // required when vectorizer is "none"
    properties: obj,
  }));

  // Import in chunks of 100 to stay within recommended batch size
  for (let i = 0; i < batchObjects.length; i += 100) {
    const chunk = batchObjects.slice(i, i + 100);
    const res = await fetch(`${WEAVIATE_URL}/v1/batch/objects`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
      body: JSON.stringify({ objects: chunk }),
    });
    const data = await res.json();
    // Check per-object results — batch may partially succeed
    const errors = data.filter((r: any) => r.result?.status === 'FAILED');
    if (errors.length > 0) {
      console.error('Batch import partial failure:', errors);
    }
  }
}

Unlike Pinecone's upsert (which is always idempotent by vector ID), Weaviate batch imports create new objects with auto-generated UUIDs unless you supply the id field explicitly. To make batch imports idempotent, generate deterministic UUIDs from your source record IDs using a UUID v5 hash, then supply them as the id field — Weaviate will update an existing object if the ID already exists.

Multi-tenancy — per-tenant isolation within a shared class

Weaviate's multi-tenancy feature creates isolated data partitions within a single class — each tenant has its own storage shard. Tenants must be declared on the class at creation time (multiTenancyConfig: { enabled: true }). Once multi-tenancy is enabled, all data operations (insert, query, delete) require a tenant parameter. Tenants can be individually activated or deactivated (cold storage) to reduce memory footprint.

// Multi-tenancy class definition
const mtClass = {
  class: 'TenantDocument',
  multiTenancyConfig: { enabled: true },
  vectorizer: 'text2vec-openai',
  properties: [/* ... */],
};

// Create tenants before inserting tenant-scoped data
await fetch(`${WEAVIATE_URL}/v1/schema/TenantDocument/tenants`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
  body: JSON.stringify([
    { name: 'tenant-abc', activityStatus: 'HOT' },
    { name: 'tenant-xyz', activityStatus: 'HOT' },
  ]),
});

// Tenant-scoped GraphQL query (tenant parameter is required)
const tenantQuery = `{
  Get {
    TenantDocument(
      tenant: "tenant-abc"
      nearVector: { vector: [...] certainty: 0.7 }
      limit: 5
    ) {
      title
      _additional { id certainty }
    }
  }
}`;

Multi-tenancy queries without the tenant parameter return HTTP 422 — the error message explicitly states the tenant is required. Tenants in COLD status (deactivated) return HTTP 422 "tenant is not active" when queried. Use the tenant activity status API to reactivate a tenant before serving requests if your MCP tool needs to work with intermittently cold tenants.

Health probe — /.well-known/ready and /.well-known/live

Weaviate exposes two Kubernetes-style health endpoints. GET /v1/.well-known/ready returns HTTP 200 when Weaviate is ready to accept requests (schema loaded, connections established). GET /v1/.well-known/live returns HTTP 200 when the process is alive but not necessarily ready. The readiness endpoint is the correct one for MCP server health checks — liveness only confirms the process hasn't crashed.

async function checkWeaviateHealth(): Promise<{ ready: boolean; version: string }> {
  // Readiness check — HTTP 200 means accepting requests; 503 means starting up
  const readyRes = await fetch(`${WEAVIATE_URL}/v1/.well-known/ready`);
  if (readyRes.status === 503) {
    return { ready: false, version: 'unknown' };
  }

  // Meta endpoint returns version and loaded modules (requires auth if auth is enabled)
  const metaRes = await fetch(`${WEAVIATE_URL}/v1/meta`, {
    headers: getAuthHeaders(),
  });
  const meta = await metaRes.json();
  return {
    ready: true,
    version: meta.version,
    // meta.modules lists all enabled modules — verify text2vec-openai etc. are present
  };
}

The /v1/.well-known/ready and /v1/.well-known/live endpoints do not require authentication even when auth is enabled on the instance — they are public health endpoints. This means your MCP health check can probe readiness without credentials, which is useful for pre-auth connection validation.

Related guides