“Update the meta description on the pricing page” used to mean four clicks and a save button. Now it’s a sentence you type into a chat window — and the CMS just does it.
That shift is what Optimizely’s MCP server actually delivers. MCP is an open standard that gives AI assistants typed, contract-defined access to real systems. Optimizely built its SaaS CMS on top of it, so content items, content types, media, experiences, and publishing are all reachable in one conversation instead of a maze of screens.
The interesting part isn’t the chat interface — it’s what happens underneath. You describe the outcome; the assistant picks a tool and assembles a payload. Most of the friction in practice comes down to getting that payload shape right: a reference formatted as an object instead of a string, or a nested property stringified when it should be a plain object, is usually the difference between an update that lands and one that doesn’t.
No exhaustive tool catalog here. Just the operations I reach for most, the payloads that actually work, and the near-misses worth knowing about.
Connecting to the CMS (SaaS) MCP Server
Before any of these tool calls work, the AI client needs to be pointed at Optimizely’s CMS (SaaS) MCP server and authenticated against an actual Optimizely account.
Add the server. Point the AI client’s MCP configuration at:
https://cms.mcp.opal.optimizely.com/mcp
First connection triggers an OAuth flow: log in with Opti ID, select the Opal instance, authorize. No separate API token — access follows the account’s existing permissions.
Verify with: “List all of my content types.” A working connection returns real data and shows cms_ -prefixed tools in the client’s tool list.
Debugging with MCP Inspector. To inspect the server directly — outside any AI client — install it globally:
npm i -g @modelcontextprotocol/inspector
Then launch it:
npx @modelcontextprotocol/inspector
This opens a local UI at http://localhost:6274. Set transport to Streamable HTTP, paste in https://cms.mcp.opal.optimizely.com/mcp, and connect — it walks through the same Opti ID/Opal OAuth flow. From there, the UI lists every exposed tool with its raw input schema and lets you fire test calls and inspect exact request/response JSON, which is faster than round-tripping through chat when a payload isn’t behaving.
How MCP and Opal Fit Together
The client sends tool invocations over Streamable HTTP to the MCP server. On first connection, an OAuth handshake through Opti ID resolves which Opal instance — and therefore which CMS instance — you’re authorized against. From there, Opal routes every cms_* call to the Content Management API, applying your existing account permissions. The same tools are also available to Opal agents, which invoke them without a client in the loop.

