Guide · Headless CMS Integration

MCP Server Sanity — GROQ queries, Portable Text, draft vs published

Three Sanity behaviours catch MCP server authors off-guard: GROQ projections are mandatory for efficient tool responses — a bare *[_type == "post"] query without a projection returns every field including large Portable Text bodies, asset objects, and internal _rev metadata, easily producing 500 KB responses from a modest content set; always pair queries with a { ... } projection selecting only the fields your tool needs; Portable Text is a structured block array, not a string — returning the raw Portable Text value from a field like body sends a JSON array of block nodes to the agent instead of readable prose, so you must convert it with @portabletext/to-html or the toPlainText utility before the tool returns; and draft documents have IDs prefixed with drafts. — if your Sanity client is configured with a token that has editor permissions, queries against the main dataset return both draft and published documents, and your tool must filter or de-duplicate them explicitly.

TL;DR

Use @sanity/client v6+ with useCdn: true for all read-only MCP tool handlers. Always include a GROQ projection ({ title, slug, ... }) to limit field selection. Resolve asset references inline with asset->url in the projection instead of resolving them in code. Convert Portable Text with toPlainText (from @portabletext/to-html) before returning to agents. Use !(_id in path('drafts.**')) in queries when you want published-only content.

GROQ projections — always shape the response

GROQ (Graph-Relational Object Queries) is Sanity's query language. A GROQ query has two parts: the filter expression (*[_type == "post" && defined(slug)]) and an optional projection ({ title, slug, publishedAt, "author": author->name }). Without a projection, Sanity returns the full document including every field, nested reference stub, and internal system field. For MCP tool handlers, always write a tight projection that returns only what the agent needs — this reduces response size, speeds up queries, and keeps responses within MCP context limits.

import { createClient } from '@sanity/client';
import { toPlainText } from '@portabletext/to-html';
import { z } from 'zod';

const sanity = createClient({
  projectId: process.env.SANITY_PROJECT_ID!,
  dataset:   process.env.SANITY_DATASET ?? 'production',
  apiVersion: '2024-01-01',  // pin API version — never use 'vX'
  useCdn:    true,           // CDN for reads; set false for real-time queries
  // token: process.env.SANITY_READ_TOKEN,  // only needed for private datasets
});

