Guide · Headless CMS Integration
MCP Server Contentful — CDA vs CMA, rich text, locale handling, rate limits
Three Contentful behaviours trip up MCP server authors: the Content Delivery API and Content Management API use different keys and different rate limits — the CDA key is safe to use in tool handlers for reads (55 req/s), but the CMA token is a full-write credential that should never appear in a tool handler unless your MCP server deliberately exposes content creation; rich text (document) fields are not strings — a Contentful rich text field returns a nested CFDA document tree with { nodeType, content: [] } nodes that most LLMs cannot parse as readable text, so you must serialize it with @contentful/rich-text-plain-text-renderer or documentToHtmlString before returning it from a tool handler; and linked entries are not automatically nested — when you request an entry with include: 2, linked entries arrive in a flat includes.Entry[] array, not inline within the parent entry, so you must resolve them manually or use the Contentful JavaScript SDK's automatic link resolution.
TL;DR
Use contentful.createClient with your CDA access token for all read-only MCP tool handlers. Serialize rich text fields with documentToPlainTextString before returning to agents. Use include: 1 or include: 2 to load linked entries and let the SDK resolve them automatically — the resolved client returns nested objects, not flat arrays. Lock queries to a specific locale instead of locale: '*'. Handle 429 responses by reading the X-Contentful-RateLimit-Reset header and retrying after that delay.
CDA vs CMA — choosing the right API and key
Contentful exposes two separate REST APIs. The Content Delivery API (CDA) is a read-only CDN-backed API designed for consumption by frontend applications and tool handlers — it accepts a space-specific access token (also called the CDA key). The Content Management API (CMA) is a full read-write API that accepts a user-level or app-level personal access token or CMA token. For MCP server tool handlers that retrieve content for agent reasoning, the CDA is the correct choice. The CDA has higher rate limits (55 req/s vs 10 req/s for CMA) and its keys have read-only scope, so a leaked CDA key cannot be used to delete or modify content.
import contentful from 'contentful';
import { documentToPlainTextString } from '@contentful/rich-text-plain-text-renderer';
import { z } from 'zod';
// CDA client — use for all read-only tool handlers
const cda = contentful.createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_CDA_TOKEN!, // read-only delivery token
// environment: 'master', // defaults to 'master'
});
// For Preview API (unpublished content during staging/review)
const cdaPreview = contentful.createClient({
space: process.env.CONTENTFUL_SPACE_ID!,
accessToken: process.env.CONTENTFUL_PREVIEW_TOKEN!, // preview token (not CDA token)
host: 'preview.contentful.com',
});
// MCP tool — search blog posts by keyword
server.tool(
'search_blog_posts',
{
query: z.string().describe('Keyword to search in title and body'),
limit: z.number().int().min(1).max(20).default(5),
locale: z.string().default('en-US'),
},
async ({ query, limit, locale }) => {
const entries = await cda.getEntries({
content_type: 'blogPost',
'fields.title[match]': query, // full-text search on title field
limit,
locale, // always specify locale — see locale section
include: 1, // resolve first-level linked entries (author, category)
select: [
'sys.id',
'sys.createdAt',
'sys.updatedAt',
'fields.title',
'fields.slug',
'fields.summary',
'fields.body',
'fields.author',
'fields.tags',
].join(','), // only fetch fields you need — reduces payload
});
const posts = entries.items.map(entry => {
const f = entry.fields as Record<string, unknown>;
return {
id: entry.sys.id,
title: f.title as string,
slug: f.slug as string,
summary: f.summary as string | undefined,
// Rich text body — serialize to plain text before returning to agent
body_text: f.body ? documentToPlainTextString(f.body as any) : null,
// Linked author entry is automatically resolved when include: 1 is set
author: (f.author as any)?.fields?.name ?? null,
tags: (f.tags as string[]) ?? [],
created_at: entry.sys.createdAt,
updated_at: entry.sys.updatedAt,
};
});
return {
content: [{ type: 'text', text: JSON.stringify({ total: entries.total, posts }, null, 2) }],
};
}
);
The select parameter is critical for MCP tools. Without it, Contentful returns all fields including large rich text bodies, asset metadata, and deeply nested references — a single entry can easily reach 50 KB when fully populated. Specifying only the fields your tool needs keeps responses inside MCP context limits and reduces latency.
Rich text — serialize before returning to agents
Contentful rich text (used for long-form content like blog post bodies) is stored as a structured document tree: a JSON object with { nodeType: 'document', content: [{ nodeType: 'paragraph', content: [...] }] }. Returning this raw tree to an agent wastes context tokens on structural JSON instead of readable prose, and many LLMs will struggle to extract meaning from it. Always convert rich text to plain text or HTML before the tool returns.
import { documentToPlainTextString } from '@contentful/rich-text-plain-text-renderer';
import { documentToHtmlString } from '@contentful/rich-text-html-renderer';
import { BLOCKS, INLINES } from '@contentful/rich-text-types';
// Plain text — best for agent reasoning (token-efficient, no markup)
function richTextToPlain(doc: any): string {
if (!doc || doc.nodeType !== 'document') return '';
return documentToPlainTextString(doc);
}
// HTML — best when the agent needs to render or inspect structure
function richTextToHtml(doc: any): string {
if (!doc || doc.nodeType !== 'document') return '';
return documentToHtmlString(doc, {
renderNode: {
// Embedded assets (images) — return a plain img tag
[BLOCKS.EMBEDDED_ASSET]: (node) => {
const file = node.data.target.fields?.file;
if (!file) return '';
return `
`;
},
// Inline hyperlinks — resolve entry references to their slug
[INLINES.ENTRY_HYPERLINK]: (node, next) => {
const slug = node.data.target.fields?.slug;
return slug ? `${next(node.content)}` : next(node.content);
},
},
});
}
// MCP tool — get a single article with clean text output
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 2 levels deep (article → author → avatar)
});
const entry = entries.items[0];
if (!entry) {
return { content: [{ type: 'text', text: JSON.stringify({ error: 'Article not found' }) }] };
}
const f = entry.fields as Record<string, unknown>;
const bodyDoc = f.body as any;
return {
content: [{
type: 'text',
text: JSON.stringify({
id: entry.sys.id,
title: f.title,
slug: f.slug,
body: format === 'html' ? richTextToHtml(bodyDoc) : richTextToPlain(bodyDoc),
author: {
name: (f.author as any)?.fields?.name,
bio: (f.author as any)?.fields?.bio
? richTextToPlain((f.author as any).fields.bio)
: null,
},
}, null, 2),
}],
};
}
);
When the rich text body contains embedded entry blocks (inline components, call-out boxes, related articles), those entries must be resolved before the HTML renderer can access their fields. Set include: 2 on the API request — level 1 resolves the entry reference, level 2 resolves any references within those entries. The JavaScript SDK auto-resolves these from the flat includes array into the nested fields objects you see above.
Locale handling — always specify, never use locale: '*'
Contentful spaces often have multiple locales (en-US, de-DE, fr-FR). When you omit the locale parameter, Contentful returns the default locale. When you pass locale: '*', every field value becomes a nested object keyed by locale code — { 'en-US': 'Hello', 'de-DE': 'Hallo' } — which multiplies payload size by the number of active locales and forces your tool to pick a value from the map. Always specify the exact locale your agent context requires.
// BAD — locale: '*' makes every field a locale map
const bad = await cda.getEntries({
content_type: 'product',
locale: '*', // { title: { 'en-US': 'Widget', 'de-DE': 'Gerät', 'fr-FR': 'Gadget' } }
});
// GOOD — specify locale, get plain field values
const good = await cda.getEntries({
content_type: 'product',
locale: 'en-US', // { title: 'Widget' }
limit: 10,
});
// MCP tool that respects the agent's requested locale
server.tool(
'list_products',
{
locale: z.enum(['en-US', 'de-DE', 'fr-FR']).default('en-US'),
category: z.string().optional(),
limit: z.number().int().min(1).max(50).default(10),
},
async ({ locale, category, limit }) => {
const query: Record<string, unknown> = {
content_type: 'product',
locale,
limit,
select: 'sys.id,fields.name,fields.slug,fields.price,fields.category',
};
if (category) query['fields.category'] = category;
const entries = await cda.getEntries(query);
return {
content: [{
type: 'text',
text: JSON.stringify(entries.items.map(e => ({
id: e.sys.id,
name: (e.fields as any).name,
slug: (e.fields as any).slug,
price: (e.fields as any).price,
category: (e.fields as any).category,
})), null, 2),
}],
};
}
);
Rate limit handling — read X-Contentful-RateLimit-Reset
The Contentful CDA enforces a 55 req/s limit per API key. When that limit is exceeded, the API returns HTTP 429 with the header X-Contentful-RateLimit-Reset: <seconds> indicating how many seconds to wait before retrying. The JavaScript SDK throws a ContentfulRateLimitError with a retrySec property you can use directly. MCP tool handlers that perform multiple Contentful queries in one call (e.g., fetching N entries individually) are at risk — prefer bulk queries with sys.id[in] instead of per-ID loops.
import { ContentfulClientApi, ContentfulRateLimitError } from 'contentful';
// Retry wrapper with exponential backoff
async function cdaWithRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
): Promise<T> {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (err: any) {
if (err instanceof ContentfulRateLimitError && attempt < maxRetries) {
const waitSec = err.retrySec ?? Math.pow(2, attempt);
await new Promise(r => setTimeout(r, waitSec * 1000));
attempt++;
continue;
}
throw err;
}
}
}
// Bulk fetch by IDs — single request, not N individual requests
async function fetchEntriesByIds(ids: string[]): Promise<Record<string, unknown>[]> {
if (ids.length === 0) return [];
const entries = await cdaWithRetry(() =>
cda.getEntries({
'sys.id[in]': ids.join(','), // fetch all IDs in one request
limit: ids.length,
locale: 'en-US',
})
);
// Return in original ID order
const map = Object.fromEntries(entries.items.map(e => [e.sys.id, e]));
return ids.map(id => map[id]?.fields ?? null).filter(Boolean);
}
// Health probe — verify space access at MCP server startup
server.resource('contentful_health', 'cms://contentful/health', async () => {
try {
const space = await cdaWithRetry(() => cda.getSpace());
return {
contents: [{
uri: 'cms://contentful/health',
text: JSON.stringify({ status: 'ok', space: space.name, locales: space.locales }),
}],
};
} catch (err: any) {
return {
contents: [{
uri: 'cms://contentful/health',
text: JSON.stringify({ status: 'error', message: err.message }),
}],
};
}
});
Linked entries — resolving the includes array
When you request include: N in a Contentful query, linked entries arrive in the response as a flat response.includes.Entry[] array, not embedded inline in the parent entry's fields. The raw REST response has fields.author = { sys: { type: 'Link', id: '...' } } — just a reference stub. The Contentful JavaScript SDK resolves these links automatically by matching the sys.id values from the flat includes array into the correct nested positions, giving you fields.author.fields.name directly. If you use raw fetch() instead of the SDK, you must resolve links manually.
// Manual link resolution (when not using the SDK)
interface ContentfulLink {
sys: { type: 'Link'; linkType: 'Entry' | 'Asset'; id: string };
}
function resolveLinks(
value: unknown,
includesMap: Map<string, Record<string, unknown>>,
depth: number = 0,
): unknown {
if (depth > 3) return value; // prevent circular reference loops
if (Array.isArray(value)) return value.map(v => resolveLinks(v, includesMap, depth));
if (value && typeof value === 'object') {
const obj = value as Record<string, unknown>;
if (obj.sys && (obj.sys as any).type === 'Link') {
const id = (obj.sys as any).id as string;
const resolved = includesMap.get(id);
return resolved ? resolveLinks(resolved, includesMap, depth + 1) : obj;
}
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [k, resolveLinks(v, includesMap, depth)])
);
}
return value;
}
// Build includes map from raw API response
function buildIncludesMap(response: any): Map<string, unknown> {
const map = new Map<string, unknown>();
for (const entry of response.includes?.Entry ?? []) {
map.set(entry.sys.id, entry);
}
for (const asset of response.includes?.Asset ?? []) {
map.set(asset.sys.id, asset);
}
return map;
}