Skip to main content

What are MCP Apps?

MCP Apps are MCP servers that expose tools with associated UI resources. When your agent calls one of these tools, CopilotKit automatically fetches and renders the HTML/JavaScript UI in the chat — no frontend code required. This is the most flexible generative UI approach, providing:
  • Zero frontend code - UI components are served by the MCP server
  • Unlimited creativity - Any HTML, CSS, JavaScript is possible
  • Full interactivity - Components can use forms, buttons, charts, animations
  • Secure sandboxing - Content runs in isolated iframes
  • Direct server communication - The middleware proxies interactions between UI and MCP server
  • Thread persistence - MCP Apps are saved in conversation history

How MCP Apps Work

  1. Agent invokes tool - The LLM decides to call an MCP tool
  2. MCP Server returns UI - The server sends HTML/JavaScript as a tool response
  3. Middleware handles it - MCPAppsMiddleware processes the UI resource
  4. Frontend renders - CopilotKit displays it in a sandboxed iframe
  5. Interactions flow back - User actions are proxied to the MCP server

Quick Start

Prerequisites

  • Node.js 20+
  • An MCP server running (see Example Servers)
  • OpenAI API key (or your preferred LLM provider)

Step 1: Install Dependencies

npm install @copilotkit/react-ui @copilotkit/react-core @copilotkit/runtime @ag-ui/mcp-apps-middleware

Step 2: Configure the Runtime

Add the MCPAppsMiddleware to your agent configuration:
app/api/copilotkit/route.ts
import {
  CopilotRuntime,
  ExperimentalEmptyAdapter,
  copilotRuntimeNextJSAppRouterEndpoint,
} from "@copilotkit/runtime";
import { BuiltInAgent } from "@copilotkit/runtime/v2";
import { MCPAppsMiddleware } from "@ag-ui/mcp-apps-middleware";
import { NextRequest } from "next/server";

// 1. Create agent with MCP Apps middleware
const agent = new BuiltInAgent({
  model: "openai/gpt-4o",
  prompt: "You are a helpful assistant.",
}).use(
  new MCPAppsMiddleware({
    mcpServers: [
      {
        type: "http",
        url: "http://localhost:3108/mcp",
        serverId: "budget-tracker", // Stable identifier
      },
    ],
  }),
);

// 2. Create service adapter
const serviceAdapter = new ExperimentalEmptyAdapter();

// 3. Create runtime with agent
const runtime = new CopilotRuntime({
  agents: {
    default: agent,
  },
});

// 4. Create API route
export const POST = async (req: NextRequest) => {
  const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
    runtime,
    serviceAdapter,
    endpoint: "/api/copilotkit",
  });

  return handleRequest(req);
};

Server ID Importance

Always provide a serverId for production. Without it, CopilotKit generates a hash from the URL. If your URL changes between environments, previously stored MCP Apps in conversation history won’t load correctly.

Step 3: Configure Environment

.env.local
OPENAI_API_KEY=your_openai_api_key

Step 4: Set Up Frontend

app/layout.tsx
import { CopilotKit } from "@copilotkit/react-core/v2";
import "@copilotkit/react-ui/v2/styles.css";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <CopilotKit runtimeUrl="/api/copilotkit">
          {children}
        </CopilotKit>
      </body>
    </html>
  );
}

Step 5: Add Chat Interface

app/page.tsx
"use client";

import { CopilotSidebar } from "@copilotkit/react-ui/v2";

export default function Page() {
  return (
    <main>
      <h1>MCP Apps Demo</h1>
      <CopilotSidebar />
    </main>
  );
}
That’s it! When your agent uses MCP tools with UI resources, they’ll render automatically.

Transport Types

The middleware supports two transport protocols:

HTTP Transport

For MCP servers using HTTP-based communication:
{
  type: "http",
  url: "http://localhost:3101/mcp",
  serverId: "my-http-server"
}
This is the most common transport for local development and standard HTTP servers.

SSE Transport

For MCP servers using Server-Sent Events:
{
  type: "sse",
  url: "https://mcp.example.com/sse",
  headers: {
    "Authorization": "Bearer token",
    "X-Custom-Header": "value"
  },
  serverId: "my-sse-server"
}
Use SSE when:
  • Your MCP server pushes real-time updates
  • You need long-lived connections
  • Your infrastructure requires SSE for streaming

Multiple MCP Servers

You can configure multiple MCP servers simultaneously:
new MCPAppsMiddleware({
  mcpServers: [
    {
      type: "http",
      url: "http://localhost:3108/mcp",
      serverId: "budget-tracker",
    },
    {
      type: "http",
      url: "http://localhost:3109/mcp",
      serverId: "data-visualizer",
    },
    {
      type: "sse",
      url: "https://external-service.com/mcp",
      headers: { "Authorization": "Bearer token" },
      serverId: "external-analytics",
    },
  ],
})
The agent will have access to all tools from all servers.

Creating an MCP Server with UI

Here’s a simple example of an MCP server that returns HTML UI:
mcp-server/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";

const server = new McpServer({
  name: "budget-tracker",
  version: "1.0.0",
});

// Define a tool that returns HTML UI
server.tool({
  name: "show_budget_allocator",
  description: "Display an interactive budget allocation widget",
  parameters: z.object({
    totalBudget: z.number().describe("Total budget amount"),
    categories: z.array(z.string()).describe("Budget categories"),
  }),
  execute: async ({ totalBudget, categories }) => {
    // Generate HTML UI
    const html = `
      <!DOCTYPE html>
      <html>
        <head>
          <style>
            body {
              font-family: system-ui, -apple-system, sans-serif;
              padding: 20px;
              background: white;
            }
            .budget-header {
              font-size: 24px;
              font-weight: bold;
              margin-bottom: 20px;
            }
            .category {
              display: flex;
              align-items: center;
              margin-bottom: 15px;
            }
            .category label {
              flex: 1;
              font-weight: 500;
            }
            .category input {
              width: 100px;
              padding: 8px;
              border: 1px solid #ddd;
              border-radius: 4px;
            }
            .total {
              margin-top: 20px;
              padding: 15px;
              background: #f0f0f0;
              border-radius: 4px;
              font-weight: bold;
            }
          </style>
        </head>
        <body>
          <div class="budget-header">Budget Allocator</div>
          <div class="total">Total: $${totalBudget.toLocaleString()}</div>
          
          ${categories.map(cat => `
            <div class="category">
              <label>${cat}</label>
              <input 
                type="number" 
                placeholder="0" 
                onchange="updateTotal()"
                data-category="${cat}"
              />
            </div>
          `).join('')}
          
          <div class="total" id="remaining">
            Remaining: $${totalBudget.toLocaleString()}
          </div>

          <script>
            const totalBudget = ${totalBudget};
            
            function updateTotal() {
              const inputs = document.querySelectorAll('input[type="number"]');
              let allocated = 0;
              
              inputs.forEach(input => {
                allocated += parseFloat(input.value || 0);
              });
              
              const remaining = totalBudget - allocated;
              document.getElementById('remaining').textContent = 
                'Remaining: $' + remaining.toLocaleString();
                
              // Optionally send data back to server
              if (remaining < 0) {
                document.getElementById('remaining').style.color = 'red';
              } else {
                document.getElementById('remaining').style.color = 'inherit';
              }
            }
          </script>
        </body>
      </html>
    `;

    return {
      content: [
        {
          type: "ui",
          html: html,
          // Optional: Add metadata
          metadata: {
            title: "Budget Allocator",
            width: 600,
            height: 400,
          },
        },
      ],
    };
  },
});

// Start server
server.listen(3108);

Interactive MCP Apps

MCP Apps can be fully interactive. Users can click buttons, fill forms, and the interactions flow back to the MCP server:
server.tool({
  name: "show_task_manager",
  description: "Display an interactive task management widget",
  parameters: z.object({
    tasks: z.array(z.object({
      id: z.string(),
      title: z.string(),
      completed: z.boolean(),
    })),
  }),
  execute: async ({ tasks }) => {
    const html = `
      <!DOCTYPE html>
      <html>
        <body>
          <h2>Tasks</h2>
          ${tasks.map(task => `
            <div class="task">
              <input 
                type="checkbox" 
                ${task.completed ? 'checked' : ''}
                onchange="toggleTask('${task.id}')"
              />
              <span>${task.title}</span>
            </div>
          `).join('')}

          <script>
            async function toggleTask(taskId) {
              // Send message back to MCP server
              const response = await fetch('/api/toggle-task', {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ taskId })
              });
              
              const result = await response.json();
              console.log('Task toggled:', result);
            }
          </script>
        </body>
      </html>
    `;

    return { content: [{ type: "ui", html }] };
  },
});

// Handle interactions
server.endpoint("/api/toggle-task", async (req) => {
  const { taskId } = await req.json();
  // Update task in database
  return { success: true, taskId };
});

Threading and Persistence

MCP Apps integrate with CopilotKit’s threading system:

Persistence

When you save a thread, MCP Apps are stored as part of the conversation history.

Restoration

