MCP Tools: Pages and Content

Thirteen of the one hundred and one Operator MCP tools work on pages. Two create them, five read them, two write bodies, one changes metadata and three search published content. This page is the parameter reference: what each tool takes, what it gives back, and where the sharp edges are. Publishing a page, now or on a schedule, is on MCP Tools: Publishing, Distribution and Users.

It is deliberately not the workflow. The order these tools must be called in, when a body can be filled and when it can only be edited, and what happens when an anchor goes stale are all on Authoring Pages Over MCP — read that first if you are writing content rather than looking up a field. Every tool name here is also in the alphabetical Operator MCP Tool Index.

Twelve of the thirteen are available to the Leed Assistant as well as to MCP clients — list_page_sync_state is MCP only — and all thirteen dispatch to the same permission-checked API routes the CMS itself calls, so your role is the ceiling on every one of them.

Creating pages

reserve_page

POST /api/page · Returns { page: { pageId, title, slug, url, status, pageTypeId }, menuPlacement? }, also delivered as structuredContent · Available in the assistant

ParameterTypeRequiredDefaultNotes
pageTypeIdstringYes—From list_page_types. A locked page type refuses new pages
titlestringYes—2–150 characters. The slug is derived from it when you send none
slugstringNotitle-derivedUsed verbatim. See below
menuFolderIdstringNotop levelDocumentation and API types only: an existing folder in the type’s own left-nav menu. Rejected for posts
menuIconstringNo—CSS class string for the nav entry, e.g. fa-solid fa-book. Rejected for posts
menuIconStylestringNo—Inline CSS for the icon; requires menuIcon. Rejected for posts
sourceIdstringNo—Tags the page with your own source commit; see Authoring Pages Over MCP

Creates the page in DRAFT status with an empty body and hands back its pageId. That id is minted server-side and cannot be supplied, which is what makes a multi-page import a two-pass job.

{ "pageTypeId": "u05nxr", "title": "Installing the CLI" }
{
  "page": {
    "pageId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
    "title": "Installing the CLI",
    "slug": "installing-the-cli",
    "url": "/docs/installing-the-cli/",
    "status": "DRAFT",
    "pageTypeId": "u05nxr"
  }
}

A documentation or API page is appended to that page type’s bound left-nav menu — at the top level, or inside the folder menuFolderId names. The append is best effort: it never fails the create, and a menuPlacement object in the response means the entry did not land as a fresh append (present, unavailable or failed). The page exists either way, so repair the nav with add_menu_item or move_menu_item rather than creating the page again. How folders become URLs is in Folders Set Your URLs.

create_page_from_markdown

POST /api/page/from-markdown · Returns the created page · Available in the assistant

ParameterTypeRequiredDefaultNotes
titlestringYes—2–150 characters
pageTypeIdstringYes—From list_page_types
markdownstringYes—Leed Markdown, sent raw — not fenced, not escaped
slugstringNotitle-derivedSame rules as reserve_page. Use it to preserve an existing URL on import
labelsstring[]No—Label ids
plannedDatestringNo—ISO date, YYYY-MM-DD
menuFolderId, menuIcon, menuIconStylestringNo—Same nav placement fields as reserve_page
sourceIdstringNo—Tags the page with your own source commit

The one-shot: creates the page and sets its body in a single call. Right for a standalone page, wrong for a set that cross-links, because the body it writes cannot reference a page that has not been reserved yet. Markdown using a formatting feature the page type disables is rejected with a 400 and nothing is created, and so is a body holding more than 50,000 table cells (markdown.too_many_table_cells); a warning in the response names anything the import dropped — a stripped {...} attribute group, a user-written id, or a line whose words were lost — and a validation array lists anything the write changed or dropped without blocking it.

