1. Home
  2. Technical Writing
  3. MCP Server for EDS da.live

Building an MCP Server for EDS da.live: Scripted AI Flow vs Agent Tools

By Jack Jin · October 5, 2025 · Updated October 4, 2026 · 10 min read · Includes my 9:57 video

There are two ways to let a model edit a da.live page, the newest way to author Edge Delivery Services sites when I built this. In a scripted flow, an Azure Function fetches the page, asks Claude for an edit and saves it. With an MCP server, get and save become two tools, and the model decides when to call them. I built both: same content quality, about twice the time and tokens for the tools, and a lot more flexibility. The code for both is below, with a side-by-side run of both versions that shows where the extra cost comes from.

Building an MCP Server for EDS da.live: From a Scripted AI Flow to Agent Tools

Recorded the night of October 5, 2025. Each part below links to its moment in the video, or watch it on YouTube.

First published as a LinkedIn video on October 5, 2025. Rewritten for this site in October 2026. Code: azure-da-mcp at 81e4cac, the last commit before I recorded, and 1ffb600 for the scripted version.

What changed after October 2025

A week later I pushed the same server much harder: Claude, Gemini and Azure OpenAI on one MCP server, a deploy to Azure Functions, and n8n on top. That's part two.

The repo kept going after that. In November 2025 it picked up tools to preview and publish, create pages and create folders. Over the next months it grew into the Content Factory, agents that generate, migrate and evaluate da.live content, which became my adaptTo() 2026 talk. This article stays with the October 2025 version: two tools, one weekend.

Content is where AI meets EDS, and da.live works in plain HTML

I think content and marketing enablement is ripe for AI. It has migration use cases, personalization use cases, and a lot more, if you can figure out how to get AI to work with content. AEM is a content management system, and Edge Delivery Services is no different.

As I covered in an earlier video, EDS comes in three flavors, and each one needs a completely different way of thinking about AI work:

How you author
What an AI integration works with
Word or Google Docs
The documents themselves
Universal Editor
AEM nodes: JCR nodes and their properties
da.live
HTML

This one is about da.live, which was Adobe's newest EDS flavor when I built this. Unlike Universal Editor, where you deal with how AEM thinks about content, here you're literally dealing with HTML. Both the get call and the save call carry HTML, and HTML is much cleaner to hand to a model than a tree of nodes.

Watch this part 0:00 Content is where AI meets EDS 0:31 Three EDS flavors, and da.live works in HTML

The first version, a scripted flow with one model call

I started with two Azure Functions. GetContent just fetches a page. No AI, just a call: you pass the bearer token you get from admin.da.live, and the source API hands back the page as HTML. Saving is a POST to the same path, with the HTML sent as form data in a field called data.

functions/src/modules/DaliveClient.js, lines 15 to 23 and 60 to 77

81e4cac on GitHub

