Skip to main content
Loading that UI in a sandboxed iframe, then syncs tool input, results, streaming partial input, cancellation, and host context (theme, display mode, etc.) over the AppBridge protocol (@modelcontextprotocol/ext-apps).

Overview

When a tool advertises a UI resource URI, @mcp-ts/sdk can render it via useMcpApps and McpAppRenderer. For ui:// and mcp-app:// resources (and any path where HTML is injected), you must provide a sandbox configuration pointing at a sandbox proxy page you serve from your app (see Sandbox proxy). Tool calls from the guest can be forwarded to your SSEClient automatically, or intercepted with onCallTool / onReadResource and related callbacks.

Key features

  • Sandbox proxy — Injected HTML loads through a dedicated proxy page; CSP can be passed via query string and applied inside the guest document.
  • Resource preloadinguseMcp preloads UI resources when tools are discovered (SSEClient.preloadToolUiResources), so apps open faster.
  • Host ↔ guest sync — Tool input, final result, toolInputPartial, toolCancelled, merged hostContext (including displayMode for inline/fullscreen).
  • Mediation hooks — Optional onCallTool, onReadResource, onFallbackRequest, onMessage, onOpenLink, etc., instead of automatic forwarding.
  • Fullscreen — Guest requestDisplayMode can drive the browser Fullscreen API (handled inside McpAppRenderer).

Sandbox proxy

Hosts must ship a static MCP Apps sandbox proxy page (for example copy examples/agents/public/sandbox.html) that follows @modelcontextprotocol/ext-apps:
  • After load, posts a JSON-RPC notification to the parent with method ui/notifications/sandbox-proxy-ready (same string as SANDBOX_PROXY_READY_METHOD from @modelcontextprotocol/ext-apps, also re-exported from @mcp-ts/sdk/client).
  • Listens for ui/notifications/sandbox-resource-ready from the parent (the host sends this via AppBridge.sendSandboxResourceReady after the bridge connects), then writes params.html into an inner iframe. Optional CSP: ?csp= query JSON and/or structured params.csp when it is a directive map (script-src, etc.).
Point sandbox.url at that page (no special query string required; csp is appended automatically from sandbox.csp when you pass it in React):
import { DEFAULT_MCP_APP_CSP } from "@mcp-ts/sdk/client/react";

<McpAppRenderer
   sandbox={{
     url: "/sandbox.html",
     csp: DEFAULT_MCP_APP_CSP,
   }}
/>
You can extend DEFAULT_MCP_APP_CSP (for example narrow or widen connect-src) per deployment.

Quick start

1. MCP connection

Same as the rest of the React client: useMcp, connect to your server, expose mcpClient. See the React guide.

2. Render MCP Apps on tool calls

Pass sandbox on every McpAppRenderer that loads server UI resources (HTML injection path):
import { useRenderToolCall } from "@copilotkit/react-core";
import { McpAppRenderer, DEFAULT_MCP_APP_CSP } from "@mcp-ts/sdk/client/react";
import { useMcpContext } from "./mcp-context";

function ToolRenderer() {
  const { mcpClient } = useMcpContext();

  useRenderToolCall({
    name: "*",
    render: ({ name, args, result, status }) => (
      <McpAppRenderer
        client={mcpClient}
        name={name}
        input={args}
        result={result}
        status={status === "complete" || status === "inProgress" || status === "executing" ? status : "executing"}
        sandbox={{
          url: "/sandbox.html",
          csp: DEFAULT_MCP_APP_CSP,
        }}
      />
    ),
  });

  return null;
}
McpAppRenderer resolves the UI URI from the tool name using mcpClient.connections (see Tool metadata). You can override the URI or pass raw HTML with toolResourceUri / html.

3. Optional: streaming and cancellation

If your agent streams tool arguments or can cancel a run, pass through:
<McpAppRenderer
  name={name}
  input={args}
  result={result}
  status={status}
  sandbox={{ url: "/sandbox.html", csp: DEFAULT_MCP_APP_CSP }}
  toolInputPartial={streamingArgsPartial}
  toolCancelled={wasCancelled}
/>

4. Optional: host context and mediation

<McpAppRenderer
  name={name}
  input={args}
  result={result}
  status={status}
  sandbox={{ url: "/sandbox.html", csp: DEFAULT_MCP_APP_CSP }}
  hostContext={{ theme: "light", locale: "en-US" }}
  onCallTool={async ({ name, arguments: args }) => {
    // Custom path: validate, then call your backend, etc.
    return { /* CallToolResult-shaped */ };
  }}
/>
If onCallTool / onReadResource are omitted, the host forwards to mcpClient.sseClient using the session inferred from the tool metadata.

Tool metadata & Proxy Unwrapping

getMcpAppMetadata and McpAppRenderer look up UI resources using the first match on the tool name. Crucially, they both natively support unwrapping ToolRouter proxies (e.g., mcp_execute_tool). If a proxy wrapper is encountered, it seamlessly inspects the input arguments, resolves the true underlying tool name, strips any prefixes like tool_github_..., and returns the underlying UI. A resource URI may come from:
  • tool.mcpApp.resourceUri
  • tool._meta?.ui?.resourceUri
  • tool._meta?.['ui/resourceUri']

Preloading

When the client receives tool discovery events, useMcp calls SSEClient.preloadToolUiResources(sessionId, tools) so ui:// / mcp-app:// HTML is often already cached before the user opens a tool. For advanced use, AppHost also exposes preload(tools) (see API reference).

API summary

getMcpAppMetadata(mcpClient, toolName, input?)

function getMcpAppMetadata(
  mcpClient: McpClient | null,
  toolName: string,
  input?: Record<string, unknown> | null
): McpAppMetadata | undefined;
Returns { toolName, resourceUri, sessionId } when the tool has a UI URI. Use for conditionally checking if an Interactive UI exists for a tool before rendering. If toolName is a proxy like mcp_execute_tool, supplying input will allow it to unwrap the proxy and find the target app accurately.

McpAppRendererProps

PropTypeDescription
clientMcpClient?Your active MCP client object.
namestringTool name (matched against connection tools). Passes natively through proxy unwrapper.
inputRecord<string, unknown>?Tool arguments; sent with sendToolInput after launch.
resultunknown?Final tool result; sent when status === 'complete'.
status'executing' | 'inProgress' | 'complete' | 'idle'Optional; default 'idle'.
toolResourceUristring?Override UI resource URI from metadata.
htmlstring?Raw HTML instead of fetching toolResourceUri.
sandboxSandboxConfig?Required for injected HTML: { url, csp?, permissions? }.
hostContextRecord<string, unknown>?Merged with defaults; displayMode is set by the renderer.
toolInputPartialany?Streamed partial input (sendToolInputPartial).
toolCancelledboolean?When truthy, sends tool cancelled to the guest.
onCallTool(params) => Promise<unknown>?Override automatic callTool forwarding.
onReadResource(uri: string) => Promise<{ contents: … }>?Override resource reads.
onFallbackRequest(request: any) => Promise<any>?AppBridge fallback JSON-RPC.
onLoggingMessage(params) => void?Guest logging notifications.
onSizeChanged(params) => void?Guest size changes (iframe height adjusted automatically).
onError(error: Error) => void?Bridge / launch errors.
classNamestring?Container class names.
loaderReact.ReactNode?Shown until the app finishes launching.

Next steps