Guide · Productivity & Project Management Integrations

MCP Server Confluence — storage format, version conflicts, CQL search, API v2

Four Confluence API behaviours trip up MCP tool authors: page content is returned as "storage format" — an XHTML-based dialect — not Markdown, and update requests must send storage format XML back; attempting to send plain text or Markdown produces a 400 with a cryptic XHTML parse error; every page update requires the current version number + 1 — reading the page to get its version and then updating in a non-atomic sequence can race with concurrent editors, returning 409 "Conflict" when the version you send is stale; Confluence Cloud uses Authorization: Basic base64(email:api_token), not Bearer auth — a common mistake is putting the API token in a Bearer header, which silently fails with a 401; and the Confluence REST API v1 and v2 use incompatible identifier schemes — v1 uses string space keys like ~ENG, v2 uses numeric space IDs — mixing them returns 404s that look like the resource doesn't exist.

TL;DR

Use Basic auth with base64(email:api_token). Always fetch a page's current version before updating — pass version.number + 1 in the update body. Send page content as storage format XHTML (wrap paragraphs in <p> tags, not raw text). Use the v1 REST API for content operations; v2 for spaces. Use CQL for search: space = "ENG" AND type = page AND text ~ "keyword". Add ?expand=body.storage to page GETs to receive body content — it's omitted by default.

Authentication and client setup

Confluence Cloud uses Atlassian API tokens, not passwords. Generate a token at id.atlassian.com/manage-profile/security/api-tokens. The Authorization header combines your Atlassian account email and the API token using HTTP Basic auth. Do not use Bearer — that's for OAuth 2.0 flows, not personal API tokens.

import fetch from 'node-fetch';
import { z } from 'zod';

const CONFLUENCE_BASE_URL = process.env.CONFLUENCE_BASE_URL!;
// Example: 'https://yourorg.atlassian.net/wiki/rest/api'
// Note: Confluence Cloud REST API lives at /wiki/rest/api, NOT /rest/api

const email    = process.env.ATLASSIAN_EMAIL!;
const apiToken = process.env.ATLASSIAN_API_TOKEN!;

// Basic auth: base64(email:apiToken) — NOT Bearer
const authHeader = `Basic ${Buffer.from(`${email}:${apiToken}`).toString('base64')}`;

async function confluenceGet<T>(path: string): Promise<T> {
  const res = await fetch(`${CONFLUENCE_BASE_URL}${path}`, {
    headers: {
      Authorization:  authHeader,
      'Content-Type': 'application/json',
      Accept:         'application/json',
    },
  });

  if (!res.ok) {
    const body = await res.text().catch(() => '');
    throw new Error(`Confluence GET ${path} → ${res.status}: ${body}`);
  }

  return res.json() as Promise<T>;
}

async function confluencePost<T>(path: string, body: unknown): Promise<T> {
  const res = await fetch(`${CONFLUENCE_BASE_URL}${path}`, {
    method: 'POST',
    headers: {
      Authorization:  authHeader,
      'Content-Type': 'application/json',
      Accept:         'application/json',
    },
    body: JSON.stringify(body),
  });

  if (!res.ok) {
    const text = await res.text().catch(() => '');
    throw new Error(`Confluence POST ${path} → ${res.status}: ${text}`);
  }

  return res.json() as Promise<T>;
}

async function confluencePut<T>(path: string, body: unknown): Promise<T> {
  const res = await fetch(`${CONFLUENCE_BASE_URL}${path}`, {
    method: 'PUT',
    headers: {
      Authorization:  authHeader,
      'Content-Type': 'application/json',
      Accept:         'application/json',
    },
    body: JSON.stringify(body),
  });

  if (!res.ok) {
    const text = await res.text().catch(() => '');
    throw new Error(`Confluence PUT ${path} → ${res.status}: ${text}`);
  }

  return res.json() as Promise<T>;
}

Reading pages with body content

