Skip to main content

MCP Apps Architecture

This guide explains how MCPJam Inspector implements MCP Apps (SEP-1865) to render custom UI widgets for MCP tool results. MCP Apps use JSON-RPC 2.0 over postMessage with a double-iframe sandbox architecture for security isolation.
MCPJam Inspector also supports OpenAI Apps SDK and MCP-UI for custom UI rendering. MCP Apps takes priority when a tool has ui/resourceUri metadata.

Overview

MCP Apps enables MCP servers to provide rich, interactive widgets for tool results. The implementation uses a secure double-iframe architecture where the outer iframe (sandbox proxy) isolates the inner iframe (guest UI) from the host page.

Key features

  • Double-iframe sandbox: Security isolation via sandbox proxy at different origin
  • JSON-RPC 2.0 protocol: Standardized communication over postMessage
  • Tool and resource access: Widgets can call MCP tools and read resources
  • Theme synchronization: Automatic light/dark mode updates
  • Display modes: Inline, Picture-in-Picture, and fullscreen support

Detection priority

When rendering tool results, the Inspector checks for UI metadata in this order:
  1. MCP Apps: ui/resourceUri in tool metadata
  2. OpenAI Apps SDK: openai/outputTemplate in tool metadata
  3. MCP-UI: Inline ui:// resource in tool result

Architecture overview

Double-iframe sandbox

The double-iframe architecture provides security isolation:
  1. Host page: MCPJam Inspector React application
  2. Sandbox proxy: Outer iframe at /api/mcp/sandbox-proxy with minimal permissions
  3. Guest UI: Inner iframe with widget HTML loaded via srcdoc

Message flow

SandboxedIframe component

Located in client/src/components/ui/sandboxed-iframe.tsx:
export const SandboxedIframe = forwardRef<SandboxedIframeHandle, SandboxedIframeProps>(
  function SandboxedIframe({ html, sandbox, onMessage, ... }, ref) {
    // Outer iframe loads sandbox proxy
    // Waits for ui/notifications/sandbox-ready
    // Sends HTML via ui/notifications/sandbox-resource-ready
    // Forwards all non-sandbox messages to parent handler
  }
);

Widget data flow

1. Tool execution and detection

When a tool is executed, the system checks for MCP Apps support:
// In thread.tsx
const uiType = detectUIType(partToolMeta, (part as any).output);

if (uiType === "mcp-apps" && partToolMeta?.["ui/resourceUri"]) {
  return <MCPAppsRenderer ... />;
}

2. Widget data storage

Before rendering, widget data is stored server-side:
// POST /api/mcp/apps/widget/store
{
  serverId: string;
  resourceUri: string;
  toolInput: Record<string, unknown>;
  toolOutput: unknown;
  toolId: string;
  toolName: string;
  theme: "light" | "dark";
  protocol: "mcp-apps";
}

3. HTML fetching and injection

The server fetches HTML from the MCP resource and injects the client script:
// GET /api/mcp/apps/widget-content/:toolId
// 1. Retrieve widget data from store
// 2. Read HTML from MCP server via readResource(uri)
// 3. Inject window.mcpApp script into <head>
// 4. Return enhanced HTML

window.mcpApp API

The injected script provides the MCP Apps API to widget code:
window.mcpApp = {
  toolInput: { ... },   // Tool input arguments
  toolResult: { ... },  // Tool execution result
  hostContext: { ... }, // Host context (theme, locale, timezone, etc.)

  // Call another MCP tool
  async callTool(name, args = {}) { ... },

  // Read an MCP resource
  async readResource(uri) { ... },

  // Open external link
  async openLink(url) { ... },

  // Send message to chat
  async sendMessage(text) { ... },

  // Notify host of size change
  resize(width, height) { ... },
};

Host Context

The hostContext object provides information about the host environment (as of PR #1038):
{
  theme: "light" | "dark",
  displayMode: "inline" | "pip" | "fullscreen",
  availableDisplayModes: ["inline", "pip", "fullscreen"],
  viewport: {
    width: number,      // Current viewport width in pixels
    height: number,     // Current viewport height in pixels
    maxHeight: number   // Maximum available height
  },
  locale: string,       // BCP 47 locale (e.g., "en-US", "ja-JP")
  timeZone: string,     // IANA timezone (e.g., "America/New_York")
  platform: "web" | "desktop" | "mobile",
  userAgent: string,    // Browser user agent string
  deviceCapabilities: {
    hover: boolean,     // Whether hover interactions are supported
    touch: boolean      // Whether touch input is supported
  },
  safeAreaInsets: {
    top: number,        // Safe area inset in pixels
    right: number,
    bottom: number,
    left: number
  },
  toolInfo: {
    id: string,         // Tool call ID
    tool: {
      name: string,
      inputSchema: object,
      description?: string
    }
  }
}
Widgets receive the full host context during initialization via the ui/initialize response. When context values change (e.g., theme toggle, display mode change), the host sends a ui/notifications/host-context-changed notification with only the changed fields.

Events

Widgets can listen for these events:
  • mcp:tool-input - Tool input received
  • mcp:tool-result - Tool result received
  • mcp:context-change - Host context changed (theme, display mode, locale, timezone, etc.)
  • mcp:tool-cancelled - Tool was cancelled
  • mcp:teardown - Widget is about to be torn down

JSON-RPC 2.0 protocol

All communication uses JSON-RPC 2.0 format:

Requests (with response expected)

// Widget sends
{ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "...", arguments: {} } }

