Headless CMS Integration · 2026-07-31 · Headless CMS arc

Headless CMS Integration for MCP Servers: Auth, Rich Content, and Field Selection

Five headless CMSes — Contentful, Sanity, Strapi, Directus, and Ghost — appear in MCP servers for the same reason: agents need to query, summarize, and search content that lives in a CMS, and the CMS has no native MCP interface. Auth credential selection is the first and most consequential pattern, because each CMS ships multiple credential types with different scopes and different risk profiles: Contentful separates the Content Delivery API key (read-only, 55 req/s, safe to embed in tool handlers) from the Content Management API token (full write access, 10 req/s — it should never appear in a tool handler unless the MCP server deliberately exposes content creation workflows); Sanity uses a single client initialized with a project ID and dataset name, but public datasets require no token while private datasets need a read token scoped to specific collections — the useCdn: true flag switches to CDN-backed responses with eventual consistency, which is correct for read-only tools but wrong for real-time preview flows; Strapi v4 offers API tokens (created in admin, long-lived, scoped to read or full-access — the right choice for MCP servers) and JWT tokens (obtained by user login, expire after 30 days, require re-authentication — the right choice for user-facing clients, not automated tool handlers); Directus uses static tokens tied to roles, and the most common debugging trap is that queries for unauthorized collections return empty arrays rather than 403 errors, making permission misconfiguration look like a data absence; Ghost splits the Content API (read-only, authenticates via ?key= query parameter — never a bearer header) from the Admin API (read-write, authenticates via a fresh JWT signed from an id:secret key — the signing must happen per-request, not once at startup). Rich content serialization is the second pattern, and the one that wastes the most agent context tokens when done wrong: every CMS stores long-form content not as a plain string but as a structured intermediate format that requires an explicit deserialization step before the value is useful for agent reasoning — Contentful rich text is a nested { nodeType: 'document', content: [{ nodeType: 'paragraph', content: [...] }] } document tree that serializes to readable prose only with documentToPlainTextString(doc) from @contentful/rich-text-plain-text-renderer; Sanity Portable Text is an array of block objects ([{ _type: 'block', style: 'normal', children: [{ _type: 'span', text: '...' }] }]) that serializes with toPlainText(blocks) from @portabletext/to-html; Strapi rich text in v4 is typically stored as HTML string (from the Quill editor) which is already passable to agents but is 3–5× more token-heavy than stripped plaintext for long articles; Directus WYSIWYG fields store HTML produced by TipTap or Quill and are the most benign of the five — they return a string, not a structured type, so the only question is whether to strip tags before returning; Ghost is the only CMS that solves this natively — formats: ['plaintext'] in the query requests the pre-computed plaintext version, reducing a 20,000-token HTML post body to roughly 4,000 tokens without any client-side processing, a 5× token reduction that directly translates to faster, cheaper agent calls. Field selection and projection is the third pattern, and the one where the five CMSes diverge most sharply in their APIs: every CMS returns more data than needed by default, but each expresses field limiting in a different syntax — Contentful uses a select query parameter with a comma-separated list of dot-notation field paths (select: 'sys.id,fields.title,fields.slug,fields.body'); Sanity uses GROQ projections, where the query itself defines the output shape — a bare *[_type == "post"] without a projection ({ title, slug, ... }) suffix returns every field in every document including large Portable Text bodies, asset reference objects, and internal _rev metadata, easily returning 500 KB from a modest dataset; Strapi v4 uses a fields array for scalar fields and a separate populate object for relation fields, where populate: '*' fetches all relations at one level deep (often 50 KB per entry) and the correct pattern is granular populate syntax (populate: { author: { fields: ['name', 'email'] } }) serialized with the qs library because Node's built-in URLSearchParams does not serialize nested objects correctly; Directus requires a fields array on every readItems() call — omitting it returns all fields including all nested relations, which for a typical article with status, sort, audit fields, and multiple relations is 5–10× the necessary payload, and dot-notation expresses nested relation fields ('author.name'); Ghost uses a fields parameter for scalar fields and a separate include parameter for relations (include: 'authors,tags'), with the plaintext body controlled by formats rather than fields. This post covers all three patterns with code for each CMS, a health probe comparison table, a CMS selection matrix, and the internal link map for the five dedicated integration guides.

TL;DR

