Guide · Headless CMS Integration

MCP Server Directus — SDK, field selection, permissions, image transforms

Three Directus behaviours catch MCP server authors off-guard: omitting the fields parameter returns all fields including deeply nested relations — a Directus collection with status, sort, user_created, date_created, user_updated, date_updated, and several relational fields returns a response that is 5–10× larger than necessary when fields is not set; always specify the exact fields your tool needs using dot-notation for nested fields (author.name) and wildcard shorthand for flat objects (*); row-level permissions are enforced server-side based on the static token's role — if your Directus static token is scoped to a role with restricted collection access, API calls for unauthorized items return empty arrays rather than 403 errors, which makes debugging difficult; verify your token's role permissions in Directus settings when queries return unexpectedly empty results; and image transforms are served via the /assets/{id} endpoint with query parameters — the id field in an image relation is a UUID pointing to the directus_files collection, not a URL, and you must construct the transform URL manually.

TL;DR

Use createDirectus(url).with(rest()) from @directus/sdk and a static token for all MCP tool handlers. Always pass fields: ['id', 'title', ...] to every readItems() call. Use dot-notation for nested relation fields ('author.name') and '*' for flat expansion. Construct image URLs as ${DIRECTUS_URL}/assets/${fileId}?width=800&format=webp. Use GET /server/health for the health probe — it requires no auth and returns { status: 'ok' }.

SDK setup and field selection

Directus provides an official TypeScript SDK @directus/sdk that wraps the REST and GraphQL APIs. For MCP server tool handlers, the REST client is the right choice — it is predictable, debuggable, and does not require writing GraphQL queries. Initialize the client once at startup with a static token (created under Directus Settings → Access Tokens).

import { createDirectus, rest, readItems, readItem, createItem, updateItem } from '@directus/sdk';
import { z } from 'zod';

// Type your schema for full TypeScript safety
interface Article {
  id:          string;
  status:      'published' | 'draft' | 'archived';
  title:       string;
  slug:        string;
  body:        string;
  author:      { name: string; email: string } | string | null;
  category:    { id: string; name: string } | string | null;
  featured_image: string | null;  // UUID pointing to directus_files
  date_published: string | null;
}

interface DirectusSchema {
  articles:   Article[];
  categories: { id: string; name: string; slug: string }[];
}

const directus = createDirectus<DirectusSchema>(process.env.DIRECTUS_URL!)
  .with(rest({
    onRequest: (options) => ({
      ...options,
      headers: {
        ...options.headers,
        Authorization: `Bearer ${process.env.DIRECTUS_TOKEN!}`,
      },
    }),
  }));

// MCP tool — list published articles with field selection
server.tool(
  'list_articles',
  {
    page:     z.number().int().min(1).default(1),
    limit:    z.number().int().min(1).max(50).default(10),
    category: z.string().optional(),
    search:   z.string().optional(),
  },
  async ({ page, limit, category, search }) => {
    const filter: Record<string, unknown> = {
      status: { _eq: 'published' },
    };
    if (category) {
      filter['category'] = { slug: { _eq: category } };
    }

    const articles = await directus.request(
      readItems('articles', {
        // Always specify fields — never omit this parameter
        fields: [
          'id',
          'title',
          'slug',
          'date_published',
          'featured_image',       // UUID — construct URL separately
          'author.name',          // dot-notation for nested relation field
          'category.name',
          'category.slug',
        ],
        filter,
        search: search ?? undefined,  // full-text search across all text fields
        sort:   ['-date_published'],  // dash prefix = descending
        limit,
        offset: (page - 1) * limit,
        meta:   'total_count',        // include total for pagination
      })
    );

    // Build image URLs for featured images
    const results = (articles as any[]).map(a => ({
      id:             a.id,
      title:          a.title,
      slug:           a.slug,
      date_published: a.date_published,
      author:         a.author?.name ?? null,
      category:       a.category?.slug ?? null,
      image_url:      a.featured_image
        ? directusAssetUrl(a.featured_image, { width: 800, format: 'webp' })
        : null,
    }));

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({ articles: results }, null, 2),
      }],
    };
  }
);

The meta: 'total_count' parameter instructs Directus to return the total number of matching items in response.meta.total_count alongside the paginated results. Use meta: '*' for all metadata, or omit meta entirely when you do not need pagination totals — omitting it reduces response size slightly.

Filter operators and deep relation filters

Directus uses underscore-prefixed filter operators: _eq, _contains, _in, and so on. Relation filters work out of the box without any special population parameter — unlike Strapi, Directus automatically joins the relation when a filter references a nested field. However, nested relation fields will only appear in the response if they are included in the fields parameter.

import { readItems } from '@directus/sdk';

// MCP tool — search articles with date range and tag filter
server.tool(
  'search_articles',
  {
    keyword:        z.string().optional(),
    published_after: z.string().optional(),
    tag:            z.string().optional(),
    page:           z.number().int().min(1).default(1),
    limit:          z.number().int().min(1).max(20).default(10),
  },
  async ({ keyword, published_after, tag, page, limit }) => {
    const filter: Record<string, unknown> = {
      status: { _eq: 'published' },
    };

    if (published_after) {
      filter['date_published'] = { _gte: published_after };
    }
    if (tag) {
      // Filter on many-to-many relation (articles_tags junction)
      filter['tags'] = { tags_id: { slug: { _eq: tag } } };
    }

    const result = await directus.request(
      readItems('articles', {
        fields: ['id', 'title', 'slug', 'date_published', 'author.name'],
        filter,
        search:  keyword,
        sort:    ['-date_published'],
        limit,
        offset:  (page - 1) * limit,
        meta:    'total_count',
      })
    );

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

// Available filter operators:
// _eq / _neq         — equals / not equals
// _lt / _lte         — less than / less than or equal
// _gt / _gte         — greater than / greater than or equal
// _in / _nin         — value in / not in array
// _null / _nnull     — is null / is not null
// _contains          — string contains (case-sensitive)
// _icontains         — string contains (case-insensitive)
// _starts_with       — string starts with
// _ends_with         — string ends with
// _between           — value between two bounds (inclusive)
// _intersects        — geometry intersection (for geometry fields)
// _within            — geometry within bounds

// Combine filters with _and / _or:
const complexFilter = {
  _and: [
    { status: { _eq: 'published' } },
    { _or: [
      { category: { slug: { _eq: 'engineering' } } },
      { category: { slug: { _eq: 'design' } } },
    ]},
  ],
};

Image transforms — /assets endpoint with query parameters

Directus stores files in its directus_files collection and serves them through the /assets/{fileId} endpoint. The fileId is a UUID — it appears as the value of image relation fields in your collection items. You construct the asset URL manually by combining the Directus base URL, the /assets/ path, the file UUID, and any transform query parameters. Directus applies transforms server-side using Sharp, so you can request exact dimensions, format conversion, and quality in the URL without pre-generating thumbnails.

interface AssetTransformOptions {
  width?:   number;
  height?:  number;
  quality?: number;    // 1-100, default 80
  format?:  'jpg' | 'png' | 'webp' | 'avif' | 'tiff';
  fit?:     'cover' | 'contain' | 'fill' | 'inside' | 'outside';
}

function directusAssetUrl(
  fileId: string,
  options: AssetTransformOptions = {},
): string {
  const base = `${process.env.DIRECTUS_URL}/assets/${fileId}`;
  const params = new URLSearchParams();

  if (options.width)   params.set('width',   String(options.width));
  if (options.height)  params.set('height',  String(options.height));
  if (options.quality) params.set('quality', String(options.quality));
  if (options.format)  params.set('format',  options.format);
  if (options.fit)     params.set('fit',     options.fit);

  const qs = params.toString();
  return qs ? `${base}?${qs}` : base;
}

// Usage in a tool that needs image URLs at multiple sizes
server.tool(
  'get_product',
  { id: z.string().uuid() },
  async ({ id }) => {
    const product = await directus.request(
      readItem('products', id, {
        fields: [
          'id', 'name', 'description', 'price',
          'main_image',        // UUID
          'gallery.*',         // all fields of gallery junction (includes directus_files_id)
        ],
      })
    );

    if (!product) {
      return { content: [{ type: 'text', text: JSON.stringify({ error: 'Not found' }) }] };
    }

    const p = product as any;
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          id:          p.id,
          name:        p.name,
          description: p.description,
          price:       p.price,
          images: {
            hero:       p.main_image
              ? directusAssetUrl(p.main_image, { width: 1200, height: 630, format: 'webp' })
              : null,
            thumbnail:  p.main_image
              ? directusAssetUrl(p.main_image, { width: 400, height: 400, fit: 'cover', format: 'webp' })
              : null,
            gallery:   (p.gallery ?? []).map((g: any) =>
              directusAssetUrl(g.directus_files_id, { width: 800, format: 'webp' })
            ),
          },
        }, null, 2),
      }],
    };
  }
);

Directus image transforms are cached after the first request — subsequent requests for the same fileId + parameters are served from disk without re-processing. For MCP tool handlers that return image URLs, consider whether agents actually need the image data or just the URL. If agents need to describe or process image content, return the URL and let the agent fetch it separately via a resource read.

Health probe — GET /server/health

Directus exposes a built-in health endpoint at GET /server/health that does not require authentication. The response includes the overall health status plus individual service checks for the database, cache, storage, and email. This is the correct endpoint for AliveMCP monitoring and for the MCP server's startup health gate.

// Health probe — no auth required
server.resource('directus_health', 'cms://directus/health', async () => {
  try {
    const res = await fetch(`${process.env.DIRECTUS_URL}/server/health`, {
      signal: AbortSignal.timeout(5000),
    });
    const data = await res.json();
    // data = { status: 'ok', checks: { database: [{status:'ok'}], cache: [...] } }
    return {
      contents: [{
        uri: 'cms://directus/health',
        text: JSON.stringify({
          status:   data.status,
          database: data.checks?.database?.[0]?.status ?? 'unknown',
          cache:    data.checks?.cache?.[0]?.status ?? 'unknown',
          storage:  data.checks?.storage?.[0]?.status ?? 'unknown',
        }),
      }],
    };
  } catch (err: any) {
    return {
      contents: [{
        uri: 'cms://directus/health',
        text: JSON.stringify({ status: 'error', message: err.message }),
      }],
    };
  }
});