// Host responds
{ jsonrpc: "2.0", id: 1, result: { ... } }
// or
{ jsonrpc: "2.0", id: 1, error: { code: -32000, message: "..." } }

Notifications (no response)

// Host sends
{ jsonrpc: "2.0", method: "ui/notifications/tool-result", params: { ... } }

Supported methods

MethodDirectionDescription
ui/initializeWidget → HostInitialize widget, get host context
tools/callWidget → HostCall an MCP tool
resources/readWidget → HostRead an MCP resource
ui/open-linkWidget → HostOpen external URL
ui/messageWidget → HostSend message to chat
ui/size-changeWidget → HostNotify of size change
ui/notifications/initializedWidget → HostWidget finished initializing
ui/notifications/tool-inputHost → WidgetSend tool input
ui/notifications/tool-resultHost → WidgetSend tool result
ui/host-context-changeHost → WidgetTheme or context changed

Server routes

POST /api/mcp/apps/widget/store

Stores widget data for later retrieval by the iframe.

GET /api/mcp/apps/widget-content/:toolId

Fetches widget HTML from MCP resource and injects the client script.

GET /api/mcp/sandbox-proxy

Serves the sandbox proxy HTML that creates the double-iframe architecture.

Security considerations

Content Security Policy

As of PR #1038, MCP Apps supports two CSP enforcement modes: Permissive Mode (default) - Allows all HTTPS resources for development and testing:
<meta
  http-equiv="Content-Security-Policy"
  content="
  default-src * 'unsafe-inline' 'unsafe-eval' data: blob: filesystem: about:;
  script-src * 'unsafe-inline' 'unsafe-eval' data: blob:;
  style-src * 'unsafe-inline' data: blob:;
  img-src * data: blob: https: http:;
  media-src * data: blob: https: http:;
  connect-src * data: blob: https: http: ws: wss: about:;
  ...
"
/>
Widget-Declared Mode - Enforces CSP based on widget metadata (SEP-1865):
<meta
  http-equiv="Content-Security-Policy"
  content="
  default-src 'self';
  script-src 'self' 'unsafe-inline';
  style-src 'unsafe-inline' https://example.com;
  img-src data: https://example.com;
  media-src data: https://example.com;
  connect-src https://api.example.com;
  ...
"
/>
The CSP mode is controlled via the playground toolbar (shield icon) and passed to the backend when fetching widget content. CSP violations are captured and reported to the host for debugging. CSP Violation Reporting: When a widget violates CSP rules, the sandbox proxy captures the securitypolicyviolation event and forwards it to the host:
document.addEventListener('securitypolicyviolation', function(e) {
  window.parent.postMessage({
    type: 'mcp-apps:csp-violation',
    directive: e.violatedDirective,
    blockedUri: e.blockedURI,
    sourceFile: e.sourceFile,
    lineNumber: e.lineNumber,
    effectiveDirective: e.effectiveDirective,
    timestamp: Date.now()
  }, '*');
});
Violations are displayed in the CSP debug panel with suggested fixes for the widget’s metadata.

Iframe sandbox attributes

<iframe sandbox="allow-scripts allow-same-origin allow-forms allow-popups" />

Security trade-offs

  • Double-iframe provides origin isolation
  • allow-same-origin required for localStorage access
  • Widgets should be treated as semi-trusted code
  • CSP enforcement helps restrict network access in production
  • client/src/components/chat-v2/mcp-apps-renderer.tsx - Main renderer component
  • client/src/components/ui/sandboxed-iframe.tsx - Reusable double-iframe component
  • client/src/lib/mcp-apps-utils.ts - Detection utilities
  • server/routes/mcp/apps.ts - Widget storage and serving
  • server/routes/mcp/sandbox-proxy.html - Sandbox proxy HTML
  • server/routes/mcp/index.ts - Route mounting

Comparison with OpenAI Apps SDK

FeatureMCP Apps (SEP-1865)OpenAI Apps SDK
ProtocolJSON-RPC 2.0Custom postMessage
SandboxDouble-iframeSingle iframe
APIwindow.mcpAppwindow.openai
Metadataui/resourceUriopenai/outputTemplate
State persistenceNot supportedlocalStorage
Modal supportNot supportedSupported

Contributing

When contributing to MCP Apps support:
  1. Test with real MCP servers that implement SEP-1865
  2. Verify double-iframe security isolation
  3. Check JSON-RPC message format compliance
  4. Update this documentation for architecture changes