Skip to main content

Display

OuroMcpApp

The view an MCP server returns for agent-composed UI (MCP Apps): the host, such as Claude or ChatGPT, frames it, and it shows the latest tool result's structuredContent.a2ui with A2UISurface. Read and write buttons call their action's own tool through the host, a consequential one only after the person confirms, and a person-only one takes the person to your app's screen. It follows the host's light or dark theme and tells the host its size.

Waiting for the card

Press a button.

Install

Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):

npx shadcn@latest init --base base --preset nova --yes --no-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.68

Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):

npx shadcn@latest add ourostack/ouro-ui/ouro-mcp-app#v1.3.68

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/ouro-mcp-app

Its tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):

npx shadcn@latest add ourostack/ouro-ui/ouro-mcp-app#v1.3.68 ourostack/ouro-ui/ouro-mcp-app-tests#v1.3.68
import { OuroMcpApp } from "@/components/ouro/mcp-app/ouro-mcp-app";

Paths use shadcn's default @/ alias; the CLI rewrites them to yours.

Example

createRoot(document.getElementById("root")!).render(
  <OuroMcpApp catalog={catalog} handoffUrl={({ action }) => `https://kitchen.example.com/recipes?do=${action}`} />,
);

Usage

Build it into one HTML file with your catalog embedded (node scripts/mcp-app.mts build), and serve that as the ui:// resource.

Use instead when

  • Use A2UISurface instead when the card is in your own app's screen, not in an MCP host.

Props

Type: OuroMcpAppProps.

appInfo
Type: { name: string; version: string }
Default: { name: "Ouro HX card", version: "1.0.0" }
What the view calls itself in ui/initialize.
catalog (required)
Type: unknown
The app's A2UI catalog, generated from its actions by core's ouro-a2ui-catalog.mts catalog. Tool results carry only its id.
className
Type: string
Layout only: margins, width (max-w-md).
handoffUrl
Type: ((handoff: A2UIHandoff) => string | undefined)
Where a person-only button takes the person: the address of your app's own screen for that action, which the host opens with ui/open-link. Without one, or when the host can't open links, the view tells the person where to do it.
host
Type: McpAppHostLink
Default: the window that framed the view, through windowHostLink()
The connection to the host.
label
Type: A2UISurfaceProps["label"]
The app's own word for an id on a confirm sheet, from its own data, in place of the agent's name. It works for a view you build in code. See A2UISurface's label.

Don't use

These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.

  • onAction: Not a prop. A pressed button calls that action's own MCP tool through the host: give the view only the catalog.

className is for layout only

Pass margins, width and grid placement (mt-4, w-full, col-span-2).

Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.

Alignment classes (text-center) warn too: set alignment on a parent element.

Types

A2UIActionMessage

The A2UI action message a pressed button sends: named after the action's id, with the prepared input as its context.

interface A2UIActionMessage {
  version: string;
  action: {
    /** The action's id, which is also its MCP tool's name. */
    name: string;
    surfaceId: string;
    sourceComponentId: string;
    timestamp: string;
    /** The button's label, as the person read it. */
    userMessage: string;
    /** The input the agent prepared, by field name. */
    context: Record<string, unknown>;
    /** That the person pressed it, its consent class, and for a consequential action that the person confirmed. */
    metadata: { extensions: { bot_ouroboros_action: { consent: string; by: "person"; confirmed?: true } } };
  };
}

A2UIHandoff

Where a person-only button takes the person: the action's id and the declaration's how.

interface A2UIHandoff {
  action: string;
  how: string;
}

A2UIProblem

A problem the validator found in the agent's messages, in A2UI's validation error format.

type A2UIProblem = Problem;

A2UISurfaceProps

Props for A2UISurface.

interface A2UISurfaceProps {
  /** The app's A2UI catalog, generated from its actions by core's `ouro-a2ui-catalog.mts catalog`. */
  catalog: unknown;
  /** The agent's A2UI messages, in order. The surface shows where they leave it. */
  messages: readonly unknown[];
  /** Which surface to show. @default the first one the messages create */
  surfaceId?: string | undefined;
  /**
   * Runs a pressed read or write button, and a consequential one once the person confirms. Send the message to
   * the agent, or call the action's own tool with `message.action.context`. Return a promise to show the button
   * loading until it settles.
   */
  onAction: (message: A2UIActionMessage) => void | Promise<void>;
  /**
   * A person-only button never runs from composed UI: take the person to your app's own screen for it.
   */
  onHandoff: (handoff: A2UIHandoff) => void;
  /** The validator's problems, when the messages fail it; the person sees a short alert instead. Hand them to the agent to correct. */
  onProblems?: ((problems: readonly A2UIProblem[]) => void) | undefined;
  /**
   * Names the ids in a confirm sheet's rows from the app's own data, such as a recipe's title for `rcp_lemon_orzo`.
   * Return undefined to keep the name the agent prepared, or the id. The name wins over what the agent prepared,
   * because the agent's text is the one thing the person can't check. Keep it stable, as `messages` is: a new function
   * on each render resolves the messages again.
   *
   * @example
   * label={({ field, value }) => (field === "recipe" ? titles[value] : undefined)}
   */
  label?: ((context: LabelContext) => string | undefined) | undefined;
  /** Layout only: margins, width (`max-w-md`). */
  className?: string | undefined;

  /** Not a prop. A pressed button calls `onAction`, or `onHandoff` for a person-only one: `<A2UISurface onAction={send} onHandoff={openApp}>`. */
  onPress?: { "Use onAction={send} onHandoff={openApp}": never };
}