By default the Confluence REST API returns page metadata but no body content. Add expand=body.storage to include the storage format body in the response. The storage format is XHTML-based — it uses <p>, <h1><h6>, <ul>, <ac:structured-macro> for Confluence macros, and similar XML tags.

interface ConfluencePage {
  id:      string;
  title:   string;
  version: { number: number };
  body?:   { storage?: { value: string; representation: string } };
  _links?: { webui: string };
}

server.tool(
  'confluence_get_page',
  {
    page_id:       z.string().min(1),
    include_body:  z.boolean().default(true),
  },
  async ({ page_id, include_body }) => {
    const expand = include_body ? '?expand=body.storage,version' : '?expand=version';
    const page   = await confluenceGet<ConfluencePage>(`/content/${page_id}${expand}`);

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          id:            page.id,
          title:         page.title,
          version:       page.version.number,
          body_storage:  page.body?.storage?.value ?? null,
          url:           page._links?.webui
            ? `${CONFLUENCE_BASE_URL.replace('/wiki/rest/api', '')}${page._links.webui}`
            : null,
        }),
      }],
    };
  }
);

Creating and updating pages with storage format

Confluence's storage format is a superset of XHTML. Plain text sent as content produces a 400 because Confluence attempts to parse it as XML. Wrap content in <p> tags for paragraphs. For page updates, the version number in the request must be exactly current version + 1 — any other value (including the current version itself) returns 409.

// Convert simple Markdown-like text to Confluence storage format
// For full Markdown support, use a library like confluence-markdown-sync
function textToStorageFormat(text: string): string {
  return text
    .split('\n\n')
    .map(para => {
      const trimmed = para.trim();
      if (!trimmed) return '';

      // Headings
      if (trimmed.startsWith('### ')) return `<h3>${trimmed.slice(4)}</h3>`;
      if (trimmed.startsWith('## '))  return `<h2>${trimmed.slice(3)}</h2>`;
      if (trimmed.startsWith('# '))   return `<h1>${trimmed.slice(2)}</h1>`;

      // Code blocks (fenced)
      if (trimmed.startsWith('```')) {
        const lang    = trimmed.split('\n')[0].replace('```', '').trim() || 'none';
        const content = trimmed.replace(/^```[^\n]*\n/, '').replace(/```$/, '').trim();
        return [
          '<ac:structured-macro ac:name="code">',
          `<ac:parameter ac:name="language">${lang}</ac:parameter>`,
          `<ac:plain-text-body><![CDATA[${content}]]></ac:plain-text-body>`,
          '</ac:structured-macro>',
        ].join('');
      }

      // Default: paragraph
      return `<p>${trimmed.replace(/\n/g, '<br />')}</p>`;
    })
    .filter(Boolean)
    .join('\n');
}

server.tool(
  'confluence_create_page',
  {
    space_key:  z.string().min(1).describe('Space key, e.g. "ENG" or "~username"'),
    title:      z.string().min(1).max(255),
    content:    z.string().describe('Content as simple text/markdown — will be converted to storage format'),
    parent_id:  z.string().optional().describe('Parent page ID (nests the page under this parent)'),
  },
  async ({ space_key, title, content, parent_id }) => {
    const body: Record<string, unknown> = {
      type:  'page',
      title,
      space: { key: space_key },
      body:  {
        storage: {
          value:          textToStorageFormat(content),
          representation: 'storage',
        },
      },
    };

    if (parent_id) {
      body.ancestors = [{ id: parent_id }];
    }

    const page = await confluencePost<ConfluencePage>('/content', body);

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({ id: page.id, title: page.title, version: page.version.number }),
      }],
    };
  }
);