Five CMSes, three patterns. (1) Auth: Contentful — use CDA access token (read-only, 55 req/s), never CMA token in tool handlers; SanityuseCdn: true + pinned apiVersion + read token for private datasets only; Strapi — API token (long-lived, admin-created) not JWT (expires, requires re-auth); Directus — static token with role-scoped permissions; empty arrays = permission issue, not missing data; Ghost — Content API key in ?key= param (never bearer header), Admin API needs per-request JWT signed from id:secret. (2) Rich content: Contentful — documentToPlainTextString(doc); Sanity — toPlainText(blocks); Strapi — already HTML, strip tags if needed; Directus — already HTML string; Ghost — request formats: ['plaintext'] to get 5× smaller body. (3) Field selection: Contentful — select: 'sys.id,fields.title,...'; Sanity — GROQ projection { title, slug, "slug": slug.current }; Strapi — fields: ['title','slug'] + populate: { author: { fields: ['name'] } }; Directus — fields: ['id','title','author.name']; Ghost — fields: 'id,title,slug' + include: 'authors,tags'.

Pattern 1 — API Credentials: Which Key Goes in Which Tool Handler

MCP servers that proxy headless CMSes run continuously as background processes — they handle tool calls from agents without human intervention. This changes the threat model compared to a short-lived frontend application: a leaked credential in an MCP server stays leaked until it is rotated, and it can be called at any rate the agent instructs. Choosing the right credential type for each CMS is the first architectural decision, and all five CMSes make it easy to get wrong.

Contentful — CDA token vs CMA token

Contentful ships two separate REST APIs with separate token types. The Content Delivery API (CDA) accepts a delivery access token scoped to read-only operations — it is backed by a CDN, supports 55 req/s per key, and a leaked token cannot modify or delete content. The Content Management API (CMA) accepts a personal access token or app token with full write scope — it caps at 10 req/s and a leaked token can delete an entire space's content.

For any MCP tool handler that retrieves content for agent reasoning, the CDA is the right choice. The distinction matters most in team environments where the MCP server config is shared: a CMA token in .env that gets committed to a repository or shared in a container image has catastrophic blast radius. If you need write access — for a draft creation tool or a content publishing workflow — create a dedicated MCP server with CMA scope that is not deployed alongside the read-only server.

import contentful from 'contentful';

// CORRECT — read-only CDA token for tool handlers
const cda = contentful.createClient({
  space:       process.env.CONTENTFUL_SPACE_ID!,
  accessToken: process.env.CONTENTFUL_CDA_TOKEN!,   // from Settings → API keys → Content Delivery
});

// WRONG — CMA token in a read-only tool handler
// const cma = contentful.createManagementClient({
//   accessToken: process.env.CONTENTFUL_CMA_TOKEN!,  // full write access — never here
// });

// Verify API access at startup — fail fast rather than failing on first tool call
async function verifyCdaAccess() {
  const space = await cda.getSpace();
  console.log(`Contentful CDA connected: space="${space.name}", locales=${space.locales.length}`);
}

Sanity — CDN flag and token scope

Sanity uses a single client API regardless of dataset visibility. Public datasets need no token — the project ID and dataset name are sufficient. Private datasets require a read token generated in the Sanity management console with a "Viewer" role — tokens with Editor or Administrator roles provide write access and belong in separate clients used only by write-path tool handlers.

The useCdn: true flag routes reads through Sanity's CDN with 1–5 second eventual consistency. For MCP tool handlers that retrieve content for agent summarization or search, CDN is correct — it is faster and does not count toward API request quotas. For real-time preview tools that need the just-published version of a document, set useCdn: false. Always pin the apiVersion to a specific date — using the latest version without a pin means your tool handler's behavior can change invisibly when Sanity ships a breaking API change.

import { createClient } from '@sanity/client';

// Read-only client — public dataset (no token needed)
const sanity = createClient({
  projectId:  process.env.SANITY_PROJECT_ID!,
  dataset:    process.env.SANITY_DATASET ?? 'production',
  apiVersion: '2024-01-01',  // ALWAYS pin — never use 'vX' or omit
  useCdn:     true,           // CDN-backed, 1-5s eventual consistency
});

// Read-only client — private dataset (viewer token)
const sanityPrivate = createClient({
  projectId:  process.env.SANITY_PROJECT_ID!,
  dataset:    process.env.SANITY_DATASET!,
  apiVersion: '2024-01-01',
  useCdn:     true,
  token:      process.env.SANITY_READ_TOKEN!,  // "Viewer" role — not Editor/Admin
});

