Guide · Headless CMS Integration

MCP Server Strapi — v4 REST API, populate, filters, JWT auth

Three Strapi behaviours catch MCP server authors off-guard: populate=* fetches all relation levels and produces enormous responses — a Strapi v4 article with a nested category, author, and media relation can easily return 50 KB when fully populated with ?populate=*; use selective populate syntax like populate[author][fields][0]=name&populate[author][fields][1]=email to fetch only what your tool needs; relation filters require the [populate] parameter too — you cannot filter on a relation field (filters[author][name][$eq]=Alice) unless you also include populate[author]=true in the same request, because Strapi does not join relations for filtering unless they are populated; and the Strapi v4 response envelope wraps data — every response has { data: { id, attributes: { ... } }, meta: { ... } } for single entries and { data: [{ id, attributes }], meta: { pagination } } for collections; your tool must unwrap attributes to get the actual field values.

TL;DR

Use the Strapi v4 REST API with selective populate[field][fields][]=name syntax — never populate=*. Authenticate with Authorization: Bearer <jwt> from POST /api/auth/local. Unwrap response.data.attributes before returning fields to agents. Use pagination[page] and pagination[pageSize] with pagination[withCount]=true to get total counts. Build media URLs by prepending STRAPI_URL to attributes.url from the media object.

Selective populate — never use populate=*

The Strapi v4 REST API does not populate relations by default. When you add ?populate=*, Strapi joins every relation at one level deep — for a blog post this might mean author, category, tags, featured image, SEO metadata, and related posts, all in one response. For MCP tool handlers, this is almost always more data than the agent needs. Strapi v4's granular populate syntax lets you specify exactly which relations to include and which fields to select from each.

import qs from 'qs';
import { z } from 'zod';

const STRAPI_URL = process.env.STRAPI_URL!;  // e.g. https://cms.example.com
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN!;  // API token (preferred over JWT for server-to-server)

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

// MCP tool — list articles with selective population
server.tool(
  'list_articles',
  {
    page:     z.number().int().min(1).default(1),
    pageSize: z.number().int().min(1).max(25).default(10),
    category: z.string().optional(),
    tag:      z.string().optional(),
  },
  async ({ page, pageSize, category, tag }) => {
    const params: Record<string, unknown> = {
      pagination: { page, pageSize, withCount: true },
      sort: 'publishedAt:desc',
      fields: ['title', 'slug', 'excerpt', 'publishedAt'],
      // Selective populate — only fetch name and email from author
      populate: {
        author: { fields: ['name', 'email'] },
        category: { fields: ['name', 'slug'] },
        // Nested image fields: just the URL, not the full asset object
        featuredImage: {
          fields: ['url', 'alternativeText', 'width', 'height'],
        },
      },
    };

    // Deep filter on relation — requires relation to also be populated
    if (category) {
      (params as any)['filters'] = {
        category: { slug: { $eq: category } },
      };
    }
    if (tag) {
      (params as any)['filters'] = {
        ...((params as any)['filters'] ?? {}),
        tags: { slug: { $eq: tag } },
      };
      // Also populate tags so we can filter on them
      (params.populate as any)['tags'] = { fields: ['name', 'slug'] };
    }

    const response = await strapiGet('/articles', params);

    // Unwrap Strapi v4 response envelope
    const articles = response.data.map((item: any) => {
      const a = item.attributes;
      return {
        id:           item.id,
        title:        a.title,
        slug:         a.slug,
        excerpt:      a.excerpt,
        publishedAt:  a.publishedAt,
        author:       a.author?.data?.attributes?.name ?? null,
        category:     a.category?.data?.attributes?.slug ?? null,
        featuredImage: a.featuredImage?.data?.attributes?.url
          ? `${STRAPI_URL}${a.featuredImage.data.attributes.url}`
          : null,
      };
    });

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

The qs library is essential for Strapi v4 because nested populate and filter parameters must be serialized as bracket-notation query strings (populate[author][fields][0]=name). Node.js's built-in URLSearchParams does not support nested objects — either use qs or build the query string manually.

Authentication — API tokens vs JWT

Strapi v4 supports two authentication mechanisms for API access. API tokens are created in the Strapi admin panel under Settings → API Tokens — these are long-lived, scoped to read-only or full-access, and are the correct choice for server-to-server MCP integrations. JWT tokens are obtained by calling POST /api/auth/local with a username and password — they expire (default 30 days) and require re-authentication on expiry. Use API tokens for MCP servers; reserve JWT for user-facing client applications.

// API token approach (preferred for MCP servers)
// Token created in Strapi Admin → Settings → API Tokens
const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN!;

async function strapiRequest(method: string, path: string, body?: unknown) {
  const res = await fetch(`${STRAPI_URL}/api${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${STRAPI_API_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: body ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(10_000),
  });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`Strapi ${method} ${path} failed: ${res.status} ${text}`);
  }
  return res.json();
}

// JWT approach (for user-authenticated MCP tools)
// Token valid 30 days by default; cache it and re-auth on 401
let cachedJwt: { token: string; expiresAt: number } | null = null;

async function getStrapiJwt(): Promise<string> {
  if (cachedJwt && Date.now() < cachedJwt.expiresAt) {
    return cachedJwt.token;
  }
  const res = await fetch(`${STRAPI_URL}/api/auth/local`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      identifier: process.env.STRAPI_USER!,
      password:   process.env.STRAPI_PASS!,
    }),
    signal: AbortSignal.timeout(10_000),
  });
  if (!res.ok) throw new Error(`Strapi auth failed: ${res.status}`);
  const data = await res.json();
  // Cache for 28 days (2 days before 30-day expiry)
  cachedJwt = {
    token: data.jwt,
    expiresAt: Date.now() + 28 * 24 * 60 * 60 * 1000,
  };
  return data.jwt;
}

// MCP tool that creates content (requires write token or CMS permissions)
server.tool(
  'create_article_draft',
  {
    title:   z.string().min(1),
    content: z.string().min(1),
    slug:    z.string().regex(/^[a-z0-9-]+$/),
  },
  async ({ title, content, slug }) => {
    const result = await strapiRequest('POST', '/articles', {
      data: { title, content, slug, publishedAt: null },  // null = draft
    });

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          id:    result.data.id,
          title: result.data.attributes.title,
          slug:  result.data.attributes.slug,
          status: 'draft',
        }, null, 2),
      }],
    };
  }
);

Filters — operators and deep relation filtering

Strapi v4 provides a rich filtering system using operator suffixes. Basic filters use filters[field][$operator]=value. Relation filters require the related collection to be populated — you must include both filters[relation][field][$eq]=value and populate[relation]=true (or a field-scoped populate) in the same request. Without the populate, Strapi returns all results instead of filtering on the relation, because it does not join the relation table unless instructed to.

// MCP tool — search articles with multiple filters
server.tool(
  'search_articles',
  {
    title_contains: z.string().optional(),
    author_name:    z.string().optional(),
    published_after: z.string().optional(),  // ISO date string
    published_before: z.string().optional(),
    page:           z.number().int().min(1).default(1),
    pageSize:       z.number().int().min(1).max(20).default(10),
  },
  async ({ title_contains, author_name, published_after, published_before, page, pageSize }) => {
    const filters: Record<string, unknown> = {};
    const populate: Record<string, unknown> = {};

    if (title_contains) {
      filters['title'] = { $containsi: title_contains };  // case-insensitive contains
    }
    if (published_after || published_before) {
      const dateFilter: Record<string, string> = {};
      if (published_after)  dateFilter['$gte'] = published_after;
      if (published_before) dateFilter['$lte'] = published_before;
      filters['publishedAt'] = dateFilter;
    }
    if (author_name) {
      // Deep filter on relation — MUST also populate the relation
      filters['author'] = { name: { $containsi: author_name } };
      populate['author'] = { fields: ['name'] };
    }

    const params = {
      filters,
      populate,
      pagination: { page, pageSize, withCount: true },
      sort: 'publishedAt:desc',
      fields: ['title', 'slug', 'publishedAt', 'status'],
    };

    const response = await strapiGet('/articles', params);

    const results = response.data.map((item: any) => ({
      id:          item.id,
      title:       item.attributes.title,
      slug:        item.attributes.slug,
      publishedAt: item.attributes.publishedAt,
      author:      item.attributes.author?.data?.attributes?.name ?? null,
    }));

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

// Available filter operators:
// $eq        — equals (case-sensitive)
// $eqi       — equals (case-insensitive)
// $ne        — not equals
// $lt / $lte — less than / less than or equal
// $gt / $gte — greater than / greater than or equal
// $in        — value is in array  { $in: ['draft', 'review'] }
// $notIn     — value not in array
// $contains  — string contains (case-sensitive)
// $containsi — string contains (case-insensitive)
// $null      — field is null
// $notNull   — field is not null
// $between   — value between two bounds { $between: [10, 20] }
// $startsWith / $endsWith — string prefix/suffix match

Media URLs — prepend the base URL

Strapi stores media files locally (by default) or on a cloud provider like S3, Cloudinary, or Uploadthing. For local storage, the url field in the media attributes is a path like /uploads/hero_image_abc123.jpg — a relative URL that only resolves when prepended with the Strapi server's base URL. For cloud storage, the url field is an absolute URL from the cloud provider. Always check whether the URL is absolute before prepending the base URL.

function resolveMediaUrl(url: string | null | undefined): string | null {
  if (!url) return null;
  // Absolute URL — cloud storage (S3, Cloudinary, etc.)
  if (url.startsWith('http://') || url.startsWith('https://')) return url;
  // Relative URL — local storage; prepend Strapi base URL
  return `${STRAPI_URL}${url}`;
}

// Health probe — verify Strapi is reachable
server.resource('strapi_health', 'cms://strapi/health', async () => {
  try {
    // Strapi exposes /_health as a built-in health endpoint (no auth required)
    const res = await fetch(`${STRAPI_URL}/_health`, {
      signal: AbortSignal.timeout(5000),
    });
    const healthy = res.ok;
    return {
      contents: [{
        uri: 'cms://strapi/health',
        text: JSON.stringify({ status: healthy ? 'ok' : 'degraded', http: res.status }),
      }],
    };
  } catch (err: any) {
    return {
      contents: [{
        uri: 'cms://strapi/health',
        text: JSON.stringify({ status: 'error', message: err.message }),
      }],
    };
  }
});