ChanlChanl
Tools & MCP

How to Build an MCP Apps Widget for Your CX Agent

MCP Apps (SEP-1865) lets your MCP server deliver interactive forms, dashboards, and buttons inside AI chat conversations. Here's how to build one for CX agents.

DGDean GroverCo-founderFollow
May 16, 2026
13 min read
Inline Return Confirmation Widget Rendered in an AI Chat Conversation

Your AI agent just walked a customer through an order return. It confirmed eligibility, explained the process, listed their options. Then it asks: "Refund to original payment, or store credit?"

The customer types "original payment." Your agent confirms. Done.

Except the customer isn't sure what card is on file. They want to see it. Your agent reads out the last four digits from your CRM. They're still not sure. You're now three more turns deep into a conversation that should have been one click.

That's the wall text-only agents hit on transactional CX. MCP Apps is the way through it.

What Text Responses Can't Do

AI agents are excellent at gathering information, reasoning about it, and communicating decisions in natural language. They're less good at collecting structured input from users, showing visual comparisons, confirming irreversible actions, or presenting stateful information that changes.

The gap shows up in specific interaction patterns that CX teams deal with constantly:

  • The customer needs to pick from a set of options (refund method, appointment slot, plan tier)
  • The customer needs to confirm an action before the agent takes it (return, cancellation, account change)
  • The customer needs to see something visual (order status, their current plan vs. upgrade options, a map of service areas)
  • The agent needs structured input the customer would fill into a form (shipping address correction, payment details, feedback rating)

Without interactive UI, you handle these with follow-up links ("I've sent you an email to complete this"), verbal data collection ("Please tell me your preferred refund method: original payment, or store credit"), or multi-turn Q&A that exhausts both the customer and the context window.

MCP Apps changes this. Your MCP server can serve a widget that appears directly in the conversation. A form, a picker, a tracker. The customer interacts with it inline, and the result flows back to your agent automatically.

How the Protocol Works

MCP Apps is tracked as SEP-1865 in the Model Context Protocol specification, developed collaboratively by Anthropic and OpenAI through late 2025 and early 2026. It adds two things to standard MCP: a ui:// URI scheme for declaring UI resources, and a tool metadata field for linking tools to their widgets.

The flow works like this:

I want to return my order Route message to agent callTool("initiate_return", {orderId}) Result + _meta.ui.resourceUri Tool result references ui://return/confirm Fetch ui://return/confirm resource Returns sandboxed HTML widget Renders widget inline in conversation Clicks "Confirm Return" button JSON-RPC bridge sends user action Confirmation received, proceed Return confirmed. Refund in 3-5 days. Customer Host (Claude / ChatGPT) Agent MCP Server
MCP Apps flow: tool call triggers widget render, user interaction flows back to agent

Your MCP server registers two things for each UI-enabled tool: the tool itself (with a _meta field pointing to the resource) and the UI resource (an HTML file served under the ui:// URI scheme). When the host calls the tool and sees the _meta.ui.resourceUri, it fetches the resource and renders it as a sandboxed iframe.

Building a Return Confirmation Widget

Let's build a concrete example: a return confirmation widget that appears inline after your agent determines a return is eligible, shows the customer what they're confirming, and collects their refund preference before the agent takes any action.

First, register the tool and resource on your MCP server:

mcp-server/return-tool.ts·typescript
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import {
  CallToolRequestSchema,
  ListResourcesRequestSchema,
  ReadResourceRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
 
const server = new Server({ name: "cx-return-server", version: "1.0.0" });
 
// Register the tool with a UI resource reference
server.setRequestHandler(ListToolsRequestSchema, async () => ({
  tools: [
    {
      name: "initiate_return",
      description:
        "Start a return for an eligible order. Renders a confirmation widget " +
        "where the customer selects their refund preference before any action is taken.",
      inputSchema: {
        type: "object",
        properties: {
          orderId: { type: "string" },
          customerId: { type: "string" },
          returnReason: { type: "string" },
        },
        required: ["orderId", "customerId", "returnReason"],
      },
      _meta: {
        ui: {
          resourceUri: "ui://return-confirm/widget",
        },
      },
    },
  ],
}));

Now register the UI resource itself. This is the HTML the host will render:

mcp-server/return-widget-resource.ts·typescript
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
  resources: [
    {
      uri: "ui://return-confirm/widget",
      name: "Return Confirmation Widget",
      mimeType: "text/html+mcp",
      description: "Interactive return confirmation with refund preference selector",
    },
  ],
}));
 
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
  if (req.params.uri !== "ui://return-confirm/widget") {
    throw new Error("Unknown resource");
  }
 
  return {
    contents: [
      {
        uri: req.params.uri,
        mimeType: "text/html+mcp",
        text: RETURN_WIDGET_HTML,
      },
    ],
  };
});

The widget HTML itself is a self-contained React component (or plain HTML) that uses the MCP Apps SDK to communicate with your server:

mcp-server/return-widget.html·html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <script src="https://apps.extensions.modelcontextprotocol.io/sdk/v1/mcp-app.js"></script>
  <style>
    body { font-family: system-ui, sans-serif; margin: 0; padding: 16px; }
    .order-card { background: #f5f5f5; border-radius: 8px; padding: 12px; margin-bottom: 16px; }
    .options { display: flex; gap: 8px; flex-direction: column; }
    button {
      padding: 10px 16px;
      border-radius: 6px;
      border: 1px solid #ddd;
      cursor: pointer;
      background: white;
      font-size: 14px;
    }
    button.primary { background: #c2724a; color: white; border-color: #c2724a; }
    button:hover { opacity: 0.9; }
  </style>
</head>
<body>
  <div id="root"></div>
  <script>
    const { useApp } = window.MCPApp;
 
    function ReturnConfirmWidget() {
      const app = useApp();
      const [selected, setSelected] = React.useState(null);
      const [submitted, setSubmitted] = React.useState(false);
 
      // Tool call context is passed via app.context
      const { orderId, returnReason, originalPaymentLast4 } = app.context ?? {};
 
      async function handleConfirm() {
        if (!selected) return;
        setSubmitted(true);
 
        // Send selection back to MCP server via the JSON-RPC bridge
        await app.callTool("complete_return_selection", {
          orderId,
          refundMethod: selected,
        });
      }
 
      if (submitted) {
        return React.createElement("p", null, "Confirmed! Processing your return.");
      }
 
      return React.createElement("div", null, [
        React.createElement("div", { className: "order-card", key: "card" }, [
          React.createElement("strong", null, `Order ${orderId}`),
          React.createElement("p", { style: { margin: "4px 0 0" } }, `Reason: ${returnReason}`),
        ]),
        React.createElement("p", { key: "label" }, "Select your refund method:"),
        React.createElement("div", { className: "options", key: "opts" }, [
          React.createElement(
            "button",
            {
              key: "card",
              className: selected === "card" ? "primary" : "",
              onClick: () => setSelected("card"),
            },
            `Original card ending in ${originalPaymentLast4 ?? "****"}`
          ),
          React.createElement(
            "button",
            {
              key: "credit",
              className: selected === "credit" ? "primary" : "",
              onClick: () => setSelected("credit"),
            },
            "Store credit (arrives immediately)"
          ),
        ]),
        selected && React.createElement(
          "button",
          { key: "confirm", className: "primary", onClick: handleConfirm, style: { marginTop: 12 } },
          "Confirm Return"
        ),
      ]);
    }
 
    ReactDOM.render(React.createElement(ReturnConfirmWidget), document.getElementById("root"));
  </script>
</body>
</html>

When the customer clicks "Confirm Return," the widget calls app.callTool("complete_return_selection", {...}) via the JSON-RPC bridge. Your MCP server receives this, processes the return with the selected refund method, and the agent picks up the result to close the conversation.

Bidirectional Communication: Widgets That Talk Back

The JSON-RPC bridge is what makes MCP Apps useful rather than decorative. It's not a one-way data display. The widget can both receive data from the server and send actions back.

This opens up patterns that pure text responses can't support:

Polling for live state. A shipment tracking widget can call app.callTool("get_shipment_status", {trackingId}) every 30 seconds and update the UI with the latest location without reloading the page. The customer sees their package moving in real time while still in conversation.

Multi-step forms. A booking widget can show available appointment slots, let the customer select one, confirm, and then show a calendar add link. All without leaving the conversation or asking the agent to parse free-text date/time inputs.

Progressive disclosure. Show the customer their current plan and what they'd get by upgrading. When they click "Upgrade," the widget confirms cost, sends the confirmation to your server, and the agent announces the change is done.

Each of these replaces what used to be a link in the chat response: "Please visit our portal to complete this step." That handoff breaks the conversation and loses customers to friction. The widget keeps everything in one place.

The Security Boundary

MCP Apps enforces a strict sandbox on every widget. This isn't optional. It's baked into the spec and enforced by compliant hosts.

The widget iframe:

  • Cannot access the parent page's DOM or JavaScript context
  • Cannot read the conversation history
  • Cannot make direct network requests to arbitrary URLs
  • Communicates only through the host's JSON-RPC bridge

Every action the widget takes passes through your MCP server, where you validate it. This is intentional: the server is the source of truth for what the widget is allowed to do. The widget is presentation layer only.

What this means practically:

mcp-server/action-validator.ts·typescript
// Your server validates every widget action before processing it
async function handleWidgetAction(req: CallToolRequest) {
  if (req.params.name === "complete_return_selection") {
    const { orderId, refundMethod, customerId } = req.params.arguments;
 
    // Validate the customer is authorized for this order
    const order = await db.orders.findOne({ id: orderId, customerId });
    if (!order) throw new Error("Order not found or unauthorized");
 
    // Validate the return is still eligible
    if (order.returnExpiresAt < new Date()) {
      throw new Error("Return window has closed");
    }
 
    // Only now process the return
    return await processReturn(orderId, refundMethod);
  }
}

Never trust widget inputs without server-side validation. The sandbox prevents the widget from doing unauthorized things directly, but a misconfigured widget or a malicious server could still send bad tool calls. Treat widget-originated tool calls with the same scrutiny as any external input.

The return confirmation widget is one example, but the pattern applies wherever you currently send customers a link to complete a step outside the conversation. Here are the highest-value targets for a CX deployment:

Appointment booking. Show available time slots as a calendar picker. Customer selects, confirms, and gets a calendar invite without leaving chat. Eliminates the back-and-forth of "What times work for you?"

Account verification. Instead of asking the customer to recite their last four digits, show a masked card selector widget. They click the card they recognize. Structured confirmation, no transcription errors.

Plan comparison and upgrade. Show current plan vs. upgrade side-by-side. Customer clicks "Upgrade" directly in the widget. Server processes the change. Agent confirms. No portal redirect required.

Satisfaction survey. At conversation end, render a one-to-five star rating plus optional comment field. The customer rates inline rather than clicking through an email survey link three days later. Response rate goes up significantly when the survey is right there.

Address correction. When your agent detects a shipping address might be wrong (postal code doesn't match city), show a correction form with the current address pre-filled. Customer edits the field they want to fix. No typing out the full address.

For each of these, the key test is: does this interaction currently require the customer to leave the conversation? If yes, it's a candidate for an MCP Apps widget.

Connecting to Your CX Agent

The Chanl MCP integration lets your CX agent connect to any MCP server you point it at, so adding MCP Apps-enabled tools is the same flow as adding any other MCP tool. Once your server is registered, list what your agent sees:

chanl-mcp-apps-setup.ts·typescript
import { Chanl } from "@chanl/sdk";
 
const chanl = new Chanl({ apiKey: process.env.CHANL_API_KEY });
 
// List MCP tools the agent has access to
const { data } = await chanl.mcp.listTools("cx-agent-v3");
 
// Tools your MCP server attached a UI resource to will surface _meta.ui.resourceUri
const uiEnabledTools = data.tools.filter(
  (t) => (t as { _meta?: { ui?: { resourceUri?: string } } })._meta?.ui?.resourceUri,
);
 
console.log(`${uiEnabledTools.length} UI-enabled tools loaded`);
// e.g.: initiate_return, book_appointment, process_plan_change

For testing the flow without a real browser, run your CX agent through a scenario that triggers the tool call. The scenario records the agent's tool call to initiate_return. Your MCP server returns the ui:// resource. In CI you assert on the tool call itself (and on the follow-up complete_return_selection call that a widget would normally trigger). Treat the widget the way you'd treat any HTML form: render it once, snapshot it, and test the bridge handler as a plain server endpoint.

What to Consider Before Building

MCP Apps does real work, but it's a client-side feature first. A few things to check before you start.

Host support. Your MCP server can serve UI resources all day, but if the client doesn't support MCP Apps, the host just ignores the _meta.ui.resourceUri and shows the tool result as text. Check whether your deployment target supports the spec. Claude.ai and ChatGPT do. Custom deployments using the Claude API directly won't render widgets unless you implement the host-side rendering yourself.

Mobile and voice. Widgets render in chat UIs. For mobile chat, test that your widget works in constrained widths. For voice-only channels, the widget won't render at all, so design your tools to degrade gracefully to text when no UI is rendered.

Conversation continuity. When the customer interacts with the widget, the result flows back to the agent as a tool call result. Make sure your agent's prompts account for this: it needs to know how to interpret a widget action result and continue the conversation appropriately.

State management. The widget is stateful (it holds in-memory state while rendered), but it's not persistent. If the page refreshes or the conversation session resets, the widget state resets too. For anything that needs to survive a session, write state to your server before the user closes the widget.

The Monitoring feature in Chanl captures widget tool calls alongside regular tool calls in your conversation traces. You can see when the widget was rendered, when the customer took action, and how long it took. Same observability on UI interactions that you have on text interactions.

What This Buys Your CX Stack

Go back to the customer at the top of this article, the one staring at a refund choice they couldn't answer because they couldn't see their card. With an MCP Apps widget in the response, that conversation is one click long instead of five turns. The agent gathered the eligibility logic. The widget gathered the choice. The bridge stitched them together.

That's the shape of the win. Text responses still do most of the work in a CX conversation. Widgets show up at the specific moments where typing fails: pick this card, confirm this action, see this status, rate this call. The spec keeps the widget sandboxed so the customer can trust it, and keeps the server authoritative so you can trust the customer.

For a broader look at what's changed in MCP this year, see MCP for AI Agents: The Protocol That Changes Everything and MCP Deep Dive: Advanced Patterns for Tool Integration. The Chanl MCP feature connects your agent to any MCP server, with or without UI resources attached.

Connect Your Agent's Tools in Minutes

Chanl's MCP integration supports standard MCP servers and MCP Apps-enabled tools. Connect, test, and monitor your tool interactions from one place.

Explore MCP Integration
DG

Co-founder

Building the platform for AI agents at Chanl — tools, testing, and observability for customer experience.

The Signal Briefing

Un email por semana. Cómo los equipos líderes de CS, ingresos e IA están convirtiendo conversaciones en decisiones. Benchmarks, playbooks y lo que funciona en producción.

500+ líderes de CS e ingresos suscritos

Frequently Asked Questions