// MCP tool — list published blog posts with GROQ projection
server.tool(
  'list_blog_posts',
  {
    limit:  z.number().int().min(1).max(50).default(10),
    offset: z.number().int().min(0).default(0),
    tag:    z.string().optional(),
  },
  async ({ limit, offset, tag }) => {
    // Exclude drafts with !(_id in path('drafts.**'))
    // Inline dereference: author-> resolves the reference and picks name
    // "imageUrl": mainImage.asset->url   resolves asset ref to a CDN URL
    const tagFilter = tag ? ' && $tag in tags[]->slug.current' : '';
    const query = `*[
      _type == "post"
      && defined(slug.current)
      && !(_id in path('drafts.**'))
      ${tagFilter}
    ] | order(publishedAt desc) [$offset...$end] {
      _id,
      title,
      "slug": slug.current,
      publishedAt,
      excerpt,
      "imageUrl": mainImage.asset->url,
      "author": author->{name, "avatar": image.asset->url},
      "tags": tags[]->{title, "slug": slug.current}
    }`;

    const posts = await sanity.fetch(query, {
      offset,
      end: offset + limit,
      ...(tag ? { tag } : {}),
    });

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

The dereference operator (->) follows a reference link and picks fields from the referenced document inline in the projection. author->{name, bio} resolves the author reference and returns only name and bio from the author document — no extra round-trips, no manual joins. Asset references work the same way: mainImage.asset->url resolves the Sanity asset document and returns its CDN URL as a plain string.

Portable Text — convert before returning to agents

Portable Text is Sanity's format for rich structured content. A Portable Text value is an array of block objects: [{ _type: 'block', style: 'normal', children: [{ _type: 'span', text: 'Hello' }] }]. It can also contain custom block types for images, call-outs, code snippets, or any schema-defined component. Returning the raw Portable Text array to an agent is wasteful — it adds 5–10x the tokens compared to plain text, and reasoning over block structure is harder than reasoning over prose. Convert the value to plain text or HTML before the tool returns.

import { toPlainText, PortableTextComponents, PortableText } from '@portabletext/to-html';
// Note: @portabletext/to-html v2+ exports toPlainText
// For React rendering server-side, use @portabletext/react

// Plain text — most token-efficient for agent reasoning
function portableTextToPlain(blocks: unknown[]): string {
  if (!Array.isArray(blocks)) return '';
  return toPlainText(blocks as any);
}

// HTML with custom component renderers
function portableTextToHtml(blocks: unknown[]): string {
  if (!Array.isArray(blocks)) return '';
  // @portabletext/to-html v2 API
  return PortableText(blocks as any, {
    components: {
      types: {
        // Custom block type: embedded image
        image: ({ value }) => {
          const url = value?.asset?.url ?? '';
          const alt = value?.alt ?? '';
          return url ? `${alt}` : '';
        },
        // Custom block type: code snippet
        code: ({ value }) =>
          `
${
            escapeHtml(value?.code ?? '')
          }
`, }, marks: { // Custom mark: internal link internalLink: ({ children, value }) => { const slug = value?.reference?.slug?.current ?? '#'; return `${children}`; }, }, }, }); } function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>'); } // MCP tool — get full article with body text server.tool( 'get_article', { slug: z.string(), format: z.enum(['plain', 'html']).default('plain'), }, async ({ slug, format }) => { const query = `*[_type == "post" && slug.current == $slug && !(_id in path('drafts.**'))][0] { _id, title, "slug": slug.current, publishedAt, body, // Portable Text array — will be converted below "author": author->{name}, "imageUrl": mainImage.asset->url }`; const post = await sanity.fetch(query, { slug }); if (!post) { return { content: [{ type: 'text', text: JSON.stringify({ error: 'Not found' }) }] }; } const body = format === 'html' ? portableTextToHtml(post.body ?? []) : portableTextToPlain(post.body ?? []); return { content: [{ type: 'text', text: JSON.stringify({ ...post, body }, null, 2), }], }; } );

Draft vs published — filter explicitly

Sanity stores draft documents alongside published documents in the same dataset. A draft document has its _id prefixed with drafts. — for example, drafts.c7a2f3b1-... is the draft version of published document c7a2f3b1-.... When you query Sanity with an editor or admin token, the response includes both versions. Most MCP tool handlers should return only published content — filtering drafts prevents agents from reasoning over unreleased content and prevents leaking unpublished material.

// Filter patterns for GROQ queries

// 1. Exclude all drafts — most common for public-facing tools
const publishedOnly = `*[_type == "post" && !(_id in path('drafts.**'))]`;

// 2. Get ONLY drafts — useful for content preview/review tools
const draftsOnly = `*[_type == "post" && _id in path('drafts.**')] {
  _id,
  "publishedId": string::split(_id, 'drafts.')[1],  // strip 'drafts.' prefix
  title,
  _updatedAt
}`;

// 3. Get published document with its draft override (if any)
// Useful for content editors who need to see draft state vs published state
server.tool(
  'get_post_with_draft_status',
  { slug: z.string() },
  async ({ slug }) => {
    // Fetch both published and draft in one query
    const results = await sanity.fetch(
      `{
        "published": *[_type == "post" && slug.current == $slug && !(_id in path('drafts.**'))][0] {
          _id, title, publishedAt, _updatedAt
        },
        "draft": *[_type == "post" && slug.current == $slug && _id in path('drafts.**')][0] {
          _id, title, _updatedAt
        }
      }`,
      { slug }
    );

    const hasDraft = !!results.draft;
    const isPublished = !!results.published;

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          slug,
          is_published: isPublished,
          has_unpublished_draft: hasDraft && isPublished,
          is_draft_only: hasDraft && !isPublished,
          published_at: results.published?.publishedAt ?? null,
          last_modified: results.draft?._updatedAt ?? results.published?._updatedAt ?? null,
        }, null, 2),
      }],
    };
  }
);

Asset resolution — dereference in the query, not in code

Sanity asset fields store a reference object like { _type: 'image', asset: { _ref: 'image-abc123-1200x800-jpg' } }. The asset URL is not stored directly in the document — you must either dereference it in GROQ (asset->url) or call the @sanity/image-url builder with the asset reference to generate a CDN URL with image transformation parameters. Dereferencing in GROQ is more efficient because it eliminates a round-trip; the builder is useful when you need dynamic resizing.

import imageUrlBuilder from '@sanity/image-url';

const builder = imageUrlBuilder(sanity);

function sanityImageUrl(source: unknown, width?: number, height?: number): string | null {
  if (!source) return null;
  let url = builder.image(source as any);
  if (width)  url = url.width(width);
  if (height) url = url.height(height);
  return url.auto('format').url();  // auto-selects webp/avif for supported browsers
}

// Prefer GROQ dereference for simple URL access:
const queryWithImages = `*[_type == "product"] {
  _id,
  name,
  "heroUrl":       heroImage.asset->url,    // full resolution
  "thumbnailUrl":  thumbnail.asset->url,
  "galleryUrls":   gallery[].asset->url     // array of image URLs
}`;

// Use builder only when you need transform parameters at query time:
server.tool(
  'get_product_images',
  { productId: z.string(), width: z.number().int().min(100).max(2000).default(800) },
  async ({ productId, width }) => {
    const product = await sanity.fetch(
      `*[_type == "product" && _id == $id][0] { heroImage, gallery }`,
      { id: productId }
    );
    if (!product) {
      return { content: [{ type: 'text', text: JSON.stringify({ error: 'Not found' }) }] };
    }

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          hero:    sanityImageUrl(product.heroImage, width),
          gallery: (product.gallery ?? []).map((img: unknown) =>
            sanityImageUrl(img, width)
          ),
        }, null, 2),
      }],
    };
  }
);

// Health probe — verifies dataset access
server.resource('sanity_health', 'cms://sanity/health', async () => {
  try {
    // Lightweight query — just fetch the dataset metadata
    const result = await sanity.fetch('{"ok": true, "count": count(*[_type != ""])]}');
    return {
      contents: [{
        uri: 'cms://sanity/health',
        text: JSON.stringify({ status: 'ok', document_count: result.count }),
      }],
    };
  } catch (err: any) {
    return {
      contents: [{
        uri: 'cms://sanity/health',
        text: JSON.stringify({ status: 'error', message: err.message }),
      }],
    };
  }
});