MCP Apps — Interactive UI Extensions for MCP Tools

Trust: ★★★☆☆ (0.90) · 0 validations · developer_reference

Published: 2026-05-10 · Source: crawler_authoritative

Tình huống

Guide for developers using Mastra to add interactive HTML UIs to MCP tools via the MCP Apps extension, enabling rich UI rendering in Mastra Studio.

Insight

The MCP Apps extension (from @modelcontextprotocol/ext-apps) enables MCP tools to serve interactive HTML UIs via ui:// resources. Tools define app resources on MCPServer using the appResources config with ui:// URIs mapped to inline HTML or file paths. Each tool is linked to its app resource via the _meta.ui.resourceUri property on the tool definition. The App class from @modelcontextprotocol/ext-apps provides three core guest-side APIs: callServerTool() calls MCP server tools from within the iframe for interactive computation; sendMessage() injects user messages into the agent chat to trigger new model turns; and ontoolinput is a callback that fires when the host delivers tool input data to the iframe, allowing pre-population of form fields. Communication between host and iframe uses a JSON-RPC postMessage protocol. The communication flow is: agent calls tool → tool returns brief content text plus structuredContent data → host renders iframe with app HTML → user interacts with UI → UI calls callServerTool() for computation → UI calls sendMessage() to inject results into chat. For remote MCP servers, use MCPClient.listTools() to get tools and toMCPServerProxies() to register the server so Studio can resolve app resources. Sandbox security uses @mcp-ui/client to render MCP App iframes through a sandbox proxy with allow-scripts, allow-forms, and allow-popups permissions. The iframe does not access the parent page’s DOM, cookies, or storage.

Hành động

To add an interactive UI to an MCP tool: 1) Create a tool with createTool() and ensure its execute function returns both content (brief text summary for the model) and structuredContent (data payload for the UI). 2) On your MCPServer, define appResources with a ui:// URI mapped to HTML (inline or file path). 3) Link the tool to its app via calculatorTool._meta = { ui: { resourceUri: ‘ui://calculator/main’ } }. 4) Register the MCP server at the Mastra level in the mcpServers config so Studio can resolve app resources. 5) For remote servers, use new MCPClient() with server config, pass await mcpClient.listTools() to agent tools, and spread …mcpClient.toMCPServerProxies() in Mastra mcpServers. In the app HTML, import App from the ESM CDN, register ontoolinput callback for hydration, implement button handlers that call app.callServerTool(), and call await app.connect() after registering handlers. To prevent UI flicker with default form values, start body with opacity: 0 and add the ‘ready’ class after ontoolinput hydrates or as a 150ms fallback timeout.

Kết quả

Mastra Studio renders the app HTML in a sandboxed iframe alongside the tool form or inline in agent chat. Users interact with the UI to call server tools and inject results back into the conversation, while the model sees only the brief text content from the tool response.

Điều kiện áp dụng

Requires @modelcontextprotocol/ext-apps package for guest-side App class and @mcp-ui/client for host-side iframe rendering. MCP Apps extension must be implemented by the MCP server.


Nội dung gốc (Original)

MCP Apps

The MCP Apps extension allows MCP tools to serve interactive HTML UIs via ui:// resources. When a tool has an associated app resource, Mastra Studio renders it in a sandboxed iframe alongside the tool form or inline in agent chat.

When to use MCP Apps

Use MCP Apps when a tool result is better presented as an interactive UI rather than plain text. For example:

  • A calculator that renders input fields and buttons for computation
  • A color picker that displays swatches and hex values
  • A form builder that captures structured user input
  • A data visualizer that renders charts

Quickstart

Define app resources on your MCPServer by providing a ui:// URI mapped to inline HTML or an HTML file path.

import { MCPServer } from '@mastra/mcp'
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
 