Loading a thread restores all MCP Apps with their original state.

Server ID Stability

Using consistent serverId values ensures MCP Apps load correctly across sessions:
// Good: Stable server ID
{
  serverId: "budget-tracker-v1"
}

// Avoid: URL-based (changes between environments)
{
  serverId: undefined  // Uses URL hash
}

Security Considerations

MCP Apps run in sandboxed iframes with security restrictions:

1. Same-Origin Policy

The iframe is isolated from your main application.

2. Content Security Policy

Consider setting CSP headers on your MCP server:
server.use((req, res, next) => {
  res.setHeader(
    "Content-Security-Policy",
    "default-src 'self'; script-src 'self' 'unsafe-inline';"
  );
  next();
});

3. Input Sanitization

Always sanitize any user input in your HTML:
function escapeHtml(text: string) {
  const map: Record<string, string> = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#039;'
  };
  return text.replace(/[&<>"']/g, m => map[m]);
}

const html = `<div>${escapeHtml(userInput)}</div>`;

4. HTTPS in Production

Always use HTTPS for production MCP servers, especially with SSE transport.

Example MCP Servers

Try these open-source MCP Apps to get started:

MCP Apps Examples

Official repository with demo servers including:
  • Budget allocators
  • Data visualizations
  • Interactive dashboards
  • Form builders
  • Chart components

Comparison with Other Approaches

FeatureMCP AppsStatic AG-UIDeclarative A2UI
Frontend codeNoneReact componentsTheme config
FlexibilityUnlimitedLimitedMedium
Setup timeMinutesHoursHours
SecuritySandboxedTrustedTrusted
Cross-platformWeb-onlyReact-basedMulti-platform
Type safetyNoneFullSchema-level
Visual consistencyVariableGuaranteedGood
Third-partyEasyHardMedium

When to Use MCP Apps

Best For:

  • Rapid prototyping and experimentation
  • Third-party integrations from external MCP servers
  • Internal tools where visual consistency isn’t critical
  • Complex visualizations requiring custom HTML/CSS/JS
  • Web-only applications
  • When development speed is the top priority

Not Ideal For:

  • Mission-critical features requiring brand consistency (use Static AG-UI)
  • Native mobile/desktop apps (use Declarative A2UI)
  • Features needing strong type safety (use Static AG-UI)
  • Public-facing, high-traffic features

Best Practices

1. Use Semantic HTML

<!-- Good: Semantic structure -->
<article class="task">
  <h3>Task Title</h3>
  <p>Description</p>
  <button>Complete</button>
</article>

<!-- Avoid: Div soup -->
<div class="thing">
  <div class="title">Task Title</div>
  <div class="desc">Description</div>
  <div class="btn">Complete</div>
</div>

2. Include Inline Styles

Since the HTML is isolated, include all styles inline or in a <style> tag:
<style>
  .container { padding: 20px; }
  .button { 
    background: #4CAF50; 
    color: white;
    padding: 10px 20px;
  }
</style>

3. Handle Loading States

<div id="app">
  <div class="loading">Loading...</div>
</div>

<script>
  async function loadData() {
    try {
      const data = await fetch('/api/data');
      document.getElementById('app').innerHTML = renderData(data);
    } catch (error) {
      document.getElementById('app').innerHTML = 
        '<div class="error">Failed to load</div>';
    }
  }
  
  loadData();
</script>

4. Provide Clear Metadata

return {
  content: [
    {
      type: "ui",
      html: html,
      metadata: {
        title: "Budget Allocator",        // Displayed in UI
        description: "Allocate funds",    // Helpful context
        width: 600,                       // Suggested width
        height: 400,                      // Suggested height
      },
    },
  ],
};

5. Test in Isolation First

Before integrating, test your MCP server HTML in a standalone browser:
<!-- test.html -->
<!DOCTYPE html>
<html>
  <body>
    <!-- Paste your MCP-generated HTML here -->
  </body>
</html>

Debugging Tips

Check MCP Server Logs

# View server logs
tail -f mcp-server.log

Inspect iframe Content

In browser DevTools:
  1. Right-click the MCP App
  2. Select “Inspect”
  3. View iframe content in Elements tab

Test Transport Directly

# Test HTTP transport
curl -X POST http://localhost:3108/mcp \
  -H "Content-Type: application/json" \
  -d '{"method":"tools/call","params":{"name":"show_budget"}}'

Next Steps

MCP SDK Documentation

Official MCP protocol documentation

Example MCP Apps

Open-source MCP Apps servers

Static AG-UI

Compare with React component approach

Declarative A2UI

Explore structured JSON specifications