Guide · Search Integration

MCP Tools for Meilisearch — task lifecycle, filterable attributes, tenant tokens

Three Meilisearch behaviours catch MCP server authors off-guard: all write operations (document add, settings update, index deletion) are enqueued as tasks and return a taskUid before the change takes effect — an MCP tool that adds documents and then immediately searches will get zero or stale results because the indexing task is still queued; the fix is to call client.waitForTask(taskUid) (or client.waitForTasks([...taskUids]) for batches) before any dependent query; string fields are not filterable or sortable by default — calling filter: 'category = "Electronics"' on an index that has not declared category in filterableAttributes throws an Invalid filter expression error; and pagination has a default maximum of 1000 total hits (controlled by pagination.maxTotalHits) — offset-based queries that try to paginate past position 1000 silently return an empty hit list, not an error.

TL;DR

Await client.waitForTask(taskUid) after any write before a dependent search. Set filterableAttributes and sortableAttributes in index settings before adding documents. Increase pagination.maxTotalHits for deep-pagination tools. Generate tenant tokens with generateTenantToken(apiKeyUid, searchRules, { apiKey, expiresAt }) for per-tenant data isolation. Health probe: GET /health.

Task lifecycle — await completion before dependent queries

Meilisearch processes all mutations asynchronously through a task queue. Every write — addDocuments, updateDocuments, deleteDocuments, updateSettings, createIndex — returns a task object with a taskUid and status: 'enqueued'. The documents are not yet searchable at this point. If an MCP tool adds a document and another tool (or the same tool in a chained agent call) immediately searches for it, the search may return no results.

import { MeiliSearch } from 'meilisearch';

const client = new MeiliSearch({
  host: process.env.MEILISEARCH_HOST ?? 'http://localhost:7700',
  apiKey: process.env.MEILISEARCH_API_KEY,
});

const index = client.index('products');

// MCP tool: add a product and confirm it is indexed
server.tool('add_product', async ({ product }) => {
  // addDocuments returns a task — documents NOT yet searchable
  const task = await index.addDocuments([{
    id: String(product.id),    // 'id' is the default primary key
    title: product.title,
    description: product.description,
    brand: product.brand,
    category: product.category,
    price: Number(product.price),
    inStock: product.inventory > 0,
    tags: product.tags ?? [],
    createdAt: Date.now(),
  }]);

  // Wait until the task status is 'succeeded' before returning
  // Default timeout: 5000ms, polling interval: 50ms
  const completedTask = await client.waitForTask(task.taskUid, {
    timeOutMs: 10_000,   // Increase for large batches
    intervalMs: 100,
  });

  if (completedTask.status === 'failed') {
    throw new Error(`Indexing failed: ${completedTask.error?.message}`);
  }

  return {
    indexed: true,
    taskUid: task.taskUid,
    durationMs: completedTask.duration,
  };
});

// MCP tool: bulk add and wait for all tasks
server.tool('bulk_add_products', async ({ products }) => {
  // Split into chunks of 500 documents
  const chunkSize = 500;
  const taskUids: number[] = [];

  for (let i = 0; i < products.length; i += chunkSize) {
    const chunk = products.slice(i, i + chunkSize).map(p => ({
      id: String(p.id),
      ...p,
    }));
    const task = await index.addDocuments(chunk);
    taskUids.push(task.taskUid);
  }

  // Wait for all tasks before confirming
  const completedTasks = await client.waitForTasks(taskUids, {
    timeOutMs: 60_000,  // 60 seconds for large batches
    intervalMs: 200,
  });

  const failed = completedTasks.results.filter(t => t.status === 'failed');
  return {
    total: products.length,
    tasks: taskUids.length,
    failed: failed.length,
    failureDetails: failed.map(t => t.error),
  };
});

For MCP tools that need eventually-consistent behaviour (index and move on without waiting), you can skip waitForTask and return the taskUid to the caller. The agent can then poll using a separate tool that calls client.getTask(taskUid) to check status — this is useful when the indexing job is large and the tool call would otherwise time out waiting.

Filterable and sortable attributes — declare before indexing

Meilisearch must build filter and sort data structures at index time, not at query time. Attributes that are not declared in filterableAttributes cannot appear in filter query parameters. The same applies to sortableAttributes — fields not declared there cannot be used in sort. Changing these settings after documents are already indexed triggers a full reindexing task (all existing documents are re-processed), which can take significant time on large datasets.

// Configure index settings BEFORE adding documents
// Changing filterableAttributes after documents exist triggers a reindex task
async function setupIndex() {
  const settings = await index.getSettings();

  // Only update if settings need to change (avoid unnecessary reindex)
  const needsUpdate =
    !settings.filterableAttributes?.includes('category') ||
    !settings.sortableAttributes?.includes('price');

  if (needsUpdate) {
    // updateSettings returns a task — wait for it before indexing documents
    const task = await index.updateSettings({
      // Fields that can appear in filter: expressions
      filterableAttributes: ['category', 'brand', 'inStock', 'tags', 'price'],

      // Fields that can appear in sort: parameters
      sortableAttributes: ['price', 'createdAt', 'title'],

      // Fields searched when no specific field is targeted
      searchableAttributes: ['title', 'description', 'brand', 'tags'],

      // Fields returned in hits (null = all fields)
      displayedAttributes: ['id', 'title', 'brand', 'category', 'price', 'inStock', 'tags'],

      // Stop words — don't index/search these common words
      stopWords: ['the', 'a', 'an', 'and', 'or', 'but'],
    });

    await client.waitForTask(task.taskUid, { timeOutMs: 120_000 });
    console.log('Index settings updated');
  }
}

// MCP tool: search with filters and sorts
server.tool('search_products', async ({
  query,
  category,
  brand,
  minPrice,
  maxPrice,
  inStockOnly,
  sortBy = 'relevance',
  offset = 0,
  limit = 20,
}) => {
  // Build filter expression using Meilisearch filter syntax
  const filterParts: string[] = [];
  if (category)   filterParts.push(`category = "${category}"`);
  if (brand)      filterParts.push(`brand = "${brand}"`);
  if (minPrice != null) filterParts.push(`price >= ${minPrice}`);
  if (maxPrice != null) filterParts.push(`price <= ${maxPrice}`);
  if (inStockOnly)filterParts.push('inStock = true');

  const sortParam: string[] = [];
  if (sortBy === 'price_asc')  sortParam.push('price:asc');
  if (sortBy === 'price_desc') sortParam.push('price:desc');
  if (sortBy === 'newest')     sortParam.push('createdAt:desc');

  const results = await index.search(query ?? '', {
    filter: filterParts.length ? filterParts.join(' AND ') : undefined,
    sort: sortParam.length ? sortParam : undefined,
    offset,
    limit: Math.min(limit, 100),  // Cap at 100 to protect response size
    // Return facet distribution for these attributes
    facets: ['category', 'brand', 'inStock'],
    // Highlight matched terms in these fields
    attributesToHighlight: ['title', 'description'],
    highlightPreTag: '',
    highlightPostTag: '',
    // Crop long text fields to a snippet around the match
    attributesToCrop: ['description'],
    cropLength: 50,
  });

  return {
    hits: results.hits,
    facetDistribution: results.facetDistribution,
    totalHits: results.estimatedTotalHits,
    offset: results.offset,
    limit: results.limit,
    processingTimeMs: results.processingTimeMs,
  };
});

Meilisearch filter syntax supports AND, OR, NOT operators and nested parentheses: category = "Electronics" AND (price >= 100 OR brand = "Apple"). For array fields like tags, use tags = "wireless" — it matches documents where any element of the array equals the value.

Tenant tokens — per-tenant data isolation in MCP tools

When an MCP server serves multiple tenants (users, organizations, or customers) from a single Meilisearch index, use tenant tokens to enforce row-level access control without running separate indexes per tenant. A tenant token is a signed JWT that embeds search rules — Meilisearch applies them as additional mandatory filters on every search, server-side, even if the client tries to override them.

import { MeiliSearch } from 'meilisearch';
import { generateTenantToken } from 'meilisearch';

// Master client — used server-side to generate tenant tokens
const masterClient = new MeiliSearch({
  host: process.env.MEILISEARCH_HOST!,
  apiKey: process.env.MEILISEARCH_MASTER_KEY!,
});

// To generate tenant tokens, first create a parent API key with search permissions
async function getOrCreateSearchApiKey(): Promise<{ uid: string; key: string }> {
  const keys = await masterClient.getKeys({ limit: 100 });
  const existing = keys.results.find(k => k.description === 'search-parent-key');
  if (existing) return { uid: existing.uid!, key: existing.key! };

  const created = await masterClient.createKey({
    description: 'search-parent-key',
    actions: ['search'],
    indexes: ['products', 'orders'],
    expiresAt: null,  // Never expires — manage via token expiry instead
  });
  return { uid: created.uid!, key: created.key! };
}

// Generate a tenant token scoped to a specific tenant's data
function generateTokenForTenant(
  apiKeyUid: string,
  apiKey: string,
  tenantId: string
): string {
  return generateTenantToken(
    apiKeyUid,
    // searchRules: an object where keys are index names and values are filter restrictions
    {
      products: { filter: `tenantId = "${tenantId}"` },
      orders:   { filter: `tenantId = "${tenantId}"` },
    },
    {
      apiKey,
      // Tenant tokens expire — use short expiry (1 hour) for agent sessions
      expiresAt: new Date(Date.now() + 3600 * 1000),
    }
  );
}

// MCP tool: search within tenant scope using a tenant token
server.tool('tenant_search', async ({ tenantId, query, indexName }) => {
  const { uid, key } = await getOrCreateSearchApiKey();
  const token = generateTokenForTenant(uid, key, tenantId);

  // Create a scoped client with the tenant token as the API key
  const tenantClient = new MeiliSearch({
    host: process.env.MEILISEARCH_HOST!,
    apiKey: token,          // Token, not the master key
  });

  // The tenant filter is enforced server-side — tenant cannot see other tenants' data
  const results = await tenantClient.index(indexName).search(query, {
    limit: 20,
    attributesToRetrieve: ['id', 'title', 'price', 'category'],
  });

  return {
    hits: results.hits,
    totalHits: results.estimatedTotalHits,
  };
});

Tenant tokens are stateless JWTs signed with the parent API key — no round trip to Meilisearch is needed to generate them. This makes them cheap to create per-request. Always set an expiresAt — tenant tokens without an expiry are valid indefinitely, which is a security risk if they leak.

Health probe — Meilisearch reachability from an MCP server

Meilisearch exposes a /health endpoint that returns {"status": "available"} when the server is operational and processing tasks. Use it as a startup gate and periodic liveness check.

async function checkMeilisearchHealth(): Promise<{
  healthy: boolean;
  latencyMs?: number;
  status?: string;
  error?: string;
}> {
  const start = Date.now();
  try {
    const result = await client.health();
    return {
      healthy: result.status === 'available',
      status: result.status,
      latencyMs: Date.now() - start,
    };
  } catch (err: any) {
    return {
      healthy: false,
      error: err?.message ?? String(err),
    };
  }
}

async function startMcpServer() {
  const health = await checkMeilisearchHealth();
  if (!health.healthy) {
    console.error('Meilisearch unhealthy at startup:', health.status, health.error);
    process.exit(1);
  }
  console.log(\`Meilisearch ready (\${health.latencyMs}ms). Registering MCP tools.\`);
}

server.tool('search_health', async () => {
  const [health, taskStats] = await Promise.all([
    checkMeilisearchHealth(),
    client.getTasks({ limit: 1, statuses: ['failed'] }),
  ]);
  return {
    ...health,
    recentFailedTasks: taskStats.total,
  };
});

AliveMCP monitors your MCP server's Meilisearch-backed search tools by probing the health endpoint every 60 seconds — catching Meilisearch process crashes, disk-full conditions that cause the task queue to stall, and network partitions between the MCP server process and the Meilisearch instance before they surface as silent search failures in agent workflows.

Related guides