The markdown you send is Leed Markdown; Overview and Cheat Sheet has one example of every feature. Raw HTML does not fail the call: a tag is kept as literal text and shows on the page as typed, and an HTML comment is stripped. A user-written id ({#x} or {id="x"}) is not supported on any element and is dropped.

Reading pages

get_page

GET /api/page/:pageId · Returns { page, aliases, rbacOverrides, openCommentCount } · Available in the assistant

ParameterTypeRequiredDefaultNotes
pageIdstringYes——
includeOpenCommentCountbooleanNotrueCount the body’s open comment threads into openCommentCount

Metadata, status, labels, dates and SEO fields. The body is not included — that is get_page_body. openCommentCount counts open comment threads in the body, emoji reactions included; when it is above zero, read them with get_page_review_state.

get_page_body

GET /api/page/:pageId/markdown · Returns { markdown, blocks }, also delivered as structuredContent · Available in the assistant

ParameterTypeRequiredDefaultNotes
pageIdstringYes——

Returns the body as Leed Markdown with every top-level block carrying its {data-id="…"} anchor, plus blocks — one { dataId, hash } per addressable block. blocks is the set update_page_body may target, and each hash is the baseHash token a replace or delete on that block must carry. Both must come from a read you just made — never from memory and never invented.

{
  "markdown": "## Requirements {data-id=\"8c07b3\"}\n\nNode 20 or later. {data-id=\"1de590\"}\n",
  "blocks": [
    { "dataId": "8c07b3", "hash": "v4:4f0c1a9e2b7d3c56" },
    { "dataId": "1de590", "hash": "v4:9a2e61d0c4b87f13" }
  ]
}

The markdown reflects the clean accepted-state body: a deletion that is still pending as somebody’s tracked suggestion is not shown.

get_page_review_state

GET /api/page/:pageId/review-state · Returns { pageId, counts, blocks } · Available in the assistant

ParameterTypeRequiredDefaultNotes
pageIdstringYes——
statusenumNoopen threadsWhich comment threads to return
includeTypesarrayNo["text"]Add "emoji" to include emoji reactions

One entry per block that carries review state — open comment threads, pending suggestions or both — in document order. Each entry gives the block’s dataId, markdown, text and hash, its comments and suggestions, and four flags — canReplace, canReplaceDirect, canInsertBefore and canInsertAfter — that say which update_page_body op the write would accept on that block. It is read-only: nothing on the MCP surface resolves a comment or a suggestion.

list_pages

GET /api/page · Returns the pages visible to you · Available in the assistant

ParameterTypeRequiredDefaultNotes
labelIdstringNo—Narrow to one label
pageTypeIdstringNo—Narrow to one page type

Both filters are optional and independent. The list is already scoped by your role, so a Read Only connection sees a shorter list than an Administrator’s — that is the permission model, not a bug. Managing Pages covers the same list as the CMS presents it.

list_page_sync_state

GET /api/page/state · Returns { rows, count, remaining, limit, offset } · MCP only

ParameterTypeRequiredDefaultNotes
pageTypeIdstringYes—The page type to report
limitintegerNo500Rows per call, capped at 500
offsetintegerNo0Rows to skip; page while remaining is above zero

One row per page in the page type: pageId, path, sourceId, status, scheduledFor, updatedAt, updatedBy, pendingSuggestionCount and assets. It is built for keeping an external documentation set in sync without reading every page. path here is the public site path, unlike the path other page responses return, which is folder ancestry. updatedAt and updatedBy move on metadata writes only, and pendingSuggestionCount can lag the editor by up to a minute — treat both as triage, not as a guard.

Writing bodies

set_page_body

POST /api/page/:pageId/content/fill · Returns { page: { pageId } }, also delivered as structuredContent · Available in the assistant

ParameterTypeRequiredDefaultNotes
pageIdstringYes——
markdownstringYes—The complete body. Sent raw
overwritebooleanNofalseReplace a non-empty body. Allowed only on a page that has never been published
sourceIdstringNo—Tags the page with your own source commit

New blocks need no data-id — ids are assigned on import. On a posts page type, markdown using a formatting feature the type disables is rejected with a 400 naming the offending features. On any page type, a body holding more than 50,000 table cells is rejected with a 400 (markdown.too_many_table_cells). Anything the import drops without blocking the write is named in a warning on the response.

update_page_body

POST /api/page/:pageId/content/ops · Returns { page: { pageId } }, also delivered as structuredContent · Available in the assistant

ParameterTypeRequiredDefaultNotes
pageIdstringYes——
opsarrayYes—At least one op, applied in order
modedirect or suggestNodirect over MCP, suggest in the assistantPass it explicitly on every call
sourceIdstringNo—Tags the page with your own source commit, in either mode

Each op is one of four shapes. insertAfter, insertBefore and replace take anchorId and non-empty markdown; delete takes anchorId alone. Every replace and delete must also carry baseHash — the block’s hash from your last get_page_body — or the call is a 400; an insert may carry one, and it is enforced when it does.

{
  "pageId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "mode": "suggest",
  "ops": [
    { "type": "replace", "anchorId": "1de590", "baseHash": "v4:9a2e61d0c4b87f13", "markdown": "Node 22 or later." },
    { "type": "insertAfter", "anchorId": "8c07b3", "markdown": "Bun 1.3 is also supported." }
  ]
}

In direct mode the edits change the body immediately and are not recoverable; in suggest mode they land as tracked suggestions attributed to the connected user. Over MCP direct is the default, so decide which you want before the first edit and pass mode every time. A replace gives the new block a fresh data-id, so re-read before targeting it again. If any anchor is missing, its block has changed since you read it, or it is otherwise refused, the entire op-set is rejected and nothing is applied. The op-set is also refused with a 400 when the body it would produce holds more than 50,000 table cells, even if each op’s own markdown is under that. The 409s and the recovery for each are on Authoring Pages Over MCP; what your team sees of a suggestion is on Suggesting Mode and Tracked Changes.

Changing metadata

update_page_metadata

PUT /api/page/:pageId · Returns the updated page · Available in the assistant

pageId is the only required field; every other field is optional and only the ones you send are written. These are the same fields the editor’s right rail exposes, described for humans on Page Settings.

Four of them behave in ways worth knowing before you send a bulk update:

  • slug — changing it recomputes the page’s URL and stages a next path row for the next publish. The value is de-duplicated on the way in, so the slug you get back may carry a -2 suffix. Changing title alone does not re-slug an existing page; only the create tools derive a slug from a title.
  • pageTypeId — moving a page to another page type also recomputes its path, and moving it to a non-documentation type clears its folder ancestry.
  • path — present in the tool’s schema and silently dropped by the route. The docs menu is the only writer of a docs page’s folder ancestry; a path persisted here would desync the published file location from the recorded URL. A call that sets it appears to succeed and does nothing.
  • status — cannot be set to PUBLISHED; publish with publish_page_now or schedule_pages_by_id. Any metadata change to a published page moves it to IN_REVISION, except a call that sends only sourceId, which stamps the page and leaves its status alone.
Every field update_page_metadata accepts
FieldTypeNotes
pageIdstringRequired. Which page to update
titlestring2–150 characters. Does not change the slug of an existing page
slugstringLower-case alphanumerics and single hyphens, max 150. De-duplicated; recomputes the URL
pageTypeIdstringMoves the page to another page type and recomputes its path
statusenumDRAFT, IN_REVISION, APPROVED, SCHEDULED, REPLACED — never PUBLISHED
sourceIdstringYour own source commit for the page. Sent alone, it stamps without changing status
summarystringRequired at publish on documentation page types
keywordsstring[]Rendered as meta keywords
labelsstring[]Label ids. Also drives label-scoped permission overrides
authorsstring[]User ids shown as bylines
contributorsstring[]User ids shown alongside authors
publishedAtdateDrives collection sort order, sitemap <lastmod>, feed dates and JSON-LD datePublished
plannedDatedateEditorial calendar date; controls display order and scheduling
dateFormatstringDisplay format for plannedDate, e.g. MMMM yyyy
hasBeenPublishedbooleanWhether this page has ever been live
formIdstringForm to render on the page
journeyIdstringLinks the page to a journey for attribution
featureImageobject{ assetId, src, width, height, alt, svg } — assetId, src, width, alt and svg are all required together
featureVideoobject{ assetId, videoId, videoOptions }
ogCardobject{ title (≤70), description (≤240), image }
sitemapPrioritynumber0.0–1.0
wordCountnumberNormally computed on save; settable but rarely useful
extraFrontmatterobjectUnmodeled frontmatter passed through verbatim on publish
disableCtabooleanSuppress the page’s call to action
disableAutolinkbooleanExclude the page from autolink rewriting
eleventyExcludeFromCollectionsbooleanKeep the page out of site collections and listings
privatebooleanRestrict who can see the page
previewOnlybooleanExcludes the page from production builds and from vector indexing. Defaults false
pathstringDropped by the route. Folder placement is a docs-menu save

Two notes on that table. previewOnly is documented because it is in the schema, not because it is a recommended workflow — there is no CMS control for it, and a page carrying it is absent from your production site and from the search index that search_documentation queries. This documentation set sets it on zero pages.

And modifiedAt is not settable. It is stamped server-side on every metadata write, so an import cannot backdate it. publishedAt is settable and is load-bearing for sort order, sitemaps, feeds and structured data — an AI creating pages should set it deliberately rather than leave it for somebody to notice later.

Searching your own content

search_documentation, search_posts and search_api are one search engine behind three hard-scoped views. The page-type kind is baked into each tool’s route rather than passed as an argument, so a model cannot cross from documentation into blog posts by changing a parameter.

ToolSearches
search_documentationPublished documentation pages
search_postsPublished posts — blog and article pages
search_apiPublished API reference pages

All three take the same arguments.

GET /api/page/content-search · Returns ranked pages with snippets · Available in the assistant

ParameterTypeRequiredDefaultNotes
querystringYes—2–200 characters
modeenumNohybridfreetext, vector or hybrid
limitintegerNo101–50
pageTypeIdsstring[]No—Narrows within the tool’s kind; can never widen it

pageTypeIds is the versioning axis: it is intersected server-side with the page types the tool’s kind already resolves to, so passing a blog page type to search_api narrows the search to nothing rather than escaping into blog content.

ModeHow it matchesWhen to use it
freetextFull-text search over published revisionsExact terms, product names, error strings, anything you would quote
vectorEmbeds the query and searches the workspace’s vector index by meaningConceptual questions where the page never uses your words
hybridRuns both and merges them with reciprocal rank fusion, deduplicated by pageThe default, and the right answer almost always

Only published pages are searchable. A draft an AI just created will not appear until somebody publishes it, which surprises people mid-import; use list_pages to see drafts.

This is the same engine that powers search on your published site, described from the reader’s side on Live Documentation Search.

Which fields a page type requires before any of these pages can be published is set per type; Configuring a Page Type covers that, and the full boundary of what a connected client may do is on What the Operator MCP Can and Cannot Do.

ESC