What This Walkthrough Builds
One Experience page, start to finish, built entirely through MCP tool calls — no CMS UI involved. The page gets created, its basic properties filled in, a section added to its layout, that section given its own properties, and finally a content area inside it populated with both a shared block and an inline block. Each step pairs a real payload with the reasoning behind it, so the shape of the JSON makes sense rather than just working by coincidence.
Step 1: Create the Experience Page
Every walkthrough needs a canvas. It starts with cms_create_content_item, which creates a bare content item of whatever type is specified — here, an Experience page.
{
"ContentType": "LandingPageExperience",
"DisplayName": "Summer Sale Landing Page",
"Locale": "en",
"RouteSegment": "summer-sale"
}
ContentType, DisplayName, and Locale are required. Container is optional — omit it and the page lands under the site root, fine for a draft that gets relocated later.
The response returns a ContentKey, the GUID that every subsequent step references:
{
"success": true,
"contentKey": "21dac3bb9aa849c29d3fb379362b3e9d",
"contentVersion": "3363",
"message": "Empty draft content created successfully.",
"opalInstruction": "Now call cms_update_content_item with contentKey '21dac3bb9aa849c29d3fb379362b3e9d' and version '3363' to populate the content properties."
}
Step 2: Populate the Page’s Basic Properties
With the page created but empty, cms_update_content_item fills in the fields an editor would normally set from the properties panel.
{
"ContentKey": "21dac3bb9aa849c29d3fb379362b3e9d",
"Properties": {
"MetaTitle": "Summer Sale — Up to 50% Off",
"MetaDescription": "Shop our biggest sale of the year on outdoor gear and apparel."
}
}
Two rules:
Propertiesmust be a real JSON object at every nesting level — never a stringified blob.- This page has no published version yet, so the update lands on the primary draft directly. If it were already live, the same call would auto-create a new draft instead.
Step 3: Add a Section
cms_update_content_item again, this time patching composition to add a section to the experience created in Step 1.
{
"ContentKey": "21dac3bb9aa849c29d3fb379362b3e9d",
"Properties": {
"composition": {
"displayName": "Summer Sale Landing Page",
"nodeType": "experience",
"layoutType": "outline",
"nodes": [
{
"displayName": "New Blank Section",
"nodeType": "component",
"component": {
"contentType": "HeroBlock"
}
}
]
}
}
}
What’s happening in this payload:
ContentKeyidentifies the experience page created in Step 1 — this call updates it, not a new page.- The root
displayNamematches the experience’s actual name, replacing the placeholder used earlier. HeroBlockstands in for the placeholder content type. Any block with"sectionEnabled"in itscompositionBehaviorscan be placed directly in the outline this way, without Row/Column wrapping.compositionis a plain JSON object here, like every other property — no stringification needed.
Step 4: Populate the Section’s Properties
The HeroBlock section added in Step 3 is empty. This call fills in its properties via cms_update_content_item — a heading, a rich-text description, and a content area holding one shared block and one inline block. Because composition.nodes replaces rather than merges, the full tree from Step 3 is resent with this node’s properties now populated.
{
"ContentKey": "21dac3bb9aa849c29d3fb379362b3e9d",
"Properties": {
"composition": {
"displayName": "Summer Sale Landing Page",
"nodeType": "experience",
"layoutType": "outline",
"nodes": [
{
"displayName": "New Blank Section",
"nodeType": "component",
"component": {
"contentType": "HeroBlock",
"properties": {
"Heading": {
"value": "Why Shop With Us"
},
"Description": {
"value": {
"html": "<p>Free shipping, easy returns, and deals updated daily.</p>"
}
},
"RelatedCards": {
"value": [
{
"reference": "cms://content/dce9d8e4af4e40369c4b17af989e2930"
},
{
"displayName": "Free Shipping Card",
"contentType": "CardBlock",
"properties": {
"Heading": {
"value": "Free Shipping"
},
"Link": {
"value": {
"text": "Shop Now",
"url": "https://www.example.com/summer-sale",
"title": "",
"target": ""
}
}
}
}
]
}
}
}
}
]
}
}
}
Reading the property shapes, field by field:
Heading— a plain string property. Inside a component’spropertiesobject, every value is wrapped in{"value": ...}— unlike the flat top-levelPropertiesused in Step 2, which takes values unwrapped.Description— a rich-text (XhtmlString) field. The wrapper becomes{"value": {"html": "..."}}. The HTML itself must stay one continuous string with no\nline breaks between tags.RelatedCards— a content area, sovalueis an array of components. The first entry,{"reference": "cms://content/{guid}"}, points at a block that already exists elsewhere — a shared block. The second entry defines a block inline, right in this payload — an inline block — usingdisplayName,contentType, and its ownpropertiesobject, recursively following the same{"value": ...}shape.Link— inside the inlineCardBlock, a single link field takes{"value": {"text", "url", "title", "target"}}.urlisn’t limited tocms://content/{guid}— a plain external URL works exactly the same way when the link points off-site rather than at CMS content.
Step 5: Publish the Page
Everything so far has been sitting on a draft version. cms_publish_content_item promotes a specific version to published.
{
"ContentKey": "21dac3bb9aa849c29d3fb379362b3e9d",
"Version": "3"
}
ContentKey— the same GUID used throughout every step; identifies the experience page itself.Version— a content item can have several versions (drafts, past published versions, rejected ones). This tells the tool exactly which draft to promote, so the call needs the version number of the draft built up across Steps 2–4, not just the content key.
A quick way to get that number without guessing: cms_get_content_data on the same ContentKey returns the current version in its response — check that value right before publishing.
Wrapping Up
Four tools, six calls, one Experience page: cms_create_content_item to lay the foundation, cms_update_content_item three times over to set core properties, add a section, and populate it with a shared block, an inline block, rich text, and a link, cms_get_content_data once to pull the version number, then cms_publish_content_item to make it live. No properties panel, no drag-and-drop builder — just payloads with the right shape.
Final Thoughts
The MCP server doesn’t change what the CMS can do — it changes how you reach it. The tools map cleanly onto the same operations the UI exposes, which means the real skill isn’t memorizing endpoints, it’s getting the payload shapes right: objects where objects are expected, cms://content/{guid} for references, {"value": ...} wrappers inside component properties, and version numbers before publishing. Get those right once, and the same handful of tools scales from tweaking a meta title to assembling a full experience page, one clear step at a time.
Everything here was still a human, in a chat window, calling tools one at a time. The next step is handing that sequence to Opal, Optimizely’s agent orchestration layer — where these same tools get wired into a specialized agent’s toolset and chained into a workflow that runs on its own: a brief comes in, and an agent creates the page, builds the section, populates the content, and publishes it without a person driving each call. That’s the direction worth exploring next, and the subject of a follow-up post.
Happy Optimizing!!!