export async function getContent(path, bearerToken) {
  try {
    const response = await axios.get(`${DALIVE_API_URL}${path}`, {
      headers: {
        'Authorization': `Bearer ${bearerToken}`
      },
      timeout: 5000,
      responseType: 'text' // Get raw HTML text, not JSON
    });
  // … retry and error handling trimmed
      // Create multipart form data with HTML as a file
      const formData = new FormData();
      formData.append('data', Buffer.from(html), {
        filename: 'content.html',
        contentType: 'text/html'
      });

      const response = await axios.post(
        `${DALIVE_API_URL}${path}`,
        formData,
        {
          headers: {
            'Authorization': `Bearer ${bearerToken}`,
            ...formData.getHeaders()
          },
          timeout: 5000
        }
      );

For the token, the repo's .env.example has the steps: open da.live, open the browser's DevTools, go to the Network tab, and copy the Authorization header value from a request.

EditContent is what I call the good old classical flow. Get the content, run it through whatever AI scaffolding you have, save it back. The scaffolding here is a prompt plus one call to Claude.

The scripted flow. Get Content calls the da.live source API with a GET. Edit Content calls the same GET, hands the HTML to Claude with AI scaffolding, then saves the result back to the source API.

The scripted flow. Get Content calls the da.live source API with a GET. Edit Content calls the same GET, hands the HTML to Claude with AI scaffolding, then saves the result back to the source API.

  • Function
  • Adobe API
  • LLM
The scripted flow, redrawn from the board I opened the video with. Every arrow is a call my code makes, in a fixed order.

functions/src/functions/EditContentFunction.js, the scripted version

1ffb600 on GitHub

      // PHASE 1: Fetch HTML content from da.live
      const daliveFetchStart = Date.now();
      let pageContent;
      try {
        pageContent = await getContent(path, bearerToken);
  // … timing and error handling trimmed
      // PHASE 2: Build LLM prompt with HTML content
      const llmPrompt = buildPrompt(command, pageContent.html, path);

      // PHASE 3: Call LLM API
      const llmCallStart = Date.now();
      let llmResponse;
      try {
        llmResponse = await generateEdit(llmPrompt);
  // … timing and error handling trimmed
      // PHASE 4: Update content in da.live with edited HTML
      const daliveUpdateStart = Date.now();
      try {
        await updateContent(path, llmResponse.editedHtml, bearerToken);

Keep one number in mind. This flow took about 15 seconds end to end, and that's with an expensive model that spends a bunch of reasoning tokens. A faster model would probably do it in 5 seconds, if you don't care about quality.

Watch this part 1:21 The scripted flow 2:45 The da.live source API 3:33 15 seconds end to end

Why I turned it into an MCP server

A scripted flow like this is a very old pattern. MCP, the Model Context Protocol, is a standard way to hand tools to a model. A server lists what it can do, and any client that speaks MCP (Claude Desktop, n8n, an agent you wrote) lets the model call those tools.

There were three reasons for the pivot:

  1. It's more universally plug and play. Agent platforms like n8n work with tools, and if my content operations are tools, any of them can use them.
  2. Content operations as tools have real uses: content migration, personalization, and more.
  3. I think this is where the world is going, even with the bottlenecks and drawbacks I'm about to get to.

Watch this part 4:00 Three reasons to make it an MCP server

Two MCP tools, and an agent that decides when to call them

The MCP server does two things: it gets content and it saves content. Everything else moves into a box I call the agent, because that's how n8n and a lot of folks name it. There are so many ways to draw this diagram, and I'm not a fan of drawing it this way, but this is where the board ended up:

Diagram titled EDS da.live, AI Enablement Foundational Work. Get Content calls admin.da.live/source GET. Edit Content calls Agent with Scaffolding, which talks to Claude and has two MCP server tools below it: admin.da.live/source GET and admin.da.live/source Save. A legend marks Function, LLM, Adobe API and MCP Server.

The board at the end of the video. The two calls the code used to make are now MCP tools, and the agent with its scaffolding decides when to use them.

The scaffolding moved too. It used to be the order of the calls in my code. Now it lives in how the agent prompts the model. The prompt in edit-content/v1.0.0.json says FIRST: Call get_dalive_content and LAST: Call save_dalive_content, and the code runs a loop: call the model, and if it asks for a tool, run the tool, add the result to the conversation and call the model again.

functions/src/modules/LlmClient.js, the tool loop

81e4cac on GitHub

      while (iterationCount < MAX_TOOL_ITERATIONS) {
        iterationCount++;
  // … logging and bookkeeping trimmed
        const response = await client.messages.create(requestParams);
  // … logging and bookkeeping trimmed
        // Check if response contains tool use
        const toolUseBlock = response.content.find(block => block.type === 'tool_use');

        if (toolUseBlock && mcpSession) {
  // … logging and bookkeeping trimmed
            toolResult = await callMcpTool(mcpSession, toolName, toolInput);
  // … logging and bookkeeping trimmed
          // Add tool result to conversation
          messages.push({
            role: 'assistant',
            content: response.content
          });

          messages.push({
            role: 'user',
            content: [
              {
                type: 'tool_result',
                tool_use_id: toolUseId,
                content: JSON.stringify(toolResult)
              }
            ]
          });

          // Continue conversation loop
          continue;
  // … logging and bookkeeping trimmed
        // No tool use - this is the final response
        const textBlock = response.content.find(block => block.type === 'text');

So what did it do? First, in terms of content quality, there was zero difference. Second, it took twice as long and used twice as many tokens: my 15 seconds became 30 or more. Before, I had a fairly scripted AI workflow, where the agentic level is maybe 2 out of 10. Now the agent decides when to get and save content. Slower, but much more scalable.

That loop is where the extra time and tokens come from. Below is a simplified model of both versions of the same edit, built from the two commits. Pick a version, and for the tool loop, a scenario. The log lists every call to the model in order, and the counters show the cost: how many times the model was called, and how many times the whole page went back to it.

The same edit, two versions

the loop in LlmClient.js

Both versions are in the log. The counts are for the tool loop following the order its prompt asks for.

Which version runs

What the model does

  1. Scripted flow: the code decides
  2. step 1 Code fetches the page HTML from admin.da.live.
  3. step 2 Code builds the prompt with the HTML inside it.
  4. call 1 Code calls the model once. It returns the edited HTML.
  5. step 3 Code saves the edited HTML to admin.da.live.
  6. Tool loop: the model decides
  7. call 1 Sends the prompt. The model asks for get_dalive_content.
  8. call 2 Sends the conversation so far, page HTML included. The model asks for save_dalive_content.
  9. call 3 Sends it all again, page HTML included. The model answers with text, so the loop ends.

Same edit. The scripted flow calls the model once. The tool loop calls it three times, and the page goes along on the last two.

Scripted flow: the code decides
Nothing to pick. The code runs the same four steps every time.
Running the four steps in order.
  1. step 1 Code fetches the page HTML from admin.da.live.
  2. step 2 Code builds the prompt with the HTML inside it.
  3. call 1 Code calls the model once, page HTML included. It returns the edited HTML. No tools are offered.
  4. step 3 Code saves the edited HTML to admin.da.live.
  • model calls
    1
  • page sent to the model
    1 time
  • tool calls
    0
  • page saved
    yes

Same order every time: get, one model call, save. The code decides, so the order can’t go wrong. In my test this took about 15 seconds end to end.

ok

Tool loop: the model decides
The model picks every step. Pick what it does:
Running the loop.
Follows the prompt get the page, save it, answer
  1. call 1 Sends the prompt. The model asks for get_dalive_content.
  2. tool Fetches the page HTML from admin.da.live and adds it to the conversation.
  3. call 2 Sends the whole conversation so far, page HTML included. The model asks for save_dalive_content.
  4. tool Saves the HTML the model wrote to admin.da.live.
  5. call 3 Sends the whole conversation so far, page HTML included. The model answers with text, so the loop ends.
  • model calls
    3
  • page sent to the model
    2 times
  • tool calls
    2
  • page saved
    yes

3 model calls where the scripted flow makes one, and the page goes to the model 2 times instead of once. That is where the extra time and tokens come from.

ok

Gets the page again before saving what Claude did in my demo
  1. call 1 Sends the prompt. The model asks for get_dalive_content.
  2. tool Fetches the page HTML from admin.da.live and adds it to the conversation.
  3. call 2 Sends the whole conversation so far, page HTML included. The model asks for get_dalive_content.
  4. tool Fetches the page HTML from admin.da.live and adds it to the conversation.
  5. call 3 Sends the whole conversation so far, page HTML included. The model asks for save_dalive_content.
  6. tool Saves the HTML the model wrote to admin.da.live.
  7. call 4 Sends the whole conversation so far, page HTML included. The model answers with text, so the loop ends.
  • model calls
    4
  • page sent to the model
    3 times
  • tool calls
    3
  • page saved
    yes

Still the right result, with one more model call and one more copy of the page in every call after it. Every time I ran it, it did different things.

ok

Saves without reading the page nothing in the loop stops it
  1. call 1 Sends the prompt. The model asks for save_dalive_content.
  2. tool Saves the HTML the model wrote to admin.da.live.
  3. call 2 Sends the whole conversation so far. The model answers with text, so the loop ends.
  • model calls
    2
  • page sent to the model
    0 times
  • tool calls
    1
  • page saved
    yes, unread

Saved, but the model never read the page, so it wrote HTML blind. The prompt says to get the page first. Nothing in the code enforces it. The scripted flow cannot do this.

warn

A simplified model: no real calls are made. The scripted flow follows EditContentFunction.js at 1ffb600, the tool loop follows LlmClient.js at 81e4cac.

Tracing the loop shows two more things. Every turn re-sends the whole conversation, so once the page is fetched, its HTML goes back to the model on every call after that. And nothing in the loop checks that a save happened, or that a get came first. The scripted flow guarantees both in code. The tool loop leaves them to the prompt.

Watch this part 4:55 Two tools and an agent that decides 6:16 Same quality, twice the time and tokens

The demo in Claude Desktop, reading the page before editing it

For the demo I drove the tools by hand from Claude Desktop, doing what an agent would normally do. Claude Desktop talks to MCP servers over stdio, and my server speaks HTTP, so a small bridge script in the repo reads JSON-RPC from stdin, forwards it to the function with the bearer token, and writes the answers back. The config is a few lines:

README.md, Claude Desktop setup

81e4cac on GitHub

   {
     "mcpServers": {
       "azure-da-mcp": {
         "command": "node",
         "args": ["/absolute/path/to/azure-da-mcp/functions/mcp-stdio-bridge.js"],
         "env": {
           "MCP_SERVER_URL": "http://localhost:7071/api/mcp",
           "DALIVE_BEARER_TOKEN": "your-dalive-bearer-token-here"
         }
       }
     }
   }

The page is a normal da.live page from my postal demo site. I pasted its editor URL into the chat. The tool accepts that, the admin.da.live URL or an aem.page URL, and normalizes it to /source/org/site/path.

Then I used what I call two-shot prompting. First: "Can you please get content from this page and let me know what you think." That fetches the content and gets it into context, the front of the model's memory. I can check that it read the page, because it tells me what it thinks. Then the real ask: "Looking at the first cards, can we have Air Hyper Express? Cost will be twice as much as the next highest cost, and write in the rest of the copy."

Top: the da.live editor showing a new Air Hyper Express card, Premium Choice, priced at $57.90, next to Ground Shipping at $9.65. Bottom: Claude Desktop saying the highest current cost is $28.95 for Priority Express so the new card will be $57.90, with two tool calls listed, Get dalive content and Save dalive content.

The result in the da.live editor, top, and in Claude Desktop, bottom. It read the highest price on the page and doubled it.

It got the content again, which is fine. Then it saved. The new card showed up in the da.live editor without me even refreshing the screen.

This is also the part to keep in mind: AI is unpredictable. Every time I run this, it does different things. That unpredictability is both a feature and a challenge. The wording of the scaffolding, how the agent prompts the model and how the model chooses its tools, is what drives how effective and how accurate your agent is.

Watch this part 7:13 Demo in Claude Desktop 7:41 Get the page first 8:17 Ask for the new card 9:22 The card in da.live

Same quality, twice the cost, far more flexible

Hopefully this is a good demo of MCP versus not. In the grand scheme of system design, it comes down to your requirements and what your product needs to do. Here's where I landed:

At this point the server was still a foundation. The bearer token is copied by hand from the browser, and the server only gets and saves; there's no create, preview or publish yet.

Next, I wanted to know whether the same server would work with models other than Claude, and from somewhere other than my laptop. That's part two.

Watch this part 9:32 Wrap-up