const calculatorTool = createTool({
  id: 'calculatorWithUI',
  description: 'An interactive calculator',
  inputSchema: z.object({
    num1: z.number(),
    num2: z.number(),
    operation: z.enum(['add', 'subtract']),
  }),
  execute: async ({ num1, num2, operation }) => {
    const result = operation === 'add' ? num1 + num2 : num1 - num2
    return {
      content: [{ type: 'text', text: 'An interactive calculator is displayed.' }],
      structuredContent: { result },
    }
  },
})
 
const server = new MCPServer({
  id: 'my-app-server',
  name: 'My App Server',
  version: '1.0.0',
  tools: { calculatorTool },
  appResources: {
    'ui://calculator/main': {
      name: 'Interactive Calculator',
      html: `<html>
        <body>
          <h2>Calculator</h2>
          <button id="btn">Compute</button>
          <script type="module">
            import { App } from 'https://cdn.jsdelivr.net/npm/@modelcontextprotocol/ext-apps/+esm';
            const app = new App({ name: 'Calculator', version: '1.0.0' });
            app.ontoolinput = (params) => {
              console.log('Tool input:', params.arguments);
            };
            document.getElementById('btn').addEventListener('click', async () => {
              const result = await app.callServerTool({
                name: 'calculatorWithUI',
                arguments: { num1: 10, num2: 5, operation: 'add' }
              });
              document.body.innerHTML += '<p>Result: ' + JSON.stringify(result) + '</p>';
            });
            await app.connect();
          </script>
        </body>
      </html>`,
    },
  },
})

Link the tool to its app resource by adding _meta.ui.resourceUri to the tool definition:

calculatorTool._meta = {
  ui: { resourceUri: 'ui://calculator/main' },
}

Note: Visit MCPServer reference for the full appResources configuration.

Connecting MCP Apps to agents

Agents consume tools — they do not need to know about MCP servers. Pass tools to the agent’s tools config, and register the MCP server at the Mastra level so Studio can resolve app resources.

import { Agent } from '@mastra/core/agent'
import { calculatorTool } from '../mcp/tools'
 
export const myAgent = new Agent({
  id: 'my-agent',
  name: 'My Agent',
  instructions: 'You have access to interactive UI tools.',
  model: 'openai/gpt-5-mini',
  tools: { calculatorTool },
})

Register the MCP server at the Mastra level. Studio scans registered MCP servers to map tools to their app resources.

import { Mastra } from '@mastra/core/mastra'
import { myAgent } from './agents'
import { myAppServer } from './mcp/server'
 
export const mastra = new Mastra({
  agents: { myAgent },
  mcpServers: { myAppServer },
})

For remote MCP servers, use MCPClient.listTools() to get tools and toMCPServerProxies() to register the server:

import { MCPClient } from '@mastra/mcp'
 
const mcpClient = new MCPClient({
  servers: {
    remoteApp: { url: new URL('https://remote-mcp-server.example.com/mcp') },
  },
})
 
const myAgent = new Agent({
  id: 'my-agent',
  name: 'My Agent',
  model: 'openai/gpt-5-mini',
  tools: await mcpClient.listTools(),
})
 
export const mastra = new Mastra({
  agents: { myAgent },
  mcpServers: { ...mcpClient.toMCPServerProxies() },
})

When tools come from MCPClient.listTools(), each tool’s _meta.ui is automatically stamped with a serverId so Studio can resolve its app resources without scanning all servers.

How MCP Apps work

MCP Apps follow a specific communication pattern between the host (Mastra Studio) and the iframe:

  1. The tool executes and returns a brief summary in content (visible to the model) and detailed data in structuredContent (visible to the UI only).
  2. The host renders the app HTML in a sandboxed iframe.
  3. The iframe communicates with the host via a JSON-RPC postMessage protocol.
  4. The app can call server tools using callServerTool() and inject messages into the chat using sendMessage().
Agent calls tool → Tool returns brief content + structuredContent
  → Host renders iframe with app HTML
  → User interacts with UI
  → UI calls callServerTool() for computation
  → UI calls sendMessage() to inject result into chat

Tool result format

Tools with app resources should return two fields:

  • content: A brief text summary for the model. Keep this short so the agent does not parrot the full result.
  • structuredContent: The data payload that hydrates the UI. The model does not see this field.
