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.
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-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.3.68Then 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.68Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/ouro-mcp-appIts 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.68import { 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 };
}