server.tool(
  'confluence_update_page',
  {
    page_id: z.string(),
    title:   z.string().min(1).max(255).optional(),
    content: z.string().optional(),
  },
  async ({ page_id, title, content }) => {
    // STEP 1: fetch current version — update requires version.number + 1
    const current = await confluenceGet<ConfluencePage>(
      `/content/${page_id}?expand=version,body.storage`
    );

    const updatedTitle   = title   ?? current.title;
    const updatedContent = content
      ? textToStorageFormat(content)
      : (current.body?.storage?.value ?? '<p></p>');

    // STEP 2: update with incremented version
    const updated = await confluencePut<ConfluencePage>(`/content/${page_id}`, {
      id:      page_id,
      type:    'page',
      title:   updatedTitle,
      version: { number: current.version.number + 1 },  // must be current + 1
      body:    {
        storage: {
          value:          updatedContent,
          representation: 'storage',
        },
      },
    });

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          id:      updated.id,
          title:   updated.title,
          version: updated.version.number,
        }),
      }],
    };
  }
);

If a concurrent editor updates the page between your GET and PUT, the version number you send will be stale — Confluence returns 409. Handle this by retrying the GET-then-PUT sequence (re-fetching to get the new current version) up to 3 times with a short random delay between attempts to reduce collision probability.

CQL search and space listing

Confluence Query Language (CQL) is the structured query syntax for Confluence content search. It's similar in intent to Jira Query Language (JQL) and supports field operators, text search, date comparisons, and boolean logic. Use it to find pages across spaces, filter by label, or search within a specific space.

server.tool(
  'confluence_search',
  {
    cql:     z.string().describe('CQL query, e.g. \'space = "ENG" AND type = page AND text ~ "kubernetes"\''),
    limit:   z.number().int().min(1).max(50).default(20),
    start:   z.number().int().min(0).default(0),
  },
  async ({ cql, limit, start }) => {
    // URL-encode the CQL query
    const params = new URLSearchParams({ cql, limit: String(limit), start: String(start) });
    const result = await confluenceGet<{
      results: Array<{ id: string; title: string; type: string; _links: { webui: string } }>;
      totalSize: number;
      start: number;
      limit: number;
    }>(`/content/search?${params}`);

    return {
      content: [{
        type: 'text',
        text: JSON.stringify({
          results:   result.results.map(r => ({ id: r.id, title: r.title, type: r.type })),
          total:     result.totalSize,
          start:     result.start,
          limit:     result.limit,
          has_more:  result.start + result.results.length < result.totalSize,
        }),
      }],
    };
  }
);

/*
  CQL cheat sheet for MCP tools:
  - type = page                           pages only (not blogposts, attachments)
  - space = "ENG"                         pages in space with key ENG
  - text ~ "keyword"                      full-text search (stemmed)
  - title = "Exact Title"                 exact title match (case-sensitive)
  - title ~ "partial"                     title contains "partial"
  - label = "release-notes"              pages tagged with a label
  - creator = currentUser()              pages created by the current user
  - created >= "2026-01-01"              pages created since Jan 1 2026
  - ancestor = "12345"                   pages under a specific parent page
  - space.type = "global"               global spaces only (not personal ~user spaces)

  Boolean operators: AND OR NOT
  Order: ORDER BY lastModified DESC / title ASC

  Example: find recently-updated pages in ENG space with "api" in title:
    space = "ENG" AND type = page AND title ~ "api" ORDER BY lastModified DESC
*/

server.tool(
  'confluence_list_spaces',
  { limit: z.number().int().min(1).max(50).default(25) },
  async ({ limit }) => {
    const result = await confluenceGet<{
      results: Array<{ id: number; key: string; name: string; type: string }>;
      size: number;
    }>(`/space?limit=${limit}&expand=description.plain`);

    return {
      content: [{
        type: 'text',
        text: JSON.stringify(
          result.results
            .filter(s => s.type === 'global')  // exclude personal spaces (~user)
            .map(s => ({ id: s.id, key: s.key, name: s.name }))
        ),
      }],
    };
  }
);

The space key (a short uppercase string like ENG or DEV) is the identifier to use in CQL queries and page creation. Personal spaces have keys starting with ~ followed by the user's account ID — filter them out with type === 'global' for most tool use cases. The numeric space ID appears in the Confluence REST API v2 but is not used in CQL.