# React components

> The components in @frontmcp/react: forms generated from tools and prompts, viewers for results, mcpComponent for UI a model fills in, DynamicRenderer, DOM resources, and the router bridge, with what each renders and where each falls short.

Source: https://frontmcp.dev/reference/react/components

`@frontmcp/react` has two kinds of components. Some show the server to the user: `ToolForm` and `PromptForm` build forms from a tool's input schema or a prompt's arguments, and `ResourceViewer` and `OutputDisplay` show what comes back. Others let a model drive the page: `mcpComponent()` turns a component into a tool, so a model calls `show_weather` with data and the card appears; `DynamicRenderer` draws a tree of components a model describes; the DOM readers and the router bridge let it read the page and move around the app. All of them are unstyled, and all need a [`FrontMcpProvider`](https://frontmcp.dev/reference/react) above them except where noted.

```tsx
<ToolForm tool={tool} onSubmit={(args) => …} renderField? submitLabel? />
<PromptForm prompt={prompt} onSubmit={(args) => …} renderField? submitLabel? />
<ResourceViewer data={result} loading? error? />
<OutputDisplay data={value} loading? error? />

const WeatherCard = mcpComponent(Component | mcpLazy(() => import("./Card")) | null, { name, schema, description?, fallback?, columns?, server? });
<DynamicRenderer tree={node} registry={registry} fallback? />
```

---

## Reference

Everything here ran in Node with FrontMCP 1.9.2, with React 19 rendering into jsdom, and `ToolForm` and the router bridge ran in Chromium too, in a Vite 8 build ([Running in a browser](https://frontmcp.dev/reference/react#running-in-a-browser)). `@frontmcp/react` 1.9.3 is the same code.

### `ToolForm`

A form with one field per property of a tool's `inputSchema`, from [`useListTools()`](https://frontmcp.dev/reference/react/hooks#uselisttools-uselistresources-uselistprompts).

| Prop | Default | Description |
| --- | --- | --- |
| `tool` | | **Required.** A `ToolInfo`. |
| `onSubmit(args)` | | **Required.** Called with the values converted to the schema's types. |
| `renderField(props)` | | Renders each field yourself. |
| `submitLabel` | `"Call Tool"` | |

Each field is a `<label>` and an input with the id `field-<name>`; required fields get ` *` and `required`, and the property's `description` is the placeholder. A property with `enum` is a `<select>`; `number` is a number input, `integer` one with `step="1"`, and everything else, `boolean` included, a text input.

On submit, numbers go through `Number()`, a boolean is `true` when the text is `"true"`, and an optional field left empty is left out.

A required enum field starts on the property's `default`, when that's one of its options, or on its first option, and is submitted with that value even if the user never touches it: `{ status: "open" }` for `enum: ["open", "closed"]`. An optional enum field has an empty choice first. It starts on its `default`, which is submitted, or without one on the empty choice, and is left out of the arguments until the user picks an option. A `default` on any other property isn't put into its input: the field stays empty and is left out, and the tool applies the default itself.

Changed in 1.8.7: an enum field left alone was submitted as `""` even though the `<select>` showed its first option, so the tool rejected it.

Changed in 1.9: an optional enum field without a `default` showed its first option, but was left out of the arguments, so the form showed a value the tool never got.

`renderField` gets `FieldRenderProps`: `{ name, type, required, description?, enumValues?, value, onChange(value) }`, where `type` is `"string"`, `"number"`, `"integer"`, `"boolean"` or `"enum"`, and `value` is always a string.

### `PromptForm`

A form with a `<textarea>` per argument of a prompt, from `useListPrompts()`: id `prompt-<name>`, the description as placeholder, `required` for required arguments. `onSubmit(args)` gets every argument as a string, `""` for one left empty. It takes `renderField` and `submitLabel` (default `"Get Prompt"`) like `ToolForm`; every field's `type` is `"string"`.

### `ResourceViewer` and `OutputDisplay`

| | `ResourceViewer` | `OutputDisplay` |
| --- | --- | --- |
| `data` | A read result, `{ contents }`, as [`useReadResource()`](https://frontmcp.dev/reference/react/hooks#usereadresource) returns it | Anything |
| `loading` | `Loading...` | `Loading...` |
| `error` | `Error: <message>`, in red | `Error: <message>`, in red |
| Nothing to show | `No content` | An empty `<div>` |
| Otherwise | Each content's URI, then its text; `application/json` text is re-indented in a `<pre>` | A `<pre>`: a string as it is, anything else as indented JSON |

Each state has a `data-testid`: `resource-loading`, `resource-error`, `resource-empty`, `resource-viewer`, and `output-loading`, `output-error`, `output-empty`, `output-display`. `loading` wins over `error`, and `error` over `data`. Neither needs a provider.

### `mcpComponent()`

Wraps a component so that a model can show it. While it's mounted, it registers a tool with the server, as [`useDynamicTool()`](https://frontmcp.dev/reference/react/hooks#usedynamictool) does; when the model calls the tool, the arguments, checked against `schema`, become the component's props.

```tsx
const WeatherCard = mcpComponent(({ city, temp }) => <div>{city}: {temp}°</div>, {
  name: "show_weather",
  description: "Show the weather for a city",
  schema: weatherSchema, // z.object({ city: z.string(), temp: z.number() })
  fallback: <p>Waiting for weather data…</p>,
});

<WeatherCard />
```

| Option | Description |
| --- | --- |
| `name` | **Required.** The tool's name. |
| `schema` | **Required.** A Zod object: the tool's input and the component's props. |
| `description` | The tool's description. Default: `name`. |
| `fallback` | Shown until the first call. Default: nothing. |
| `columns` | Table mode: see below. |
| `server` | Register the tool on another of the provider's servers. |

The first argument is a component, a lazy import wrapped in `mcpLazy(() => import("./HeavyChart"))` (rendered in `Suspense`, with `fallback` while it loads), or `null` for table mode. A plain `() => import(…)` is taken for a component, and React throws when it first renders ([Troubleshooting](#an-unknown-component-is-an-async-client-component)).

A call with valid input renders the component with it and returns `{"success":true,"rendered":"show_weather"}`. Invalid input returns `isError: true` with `{"error":"validation_error","issues":[…]}`, and the component keeps what it showed. Props you pass yourself, like `<WeatherCard city="Oslo" />`, render right away and are merged over the model's. The returned component has `toolName` (`"show_weather"`) and the display name `mcpComponent(show_weather)`.

With `component` `null` and `columns` `[{ key, header, render? }]`, the tool takes `{ rows: [...] }`, each row checked against `schema`, and the component renders a plain `<table>`: a `<th>` per column, and a cell per row with `render(value)`, or the value as a string.

`schema` is set once, when you call `mcpComponent()`. Call it at module level, not inside a component, where each render would make a new component.

### `DynamicRenderer` and `ComponentRegistry`

`DynamicRenderer` renders a tree of `ComponentNode`s, `{ type, props?, children? }`, where `children` is a string, a node or an array of nodes. Each `type` is looked up in the `registry`:

1. the exact URI, like `"component://Card"`;
2. then `component://<type>`, `element://<type>`, `page://<type>`;
3. otherwise the `fallback` component, or a `<div>`.

So `{ type: "Card", props: { title: "Hi" }, children: [{ type: "Unknown", children: "plain" }] }` with `Card` registered renders `<section class="card" data-title="Hi"><div>plain</div></section>`. A `type` that names an HTML tag, like `"span"`, isn't special: unregistered, it's the fallback too.

The provider's `components` prop fills the registry that `useFrontMcp().registry` returns. A `ComponentRegistry` of your own has `register(uri, component, { description? })`, `registerAll(map)`, `get(uri)`, `resolve(type)`, `has(uri)`, `list()` (`[{ uri, name, description }]`, where `name` is what follows `://`) and `clear()`. Neither `DynamicRenderer` nor the registry needs a provider.

### DOM resources

`readDomById(id)` and `readDomBySelector(selector)` read the page and return a read result, ready to be a resource's answer. They don't need a provider.

| Call | `contents[0]` |
| --- | --- |
| `readDomById("target")` | `uri: "dom://byId/target"`, `mimeType: "application/json"`, `text` `{"outerHTML":"<p id=\"target\">Target text</p>","textContent":"Target text","tagName":"p"}` |
| `readDomById("nope")` | `mimeType: "text/plain"`, `text` `Element with id "nope" not found` |
| `readDomBySelector("p.c")` | `uri: "dom://selector/p.c"`, `text` a JSON array of `{ outerHTML, textContent, tagName }` |
| `readDomBySelector("p.none")` | `text/plain`: `No elements found matching "p.none"` |
| `readDomBySelector("p[")` | `text/plain`: `Invalid selector: "p["` |
| either, without a `document` | `text/plain`: `DOM not available (not in a browser environment)` |

To serve them, use a resource template ([Letting a model read the page](#letting-a-model-read-the-page)). A plain object in `create({ resources })`, as upstream examples write it, fails: `Invalid resource 'DOM Element by ID'. Expected a class or a resource function.`

### Router tools

`@frontmcp/react/router` connects React Router 7 to the page's tools. It doesn't use the provider.

| Export | Description |
| --- | --- |
| `useRouterBridge()` | Call it inside a router. It keeps React Router's `navigate` and the current location in the bridge, and clears them on unmount. |
| `NavigateTool` | `toolName` `"navigate"`, input `{ path: string, replace?: boolean }`. `execute(args)` navigates and returns `Navigated to /settings`. |
| `GoBackTool` | `toolName` `"go_back"`, no input. `execute()` goes back and returns `Navigated back`. |
| `CurrentRouteResource` | `uri` `"route://current"`. `read()` returns `{ pathname, search, hash, href }` as JSON. |
| `createRouterEntries()` | `{ tools, resources }`: the `navigate` and `go_back` tools and the `route://current` resource, as the function-style entries `create()` takes. |
| `setNavigate`, `setLocation`, `getNavigate`, `getLocation`, `clearBridge` | The bridge itself. |

Without the bridge, the tools return `Router bridge not connected. Ensure useRouterBridge() is called inside a React Router tree.`, and the resource returns it as `{ "error": … }`.

`create({ tools: entries.tools, resources: entries.resources })` takes them, and the server lists `go_back`, `navigate` and `route://current`. Changed in 1.8.7: they were plain classes with static fields, `create()` took them without complaint, and then `server.listTools()` rejected with a `ZodError` at `tools.0.name`. `NavigateTool`, `GoBackTool` and `CurrentRouteResource` are still those classes, for registering the entries from a component with `useDynamicTool()` instead.

### Deprecated: `AgentContent` and `AgentSearch`

`AgentContent` registers a tool with a JSON Schema `inputSchema`, and renders `render(args)` after each call (`fallback` before). `AgentSearch` registers a tool named `toolName` that takes `{ query?, results? }` and passes `results` (or the whole input) to `onResults`, a resource `search://<toolName>/query` with what the user typed, and renders a text input, or `renderInput(props)`. Both answer calls with `{"success":true,…}`.

Both register server tools, as [`useDynamicTool()`](https://frontmcp.dev/reference/react/hooks#usedynamictool) does, and neither freezes the page. Changed in 1.8.7: `AgentSearch` built its schema inside itself, and `AgentContent` without `inputSchema` used a new `{ type: "object" }` each time, so under a component that read the server's state (`useListTools()`, `useFrontMcp()`, …) each re-registered its tool on every render, without end. Use `mcpComponent()` in new code anyway.

---

## Usage

### A form for any tool

```tsx ToolRunner.tsx
import { OutputDisplay, ToolForm, useCallTool, useListTools } from "@frontmcp/react";

export function ToolRunner({ name }: { name: string }) {
  const tool = useListTools().find((t) => t.name === name);
  const [call, { data, loading, error }] = useCallTool(name);
  if (!tool) return null;
  return (
    <>
      <ToolForm tool={tool} submitLabel="Run" onSubmit={(args) => call(args)} />
      <OutputDisplay data={data} loading={loading} error={error} />
    </>
  );
}
```

For a tool with `{ query: z.string().describe("Words to look for"), limit: z.number().int().optional(), status: z.enum(["open", "closed"]), urgent: z.boolean() }`, the form has a text input with the placeholder `Words to look for`, a number input with `step="1"`, a select with `open` and `closed`, and a text input for `urgent`. Typing `login`, `5` and `true` and submitting calls the tool with `{ query: "login", limit: 5, status: "open", urgent: true }`: the select starts on `open` ([ToolForm](#toolform)).

### Letting a model fill in a card

```tsx WeatherCard.tsx
import { mcpComponent } from "@frontmcp/react";
import { z } from "@frontmcp/sdk";

export const WeatherCard = mcpComponent(
  ({ city, temp }: { city: string; temp: number }) => <div id="weather">{city}: {temp}°</div>,
  {
    name: "show_weather",
    description: "Show the weather for a city on the user's screen",
    schema: z.object({ city: z.string(), temp: z.number() }),
    fallback: <p>Waiting for weather data…</p>,
  },
);

export const OrderTable = mcpComponent(null, {
  name: "show_orders",
  description: "Show orders in a table",
  schema: z.object({ id: z.string(), price: z.number() }),
  columns: [
    { key: "id", header: "Order" },
    { key: "price", header: "Price", render: (v) => `$${v}` },
  ],
});
```

Rendered as `<WeatherCard />` and `<OrderTable />` inside the provider, the tools `show_weather` and `show_orders` are listed. `show_weather` with `{ city: "Oslo", temp: 21 }` replaces the fallback with `<div id="weather">Oslo: 21°</div>`, and `show_orders` with `{ rows: [{ id: "A1", price: 5 }, { id: "A2", price: 7 }] }` renders a table with the headers `Order` and `Price` and the rows `A1 $5` and `A2 $7`.

### Letting a model read the page

A resource template serves any element by id, through `readDomById()`:

```ts dom.resource.ts
import { ResourceContext, ResourceTemplate, readDomById } from "@frontmcp/react";

@ResourceTemplate({ uriTemplate: "dom://byId/{id}", name: "dom-element", description: "An element of the page, by id", mimeType: "application/json" })
export class DomById extends ResourceContext<{ id: string }> {
  async execute(uri: string, { id }: { id: string }) {
    return readDomById(id);
  }
}

// const server = await create({ info, resources: [DomById] });
```

Reading `dom://byId/hello` on a page with `<p id="hello">Hello</p>` returns `{"outerHTML":"<p id=\"hello\">Hello</p>","textContent":"Hello","tagName":"p"}`. For the structure rather than the markup, see [`useComponentTree()`](https://frontmcp.dev/reference/react/hooks#usecomponenttree).

### Letting a model move around the app

Put the router entries into the server, and call `useRouterBridge()` in a component inside both the router and the provider:

```tsx App.tsx
import { create, FrontMcpProvider } from "@frontmcp/react";
import { createRouterEntries, useRouterBridge } from "@frontmcp/react/router";
import { BrowserRouter, Route, Routes } from "react-router-dom";

const { tools, resources } = createRouterEntries();
const server = await create({ info: { name: "my-app", version: "1.0.0" }, tools, resources });

function RouterBridge() {
  useRouterBridge();
  return null;
}

export function App() {
  return (
    <BrowserRouter>
      <FrontMcpProvider server={server}>
        <RouterBridge />
        <Routes>
          <Route path="/home" element={<h1>Home</h1>} />
          <Route path="/settings" element={<h1>Settings</h1>} />
        </Routes>
      </FrontMcpProvider>
    </BrowserRouter>
  );
}
```

Starting at `/home`, calling `navigate` with `{ path: "/settings" }`, then with `{ path: "/orders" }`, then `go_back`, leaves the app on `/settings`, and `route://current` reads `{"pathname":"/settings","search":"","hash":"","href":"/settings"}`. Without `useRouterBridge()` mounted, the tools answer `Router bridge not connected…`.

To have the tools only while a component is mounted, register them from it with `useDynamicTool()` and `useDynamicResource()`, with the schemas and the `read` function taken from the classes' static fields:

```tsx RouterTools.tsx
import { useDynamicResource, useDynamicTool, type CallToolResult } from "@frontmcp/react";
import { CurrentRouteResource, GoBackTool, NavigateTool, useRouterBridge } from "@frontmcp/react/router";

export function RouterTools() {
  useRouterBridge();
  useDynamicTool({
    name: NavigateTool.toolName,
    description: NavigateTool.description,
    inputSchema: NavigateTool.inputSchema,
    execute: async (args) => (await NavigateTool.execute(args as { path: string; replace?: boolean })) as CallToolResult,
  });
  useDynamicTool({
    name: GoBackTool.toolName,
    description: GoBackTool.description,
    inputSchema: GoBackTool.inputSchema,
    execute: async () => (await GoBackTool.execute()) as CallToolResult,
  });
  useDynamicResource({
    uri: CurrentRouteResource.uri,
    name: CurrentRouteResource.resourceName,
    mimeType: "application/json",
    read: async () => CurrentRouteResource.read(),
  });
  return null;
}
```

It goes where `RouterBridge` went, and does the same.

### Rendering components a model describes

```tsx Canvas.tsx
import { DynamicRenderer, useFrontMcp, type ComponentNode } from "@frontmcp/react";

// <FrontMcpProvider server={server} components={{ "component://Card": Card, "element://Badge": Badge }}>

export function Canvas({ tree }: { tree: ComponentNode }) {
  const { registry } = useFrontMcp();
  return <DynamicRenderer tree={tree} registry={registry} />;
}
```

`tree` can come from a tool call's result. Only registered components are rendered as themselves; every other node is a `<div>` with its children.

---

## Troubleshooting

### A tool call from `ToolForm` leaves out an enum field

The field is optional, has no `default`, and the user left it on the empty choice, so it's left out ([ToolForm](#toolform)). Make it required in the schema, give it a `default`, or render the field yourself.

### `Invalid resource '…'. Expected a class or a resource function.`

A resource was given to `create()` as a plain object with `read`, as in the upstream DOM example. Write a `@ResourceTemplate` class or use `resource()` ([Letting a model read the page](#letting-a-model-read-the-page)).

### `Router bridge not connected. Ensure useRouterBridge() is called inside a React Router tree.`

The tool ran without `useRouterBridge()` mounted inside a router. Call it in a component under `<BrowserRouter>` or `<MemoryRouter>`.

### `An unknown Component is an async Client Component`

React threw it when a model first called an `mcpComponent()` tool: the first argument was `() => import("./Chart")` without `mcpLazy()`, so it was rendered as a component, which returned a promise. Wrap it: `mcpLazy(() => import("./Chart"))`.
