MCP Tools: Pages and Content

Sixteen of the one hundred and five Operator MCP tools work on pages. Two create them, five read them, two write bodies, one changes metadata, one locks internal pages, one deletes them and four search your 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.

Fifteen of the sixteen are available to the Leed Assistant as well as to MCP clients — list_page_sync_state is MCP only — and all sixteen 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, API and internal 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, API or internal 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. A LOCKED internal page refuses body writes, like a PUBLISHED one, until update_page_metadata puts it into IN_REVISION.

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. A LOCKED internal page refuses body writes, like a PUBLISHED one, until update_page_metadata puts it into IN_REVISION. 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 type with no left-nav menu (a posts type) clears its folder ancestry. A page cannot move between an internal page type and any other kind; that is a 409.
  • 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, SCHEDULED or LOCKED: publish with publish_page_now or schedule_pages_by_id, and lock an internal page with lock_page. 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. A LOCKED internal page refuses every metadata change except status: "IN_REVISION", which revises it — see lock_page below.
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. Never across the internal line (409)
statusenumDRAFT, IN_REVISION, APPROVED, REPLACED — never PUBLISHED, SCHEDULED or LOCKED. IN_REVISION on a LOCKED internal page revises it
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.

Locking internal pages

lock_page

POST /api/page/:pageId/lock · Returns { page } · Available in the assistant

ParameterTypeRequiredDefaultNotes
pageIdstringYes—A page of an internal page type

Locks the current revision of an internal page — the same thing Lock revision does in the editor. An internal page is never published or scheduled — publish_page_now and both schedule tools refuse it with internal_page_not_publishable — so a locked revision is its settled version, the one your team reads. Locking also indexes the page for search_internal. Any other page is refused with a 400; publish it instead. Only a DRAFT, IN_REVISION or APPROVED page can be locked, so locking a page that is already locked is a 409. The tool needs page:publish, as publishing does, but it is low risk and not confirm-gated, because nothing it does reaches your site. How internal page types, their menus and their pages work in the CMS is on Internal Content.

A LOCKED page refuses body writes and every metadata change until it is revised. To revise it, call update_page_metadata with status: "IN_REVISION": that opens the next version as IN_REVISION, exactly as Revise does in the editor, and any other fields sent in the same call land on the new version. Revising needs page:publish too.

Deleting pages

delete_page

POST /api/page/:pageId/delete · Returns a preview, or { deleted, pageId, deletion, redirect, page } once confirmed · confirm-gated (route mode) · requires page:publish · Available in the assistant

ParameterTypeRequiredDefaultNotes
pageIdstringYes—The page to delete
redirectTostringFor a published page, to confirm—"404", or the pageId of the published page its live URLs should redirect to. Refused with a 400 for a page that was never published, or an internal page
menuIdsstring[]No—Further menus to remove the page from, beyond its page type’s own left-nav menu
confirmbooleanNofalseOmit for a preview. Pass true only after the user has approved it

What a delete does depends on the page, which is why the preview comes from the route rather than from your arguments. A published page is soft-deleted: it leaves the live site on the next published build, exactly as a delete from the editor does. A page that was never published is deleted permanently and at once, and so is an internal page, whose entries also leave the internal search index. Neither of those can be undone.

The first call, without confirm: true, changes nothing. It returns preview: true, the page (pageId, title, path, status), deletion (soft or permanent), an effect sentence, the liveUrls the page answers on (its current path and any aliases), what those URLs will become, and menus — the menus it will be removed from (pruned) and any other menus that still link to it (otherReferences). For a published page with no redirectTo yet, the preview says a choice is required and tells the model to ask the user for it rather than choose. When redirectTo is already on the preview call, the preview confirms it: the URLs redirect to the named page, or return a 404.

The second call, with the same arguments plus confirm: true, performs that delete. For a published page it is refused with a 400 unless redirectTo is given, and nothing changes. A redirect turns every live URL of the deleted page into an alias of the target on the next published build; "404" lets them return a 404. The target must be a published, non-internal page in the same workspace, managed by the CMS, and not the page being deleted; any other target is refused with a 400 naming the reason, on the preview and on the confirm alike.

The page is always removed from its page type’s own left-nav menu, and from any menu in menuIds. Both calls need page:publish on the page, so a caller without it gets the 403 on the preview too, and a missing or already-deleted page is a 404 on both. In the assistant the same tool parks behind an approval card, and approving it runs the confirmed call. What the editor’s own delete dialog offers is on Managing Pages.

Searching your own content

search_documentation, search_posts, search_api and search_internal are one search engine behind four hard-scoped views. The scope is baked into each tool’s route rather than passed as an argument, so a model cannot cross from documentation into blog posts, or from published content into internal pages, by changing a parameter.

ToolSearches
search_documentationPublished documentation pages
search_postsPublished posts — blog and article pages
search_apiPublished API reference pages
search_internalInternal pages — the content of internal page types, which is never published

All four 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 scope; 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 the pages in the tool’s scopeExact 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

The first three search only published pages, and never return an internal page. A draft an AI just created will not appear until somebody publishes it, which surprises people mid-import; use list_pages to see drafts. search_internal searches internal pages as they were last indexed — when the page was locked, and when an editing session on it ended — and never returns a documentation, post or API page. Its vector search runs against an index kept apart from your site’s. Semantic search is not gated by plan. The same search from the editor’s Internal section is described on Searching Content from the Left Panel, and internal content as a whole on Internal Content.

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