execute: async ({ num1, num2, operation }) => {
  const result = operation === 'add' ? num1 + num2 : num1 - num2
  return {
    content: [{ type: 'text', text: 'An interactive calculator is displayed.' }],
    structuredContent: { result },
  }
}

App API (guest-side)

MCP App HTML uses the standard App class from @modelcontextprotocol/ext-apps to communicate with the host. Import it via ESM CDN or bundle it.

import { App } from 'https://cdn.jsdelivr.net/npm/@modelcontextprotocol/ext-apps/+esm'
const app = new App({ name: 'MyApp', version: '1.0.0' })

app.callServerTool(params)

Calls an MCP server tool from within the iframe. This is useful for interactive computation without leaving the UI.

const result = await app.callServerTool({
  name: 'calculatorWithUI',
  arguments: { num1: 42, num2: 8, operation: 'add' },
})

app.sendMessage(params)

Injects a user message into the agent chat, triggering a new model turn. Use this for sharing results or requesting follow-up actions.

await app.sendMessage({
  role: 'user',
  content: [{ type: 'text', text: 'The result of 42 + 8 is 50' }],
})

app.ontoolinput

A callback that fires when the host delivers tool input data to the iframe, allowing pre-population of form fields. The params.arguments object contains the tool call arguments.

app.ontoolinput = params => {
  document.getElementById('num1').value = params.arguments.num1
}

Preventing UI flicker: If your app has default form values, the user may briefly see them before ontoolinput hydrates the correct values. To prevent this, start the body hidden and reveal it after hydration:

<style>
  body {
    opacity: 0;
    transition: opacity 0.15s;
  }
  body.ready {
    opacity: 1;
  }
</style>
<script type="module">
  import { App } from 'https://cdn.jsdelivr.net/npm/@modelcontextprotocol/ext-apps/+esm'
  const app = new App({ name: 'MyApp', version: '1.0.0' })
 
  app.ontoolinput = params => {
    // Hydrate form fields from params.arguments
    document.body.classList.add('ready')
  }
 
  await app.connect()
  // Fallback: reveal after connection if no tool input arrives
  setTimeout(() => document.body.classList.add('ready'), 150)
</script>

app.connect()

Establishes the connection to the host. Call this after registering all event handlers.

await app.connect()

Note: See the App class API reference for the full list of methods, callbacks, and lifecycle hooks.

Using external MCP servers with apps

External (non-Mastra) MCP servers that implement the MCP Apps extension work with Mastra via MCPClient. Use listTools() for agent tools and toMCPServerProxies() to register them in Studio.

import { Mastra } from '@mastra/core/mastra'
import { MCPClient } from '@mastra/mcp'
import { Agent } from '@mastra/core/agent'
 
const mcpClient = new MCPClient({
  servers: {
    'external-server': {
      command: 'node',
      args: ['path/to/external-server.js'],
    },
  },
})
 
const myAgent = new Agent({
  id: 'my-agent',
  name: 'My Agent',
  model: 'openai/gpt-5-mini',
  tools: await mcpClient.listTools(),
})
 
export const mastra = new Mastra({
  agents: { myAgent },
  mcpServers: {
    ...mcpClient.toMCPServerProxies(),
  },
})

Note: Visit MCPClient reference for more details on proxying external servers.

Sandbox security

Mastra Studio uses @mcp-ui/client to render MCP App iframes through a sandbox proxy. The proxy loads app HTML via postMessage rather than srcDoc, providing additional isolation.

App iframes are sandboxed with the following permissions:

  • allow-scripts: Enables JavaScript execution
  • allow-forms: Allows form submission
  • allow-popups: Permits window.open() and link targets

The iframe does not have access to the parent page’s DOM, cookies, or storage. All communication happens through the JSON-RPC postMessage protocol managed by @mcp-ui/client’s AppRenderer on the host side and @modelcontextprotocol/ext-apps’s App class on the guest side.

Liên kết

Xem thêm: