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 above them except where noted.

<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). @frontmcp/react 1.9.3 is the same code.

ToolForm

A form with one field per property of a tool's inputSchema, from useListTools().

PropDefaultDescription
toolRequired. 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

ResourceViewerOutputDisplay
dataA read result, { contents }, as useReadResource() returns itAnything
loadingLoading...Loading...
errorError: <message>, in redError: <message>, in red
Nothing to showNo contentAn empty <div>
OtherwiseEach 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() does; when the model calls the tool, the arguments, checked against schema, become the component's props.

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 />
OptionDescription
nameRequired. The tool's name.
schemaRequired. A Zod object: the tool's input and the component's props.
descriptionThe tool's description. Default: name.
fallbackShown until the first call. Default: nothing.
columnsTable mode: see below.
serverRegister 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).

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 ComponentNodes, { 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.

Callcontents[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 documenttext/plain: DOM not available (not in a browser environment)

To serve them, use a resource template (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.

ExportDescription
useRouterBridge()Call it inside a router. It keeps React Router's navigate and the current location in the bridge, and clears them on unmount.
NavigateTooltoolName "navigate", input { path: string, replace?: boolean }. execute(args) navigates and returns Navigated to /settings.
GoBackTooltoolName "go_back", no input. execute() goes back and returns Navigated back.
CurrentRouteResourceuri "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, clearBridgeThe 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() 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

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).

Letting a model fill in a card

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():

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().

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:

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:

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

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). 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).

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")).