// Preview client — bypasses CDN, shows draft content
const sanityPreview = createClient({
  projectId:  process.env.SANITY_PROJECT_ID!,
  dataset:    process.env.SANITY_DATASET!,
  apiVersion: '2024-01-01',
  useCdn:     false,           // real-time, no CDN
  token:      process.env.SANITY_PREVIEW_TOKEN!, // "Editor" role — only for preview tools
  perspective: 'previewDrafts',
});

Strapi — API tokens vs JWT

Strapi v4 supports two token types. API tokens are created in Strapi Admin → Settings → API Tokens — they do not expire, they are scoped at creation time (read-only, full-access, or custom with per-endpoint grants), and they are the correct credential for MCP server tool handlers. JWT tokens are obtained by calling POST /api/auth/local with a username and password — they expire after 30 days by default, require re-authentication logic in your server, and are the correct credential for interactive client applications where a human is logging in.

The failure mode for JWT in an MCP server is silent: 28 days after deployment, the JWT expires, every tool call starts returning 401, and agents stop getting CMS data with no explanation in the MCP response beyond "Strapi 401". Using an API token eliminates this entire class of failure.

// CORRECT — API token (created in Strapi Admin, scoped, non-expiring)
const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN!;

async function strapiGet(path: string, params: Record<string, unknown> = {}) {
  const query = qs.stringify(params, { encodeValuesOnly: true });
  const url = `${process.env.STRAPI_URL}/api${path}${query ? '?' + query : ''}`;
  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${STRAPI_API_TOKEN}` },
    signal:  AbortSignal.timeout(10_000),
  });
  if (!res.ok) throw new Error(`Strapi ${res.status}: ${await res.text()}`);
  return res.json();
}

// WRONG — JWT with no refresh logic (expires in 30 days, silent failure)
// const jwt = await fetch(`${STRAPI_URL}/api/auth/local`, {
//   method: 'POST',
//   body: JSON.stringify({ identifier: 'user', password: 'pass' }),
// }).then(r => r.json()).then(d => d.jwt);
// — this JWT will expire and break your MCP server silently on day 30

Directus — static tokens and the empty-array permission trap

Directus uses static tokens tied to user accounts with roles. A static token created under Settings → Access Tokens inherits the role's collection-level and row-level permissions. The debugging trap is that Directus enforces permissions server-side and returns empty arrays (not 403 errors) when the token's role lacks access to items in the requested collection. A readItems('articles', { filter: ... }) call that returns [] may mean "no articles match the filter" or may mean "the token's role cannot read this collection" — and the two cases produce identical API responses.

Always verify the token's role in Directus Settings → Roles before debugging field selection or filter logic. If a query that should match returns empty, check permissions first.

import { createDirectus, rest, readItems } from '@directus/sdk';

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

// Startup check — verify the token has access to the collections you need
async function verifyDirectusAccess() {
  try {
    // Try to read one item from a known collection — empty is fine, error means permission issue
    await directus.request(readItems('articles', { limit: 1, fields: ['id'] }));
    console.log('Directus token verified — articles collection accessible');
  } catch (err: any) {
    // Actual permission errors appear here as exceptions, not as empty arrays
    console.error('Directus access check failed:', err.message);
    process.exit(1);
  }
}

Ghost — Content API key in query params, Admin API via JWT signing

Ghost is the most unusual of the five CMSes in its authentication conventions. The Content API key is sent as a query parameter (?key=...) — not as a bearer token in the Authorization header. The official @tryghost/content-api SDK handles this automatically, which is the main reason to use the SDK over raw fetch for Ghost: it injects the key into every request URL without any manual string manipulation.

The Admin API requires a different credential type — an Admin API key in id:secret format — and every request must be signed with a fresh JWT derived from this key, valid for 5 minutes. The @tryghost/admin-api SDK handles the signing. Generating the JWT manually is error-prone because Ghost requires specific JWT header fields and a 5-minute window — a common mistake is generating the JWT at server startup and reusing it until it expires, which works for 5 minutes and then silently breaks.

import GhostContentAPI from '@tryghost/content-api';
import GhostAdminAPI from '@tryghost/admin-api';

// Content API — read-only, key in query param (SDK handles this)
const ghost = new GhostContentAPI({
  url:     process.env.GHOST_URL!,           // e.g. https://blog.example.com
  key:     process.env.GHOST_CONTENT_KEY!,   // from Ghost Admin → Integrations → Content API
  version: 'v5.0',
});
// SDK injects ?key=... automatically — do NOT pass Authorization headers

// Admin API — read-write, SDK signs JWT per-request from id:secret key
const ghostAdmin = new GhostAdminAPI({
  url:     process.env.GHOST_URL!,
  key:     process.env.GHOST_ADMIN_KEY!,     // format: id:secret (NOT Content API key)
  version: 'v5.0',
});
// SDK generates a fresh JWT for every request — do not cache or reuse

A simple startup test for Ghost: call ghost.settings.browse(), which hits GET /ghost/api/content/settings/?key=... and returns the site title and URL. If this call succeeds, the Content API key is valid and the Ghost instance is reachable. Use this in your Ghost MCP server's health probe resource.

Pattern 2 — Rich Content Serialization: Never Return Raw Structured Content to Agents

Every headless CMS solves the same problem differently: how to store rich, structured editorial content — headings, bold text, block quotes, embedded images, internal links — in a format that is both machine-readable for the CMS editor and renderable in arbitrary frontends. The solutions all share one characteristic that matters for MCP tool handlers: the stored format is not a plain string, and returning it raw to an agent wastes tokens and reduces the quality of agent reasoning.

The table below shows the stored format and the conversion function for each CMS:

CMS Stored format Conversion to plain text Token ratio (HTML vs plain)
Contentful CFDA document tree ({ nodeType, content: [] }) documentToPlainTextString(doc) ~3–5×
Sanity Portable Text block array ([{ _type: 'block', ... }]) toPlainText(blocks) ~5–10×
Strapi v4 HTML string (Quill output) text.replace(/<[^>]+>/g, '') or cheerio ~2–3×
Directus HTML string (TipTap output) text.replace(/<[^>]+>/g, '') or cheerio ~2–3×
Ghost Lexical JSON (internal), HTML (default API response) formats: ['plaintext'] in query params ~5×

Contentful rich text — documentToPlainTextString

Contentful rich text fields return a nested document object with nodeType: 'document' at the root and recursive content: [] arrays representing paragraphs, headings, lists, embedded entries, and inline marks. An agent that receives this raw object sees hundreds of tokens of structural JSON for every paragraph of prose. The @contentful/rich-text-plain-text-renderer package converts it to a plain text string in a single call:

import { documentToPlainTextString } from '@contentful/rich-text-plain-text-renderer';
import { documentToHtmlString } from '@contentful/rich-text-html-renderer';

// Always check for the document nodeType before converting
function contentfulBodyToText(field: unknown, format: 'plain' | 'html' = 'plain'): string {
  if (!field || (field as any).nodeType !== 'document') return '';
  return format === 'html'
    ? documentToHtmlString(field as any)
    : documentToPlainTextString(field as any);
}

// In a tool handler:
server.tool('get_article', { slug: z.string(), format: z.enum(['plain', 'html']).default('plain') },
  async ({ slug, format }) => {
    const entries = await cda.getEntries({
      content_type: 'article',
      'fields.slug': slug,
      limit: 1,
      locale: 'en-US',
      include: 2,  // resolve linked entries 2 levels deep
    });
    const entry = entries.items[0];
    if (!entry) return { content: [{ type: 'text', text: '{"error":"not found"}' }] };

    const f = entry.fields as any;
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          id:     entry.sys.id,
          title:  f.title,
          slug:   f.slug,
          // Convert rich text BEFORE returning — never return the raw document tree
          body:   contentfulBodyToText(f.body, format),
          author: f.author?.fields?.name ?? null,
        }, null, 2),
      }],
    };
  }
);

Contentful rich text can contain embedded entry blocks — inline components, call-out boxes, or related articles — that are represented as { nodeType: 'embedded-entry-block', data: { target: { sys: { id } } } } objects. The plain text renderer skips these blocks (they contribute no text). If you need to surface embedded content, use the HTML renderer with a custom renderNode for BLOCKS.EMBEDDED_ENTRY. See the Contentful integration guide for the full embedded-entry renderer pattern.

Sanity Portable Text — toPlainText

Sanity's Portable Text is the most complex of the five structured content formats. It is an open specification designed to be renderer-agnostic — the same Portable Text value can be rendered to HTML, Markdown, plain text, or EPUB by different renderers. For MCP tool handlers, toPlainText from @portabletext/to-html converts the block array to a plain string. Custom block types (embedded images, code snippets, pull quotes) are not included in the plain text output by default — they are skipped with no placeholder.

import { toPlainText } from '@portabletext/to-html';

function sanityBodyToText(blocks: unknown): string {
  if (!Array.isArray(blocks) || blocks.length === 0) return '';
  return toPlainText(blocks as any);
}

// In a GROQ query, fetch the body field only when needed
// and include it in the projection to enable conversion
const query = `*[
  _type == "post"
  && slug.current == $slug
  && !(_id in path('drafts.**'))   // exclude draft documents
][0] {
  _id,
  title,
  "slug": slug.current,
  publishedAt,
  body,                            // Portable Text — convert below
  "author": author->{name},
  "imageUrl": mainImage.asset->url // dereference in GROQ, not in code
}`;

const post = await sanity.fetch(query, { slug });
if (post) {
  const bodyText = sanityBodyToText(post.body);  // convert before returning
}

The dereference operator (->) in GROQ is Sanity's mechanism for resolving asset URLs inline without a round-trip. mainImage.asset->url returns the CDN URL as a plain string, while the raw stored value is a reference UUID like image-abc123-1200x800-jpg. Always dereference assets in the GROQ projection rather than in application code — it is faster, requires no extra API call, and produces a plain string the agent can read directly. See the Sanity integration guide for the full asset dereference and @sanity/image-url builder patterns.

Strapi and Directus — HTML strings and when to strip

Both Strapi and Directus store rich text as HTML strings rather than structured document trees. Strapi v4's rich text fields use Quill; Directus's WYSIWYG fields use TipTap. Both produce standard HTML. This makes them the easiest CMSes to handle in MCP tool handlers — the field value is already a string, not a structured object requiring deserialization.

The question is whether to strip HTML tags before returning to the agent. For article bodies where an agent needs to reason about content — summarize, answer questions, extract entities — stripping tags saves 2–3× tokens and eliminates noise. For tool handlers where the agent might need structural cues (headings, lists, emphasis), returning HTML is acceptable. A practical approach is to expose both as a format parameter:

function stripHtmlTags(html: string | null | undefined): string {
  if (!html) return '';
  // Remove all tags; collapse whitespace
  return html
    .replace(/<[^>]+>/g, ' ')
    .replace(/&nbsp;/g, ' ')
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/\s+/g, ' ')
    .trim();
}

// Use in a Strapi tool handler
server.tool('get_strapi_article', { slug: z.string(), format: z.enum(['plain', 'html']).default('plain') },
  async ({ slug, format }) => {
    const response = await strapiGet('/articles', {
      filters: { slug: { $eq: slug } },
      populate: { author: { fields: ['name'] } },
      fields: ['title', 'slug', 'content', 'publishedAt'],
    });
    const item = response.data?.[0];
    if (!item) return { content: [{ type: 'text', text: '{"error":"not found"}' }] };

    const a = item.attributes;
    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          id:          item.id,
          title:       a.title,
          slug:        a.slug,
          content:     format === 'plain' ? stripHtmlTags(a.content) : (a.content ?? ''),
          published_at: a.publishedAt,
          author:      a.author?.data?.attributes?.name ?? null,
        }, null, 2),
      }],
    };
  }
);

Ghost — request plaintext at query time

Ghost is the only CMS that pre-computes the plain text version of post bodies and serves it directly from the API. Requesting formats: ['plaintext'] in the Content API query adds a plaintext field to each post object alongside the default html field. The pre-computed version handles Lexical-to-text conversion server-side — no client-side parsing library required, no custom renderers, no tag stripping. For MCP tool handlers, this is the cleanest path:

const posts = await ghost.posts.browse({
  page:    1,
  limit:   10,
  formats: ['plaintext'] as any,  // adds post.plaintext — 5x smaller than HTML
  fields:  'id,title,slug,excerpt,published_at,reading_time' as any,
  include: 'authors,tags' as any,  // eagerly resolve relations
});

// post.plaintext is the pre-computed plain text body
// post.html is available if formats: ['html', 'plaintext'] is used
// Never return post.html to agents unless they specifically need markup
return {
  content: [{
    type: 'text',
    text: JSON.stringify(posts.map(p => ({
      id:           p.id,
      title:        p.title,
      slug:         p.slug,
      excerpt:      p.excerpt,
      published_at: p.published_at,
      reading_time: p.reading_time,
      body:         (p as any).plaintext ?? '',
      authors:      (p as any).authors?.map((a: any) => a.name) ?? [],
      tags:         (p as any).tags?.map((t: any) => t.slug) ?? [],
    })), null, 2),
  }],
};

A 3,000-word Ghost post body serializes to roughly 20,000 tokens as HTML and roughly 4,000 tokens as plaintext. If your MCP server handles high-frequency content retrieval calls — an agent that iterates through a blog to build a summary or find relevant posts — this 5× reduction in body token cost compounds across every call. See the Ghost integration guide for the full filter syntax and Admin API JWT patterns.

Pattern 3 — Field Selection: Every CMS Returns Too Much Data by Default

The default behavior of every headless CMS API is to return the full document when you request an item. For MCP tool handlers that sit between agents and CMSes, this is the wrong default: a full Contentful entry with rich text body, linked media assets, and nested referenced entries can reach 100 KB; a Sanity document with a Portable Text body and all its asset references can reach 500 KB for a single document set. MCP context windows are not unlimited, and returning 50 KB of structured JSON for a "find me articles about X" query is wasteful. Every CMS has a mechanism for field selection — but each has a different API for it.

Contentful — the select parameter

Contentful's select query parameter accepts a comma-separated list of field paths. Paths must be prefixed with sys. for system metadata fields or fields. for content fields. You must always include at least sys.id or sys.type because the Contentful SDK's link resolution logic depends on them.

const entries = await cda.getEntries({
  content_type: 'blogPost',
  locale: 'en-US',
  include: 1,   // resolve first-level linked entries (e.g., author)
  limit: 10,
  // Without select: returns all fields + all linked entry fields + asset metadata
  // With select: returns only exactly what you listed
  select: [
    'sys.id',
    'sys.createdAt',
    'sys.updatedAt',
    'fields.title',
    'fields.slug',
    'fields.summary',     // short summary — NOT the full rich text body
    'fields.author',      // linked entry reference (resolved by include:1)
    'fields.tags',
  ].join(','),
  // Note: fields.body (the rich text) is intentionally omitted for list views
  // Request it only for single-post GET tools where you need the full content
});

A common mistake is to include the rich text body field in every query including list queries. A tool that lists 10 blog posts for an agent to choose from does not need 10 full rich text bodies. Add a separate get_article_body tool that fetches a single post by slug and returns the full content — agents can call the list tool first to find the relevant slug, then call the body tool for the content they need.

Sanity — GROQ projections are the query language, not an add-on

In Sanity, field selection is not a separate parameter — it is the GROQ projection syntax that is part of the query itself. A GROQ query has two parts: the filter expression (*[_type == "post" && defined(slug.current)]) and the optional projection ({ title, "slug": slug.current, publishedAt }). Without the projection, Sanity returns the full document. With the projection, Sanity returns exactly the named fields.

GROQ projections are more expressive than Contentful's select because they can reshape the document: rename fields, compute derived values, dereference relations inline, and project nested structures. The "slug": slug.current pattern aliases slug.current (the nested value) to a top-level slug key in the output — the agent sees a flat slug string rather than an object with a current property.

// BAD — returns full document including _rev, _type, all fields, all asset refs
const bad = await sanity.fetch(`*[_type == "post"]`);

// GOOD — projection limits output to exactly these fields
const good = await sanity.fetch(
  `*[
    _type == "post"
    && defined(slug.current)
    && !(_id in path('drafts.**'))
  ] | order(publishedAt desc) [0...$limit] {
    _id,
    title,
    "slug": slug.current,         // rename slug.current → slug
    publishedAt,
    excerpt,
    "imageUrl": mainImage.asset->url,  // dereference asset ref to CDN URL
    "author": author->{name, "avatar": image.asset->url},
    "tags": tags[]->{title, "slug": slug.current}
    // body is OMITTED for list tools — too large for list responses
  }`,
  { limit: 10 }
);

Strapi — fields for scalars, populate for relations

Strapi v4 splits field selection across two separate parameters: fields controls which scalar fields to return for the root collection, and populate controls which relational fields to include and their own field selection. These parameters must be serialized with the qs library because the bracket-notation query string that Strapi expects (populate[author][fields][0]=name) cannot be produced by Node's built-in URLSearchParams for nested objects.

The critical rule for Strapi relation filters: if you filter on a relation field, you must also populate that relation, or Strapi returns all results instead of filtering. The filter and populate for the same relation must appear in the same request.

import qs from 'qs';

const params = {
  // Scalar fields — only what the agent needs
  fields: ['title', 'slug', 'excerpt', 'publishedAt'],
  // Relations — only the relation fields needed, selected per-relation
  populate: {
    author: { fields: ['name'] },            // NOT { fields: ['name','bio','avatar','...'] }
    category: { fields: ['name', 'slug'] },
    featuredImage: { fields: ['url', 'width', 'height'] },
    // RULE: if you filter on a relation, it MUST also be in populate
    tags: { fields: ['name', 'slug'] },
  },
  filters: {
    // Deep relation filter — requires tags to be in populate above
    tags: { slug: { $eq: 'engineering' } },
    publishedAt: { $notNull: true },
  },
  pagination: { page: 1, pageSize: 10, withCount: true },
  sort: 'publishedAt:desc',
};

// Must use qs for nested params
const query = qs.stringify(params, { encodeValuesOnly: true });
const url = `${STRAPI_URL}/api/articles?${query}`;

// Unwrap the v4 response envelope
const response = await fetch(url, { headers: { Authorization: `Bearer ${STRAPI_TOKEN}` } }).then(r => r.json());
const articles = response.data.map((item: any) => ({
  id:      item.id,
  title:   item.attributes.title,
  slug:    item.attributes.slug,
  author:  item.attributes.author?.data?.attributes?.name ?? null,
  imageUrl: item.attributes.featuredImage?.data?.attributes?.url
    ? `${STRAPI_URL}${item.attributes.featuredImage.data.attributes.url}`
    : null,
}));

Note the Strapi v4 response envelope — every response wraps data in { data: { id, attributes: { ... } }, meta: { ... } } for single items and { data: [{ id, attributes }], meta: { pagination } } for collections. The actual field values are inside attributes, not at the top level. All your unwrapping code needs to go through item.attributes. See the Strapi integration guide for the complete filter operator reference and media URL resolution pattern.

Directus — fields array, always required

Directus's readItems() SDK function accepts a fields array that controls which fields appear in the response. Unlike Strapi, Directus does not split scalars and relations into separate parameters — both are controlled by fields using dot-notation for nested relation fields. Omitting fields entirely is the most common mistake and the most expensive one — Directus returns all fields including all nested relations, which for a collection with status, sort, and audit metadata fields is 5–10× the necessary payload.

// BAD — no fields parameter, returns everything
const bad = await directus.request(readItems('articles'));

// GOOD — explicit fields with dot-notation for nested relations
const good = await directus.request(
  readItems('articles', {
    fields: [
      'id',
      'title',
      'slug',
      'date_published',
      'status',
      'featured_image',    // UUID pointing to directus_files — build URL manually
      'author.name',       // dot-notation: returns { author: { name: '...' } }
      'author.email',
      'category.name',
      'category.slug',
      // 'body' OMITTED for list tools — it's a large HTML string
    ],
    filter: { status: { _eq: 'published' } },
    sort: ['-date_published'],
    limit: 10,
    offset: 0,
    meta: 'total_count',  // include total for pagination without a separate count query
  })
);

// Build image URLs for featured_image fields (UUID → /assets/{uuid}?params)
function directusImageUrl(fileId: string | null, width = 800): string | null {
  if (!fileId) return null;
  return `${process.env.DIRECTUS_URL}/assets/${fileId}?width=${width}&format=webp`;
}

Directus relation filters are simpler than Strapi's — relation fields are automatically joined when referenced in a filter, without any explicit population parameter. filter: { category: { slug: { _eq: 'engineering' } } } works without adding category to the fields array, though the nested category.slug value will not appear in the response unless it is listed in fields. See the Directus integration guide for the full filter operator reference and image transform URL patterns.

Ghost — fields, include, and formats as separate concerns

Ghost's Content API separates field selection into three orthogonal parameters. fields controls which scalar fields appear in the response for the primary resource (post, page, tag, author). include controls which relations are eagerly loaded alongside the primary resource — include: 'authors,tags' adds the related author objects and tag objects to each post. formats controls which body format is returned — 'html', 'plaintext', 'lexical', or combinations. These three parameters are independent and are all needed for a well-shaped tool response.

const posts = await ghost.posts.browse({
  // Scalar fields — excludes body/html/plaintext unless formats is also set
  fields: 'id,title,slug,excerpt,published_at,reading_time,feature_image' as any,
  // Relations — adds authors[] and tags[] to each post
  include: 'authors,tags' as any,
  // Body format — adds plaintext field (5× smaller than html)
  formats: ['plaintext'] as any,
  // Pagination
  page: 1,
  limit: 10,
  // Filter — Ghost's custom filter syntax (not JSON, not SQL)
  // + = AND, , = OR, - = NOT, brackets for nested property access
  filter: 'featured:true+tag:engineering' as any,
  order: 'published_at DESC',
});

Ghost does not support full-text search via the Content API — the filter parameter matches exact field values and supports comparison operators (reading_time:>5) but not substring matching in body text. If agents need to search post content by keyword, the correct pattern is to pair Ghost with Meilisearch or Typesense (both support Ghost webhooks for content sync) and route keyword searches to the search engine while using Ghost for structured field queries.

Health Probe Comparison: All Five CMSes

An MCP server that connects to a headless CMS should expose a health probe — an MCP resource that pings the CMS and reports connectivity status. This is the hook where AliveMCP monitors your MCP server: a tool handler that returns stale data because the CMS is unreachable should surface as a degraded status, not as silently empty results. The correct probe endpoint differs across the five CMSes:

CMS Probe endpoint Auth required Success indicator MCP resource URI
Contentful cda.getSpace() (SDK call) CDA token Returns space.name cms://contentful/health
Sanity sanity.fetch('{"count": count(*[_type != ""])}') Token (private) / none (public) Returns count (≥ 0) cms://sanity/health
Strapi GET /_health None HTTP 204 cms://strapi/health
Directus GET /server/health None { status: "ok", checks: { ... } } cms://directus/health
Ghost ghost.settings.browse() (SDK call) Content API key (via ?key=) Returns site title and URL cms://ghost/health

Strapi and Directus both expose unauthenticated health endpoints — Strapi's /_health returns HTTP 204 with no body, and Directus's /server/health returns a detailed JSON object with individual service checks for the database, cache, storage, and email subsystems. Directus's health response is the most informative: a degraded database check means reads may fail before the next query attempt, and monitoring Directus at the service level rather than the query level gives earlier warning.

// Directus health probe — most detailed of the five
server.resource('directus_health', 'cms://directus/health', async () => {
  const res = await fetch(`${process.env.DIRECTUS_URL}/server/health`, {
    signal: AbortSignal.timeout(5000),
  });
  const data = await res.json();
  // { status: 'ok', checks: { database: [{status:'ok'}], cache: [...], storage: [...] } }
  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',
      }),
    }],
  };
});

// Strapi health probe — minimal (HTTP 204 = ok)
server.resource('strapi_health', 'cms://strapi/health', async () => {
  const res = await fetch(`${process.env.STRAPI_URL}/_health`, {
    signal: AbortSignal.timeout(5000),
  });
  return {
    contents: [{
      uri: 'cms://strapi/health',
      text: JSON.stringify({ status: res.ok ? 'ok' : 'degraded', http: res.status }),
    }],
  };
});

CMS Selection Matrix: Which Headless CMS for Which MCP Use Case

The right CMS for an MCP integration is usually the one the client is already running — you rarely choose the CMS, you inherit it. But for greenfield MCP servers where the CMS is also being selected, the integration complexity profiles differ meaningfully:

Use case Best CMS Why
Blog / publishing — agent reads and summarizes posts Ghost formats:['plaintext'] gives 5× token reduction for free; pagination meta is detailed; read-only Content API is simple to use
Multi-type structured content — products, events, people Contentful Content type system + CDA/CMA separation; select keeps list responses small; SDK handles link resolution automatically
Developer-controlled schema — custom content types, real-time preview Sanity GROQ projections are the most expressive field selection of any CMS; asset dereference in query eliminates round-trips; draft/published distinction is explicit in query filters
Self-hosted, relation-heavy content model Directus On-premise or self-hosted VPS; relation filters don't require explicit populate (unlike Strapi); detailed /server/health endpoint; image transforms at /assets/{id}
API-first, plugin ecosystem, editorial workflows Strapi Rich filter operator set; granular populate control; API tokens with per-endpoint scoping; active plugin ecosystem for media providers and auth

Across all five, the three patterns in this guide apply: choose the read-only credential, convert structured body content before returning it, and always specify field selection in every query. The CMS-specific details differ — GROQ projections vs select params vs fields arrays — but the underlying discipline is identical: return the minimum data the agent needs, in the most readable format, authenticated with the lowest-privilege credential that satisfies the tool's requirements.

Related Guides

Each CMS guide covers the integration patterns for that CMS in full depth, including complete working code for auth setup, field selection, rich content conversion, relation handling, and health probes: