React hooks

The hooks of @frontmcp/react talk to the server a FrontMcpProvider connected. Three kinds: hooks that call it (useCallTool, useReadResource, useGetPrompt), hooks that read what it has (useFrontMcp, useListTools, useListResources, useListPrompts, useStoreResource), and hooks that add to it from a component, for as long as the component is mounted (useDynamicTool, useDynamicResource, useComponentTree). Each takes { server: "name" } to use one of the provider's other servers.

const [call, { data, loading, error, called }, reset] = useCallTool(name, { server?, onSuccess?, onError?, resetOnToolChange? });
const { data, loading, error, refetch } = useReadResource(uri, { server? });
const [read, { data, loading, error }] = useReadResource({ server? });
const [getPrompt, { data, loading, error }] = useGetPrompt(name, { server? });

useDynamicTool({ name, description, schema | inputSchema, execute, annotations?, availableWhen?, app?, enabled?, server? });
useDynamicResource({ uri, name, description?, mimeType?, read, enabled?, server? });

Reference

Everything here ran in Node with FrontMCP 1.9.2, with React 19 rendering into jsdom, and the hooks of the provider ran in Chromium too, in a Vite 8 build (Running in a browser). @frontmcp/react 1.9.3 is the same code, and the provider with useFrontMcp() and useCallTool() ran again with it, in jsdom and in a Vite 8 build.

useFrontMcp()

useFrontMcp(name?) returns the state of the provider's server, or of the one named:

FieldDescription
nameThe server's name.
status"idle", "connecting", "connected" or "error".
errorThe connection error, or null.
server, clientThe wrapped DirectMcpServer, and its DirectClient once connected.
tools, resources, resourceTemplates, promptsWhat the server lists, with the tools and resources components added.
registryThe provider's ComponentRegistry.
connect()Connects, for a provider with autoConnect={false}. Does nothing once connected.

useServer(name?) returns the raw ServerEntry from serverRegistry, or undefined when none is registered under the name. Without one it reads the name of the provider it sits under, like useFrontMcp(). useResolvedServer(name?) returns { entry, name, registry, connect }, what the other hooks are built on.

Changed in 1.8.7: useServer() always looked for "default", so under a provider with a name it returned undefined.

useCallTool()

const [call, state, reset] = useCallTool<Input, Output>(toolName, options?);
OptionDefaultDescription
serverthe provider'sCall a tool on another of the provider's servers.
onSuccess(data)Called with the result after each call that returned one.
onError(error)Called when a call couldn't be made or threw.
resetOnToolChangetrueClear the state when toolName changes.

call(args) returns a promise of the result, or null when the call failed. state is { data, loading, error, called }. reset() sets it back to { data: null, loading: false, error: null, called: false }.

data is the tool's MCP result, { content, structuredContent? }. Output types data as a whole, not the tool's output, so type it as the result: useCallTool<{ name: string }, { structuredContent?: { message: string } }>("greet").

useReadResource()

With a URI, it reads the resource when the provider connects, and again when the URI changes:

const { data, loading, error, refetch } = useReadResource("app://config");

Without one, it returns a function to read on demand:

const [read, { data, loading, error }] = useReadResource();
await read("app://config");

data is the MCP result, { contents: [{ uri, mimeType, text }] }, text not parsed. A read that fails sets error to the client's error, like MCP error -32002: … Resource not found: state://counter. Before the provider connects, read() returns null with error FrontMCP not connected.

useGetPrompt()

const [getPrompt, { data, loading, error }] = useGetPrompt("summarize");
await getPrompt({ topic: "AI" }); // data: { description, messages }

Arguments are strings, as MCP prompts take them.

useListTools(), useListResources(), useListPrompts()

Read the lists the provider loaded when it connected. They start empty, and follow the server: the tools when the server says they changed, a component's tool or one from server.registerTool() included, and the resources when a component adds or removes one.

HookReturns
useListTools(options?)ToolInfo[]: { name, description, inputSchema, annotations }.
useListResources(options?){ resources, resourceTemplates }.
useListPrompts(options?)PromptInfo[]: { name, description, arguments }.

useStoreResource()

const { data, loading, error, refetch } = useStoreResource("app://count");

Reads a resource when the provider connects, parses its text as JSON when it can, and subscribes to it: when the server sends notifications/resources/updated for the URI, it reads it again. A tool that calls this.notifyResourceUpdated("app://count") updates every component showing it.

The state:// resources of the provider's stores send it whenever the store's state changes, so data follows them too. Changed in 1.8.7: they never sent it, and data kept its first value until you called refetch().

@frontmcp/react/state exports a different hook with the same name, which registers a store.

useDynamicTool()

Registers a tool with the server while the component is mounted, through server.registerTool(). It's a tool of the server like any other: listed by useListTools() and server.listTools(), callable with useCallTool(), with server.callTool() and from any client of the server, and its calls run through the server's flow, with the app's plugin hooks, authorities and availableWhen. @frontmcp/plugin-webmcp offers it to agents in the browser. It's gone when the component unmounts. Two components registering the same name make one tool, which the last to register handles. When that component unmounts, the other one's execute and description take over, and the tool stays until both unmount.

OptionTypeDescription
name, descriptionstringRequired.
schemaa Zod objectThe input, as a Zod object from @frontmcp/sdk's z. The input is checked before execute runs, and execute's arguments are typed.
inputSchemaJSON SchemaInstead of schema. Nothing is checked.
execute(args)(args) => Promise<CallToolResult>Required. Returns an MCP result, like { content: [{ type: "text", text: "Added" }] }. The tool calls the execute of the component's last committed render, so it sees the state on screen.
annotations{ readOnlyHint?, destructiveHint?, idempotentHint?, openWorldHint?, … }Listed in tools/list, as for any tool.
availableWhenWhere the tool is offered, like { surface: ["webmcp"] } for agents in the browser only (The "webmcp" call surface). The provider's own client is on the "mcp" surface, so it neither lists nor calls such a tool.
appstringThe app the tool joins, on a server with several. Default: the provider's dynamicToolApps entry.
enabledbooleanfalse unregisters the tool, or never registers it. Default true.
serverstringRegister it on another of the provider's servers.

With schema, input that doesn't match returns isError: true and the problems as JSON: {"error":"validation_error","issues":[{"path":["itemId"],"message":"Invalid input: expected string, received undefined"}]}. The check runs inside the tool, so a plugin's Will("execute") hook sees the call before it's rejected. The tool's inputSchema in tools/list is the JSON Schema of schema.

A schema written inside the component, like schema: z.object({ … }) in the hook's options, is fine: the tool is registered again only when the schema's content changes, so the component doesn't render without end under useListTools() or useFrontMcp(), and a schema that does change, like one built from state, updates the tool's inputSchema. A new read function for useDynamicResource() doesn't register it again either. Changed in 1.8.7: each render's new schema object registered the tool again, and the page froze.

The server refuses a name it already has, or one longer than 64 characters. The component renders without its tool, and the provider's onError gets Dynamic tool "greet" was not registered: A tool named "greet" is already registered, or … Tool entry validation failed: runtime tool name must be 1-64 characters. Without onError, the console gets a warning, [frontmcp] dynamic tool "greet" was not registered: ….

A render that React throws away, like one of a transition that hasn't finished, never reaches the tool: during the transition, a call still sees the state on screen.

Changed in 1.9: the tool lived in the provider's wrapper, not in the server. Its calls skipped the server's flow, so no plugin hook or authority saw them, server.callTool("add_to_cart") on the server you created answered Tool "add_to_cart" not found, and a component's tool named like a server tool replaced it for the provider's client. And the tool kept the execute of the last render, committed or not.

useDynamicResource()

Registers a resource while the component is mounted, like useDynamicTool(). The resource stays in the provider's wrapper, not in the server: the provider's client and hooks read it, and server.readResource() on the server you created answers Resource not found.

OptionTypeDescription
uri, namestringRequired.
read()() => Promise<ReadResourceResult>Required. Returns { contents: [{ uri, mimeType?, text }] }. The read of the component's last committed render is called.
description, mimeTypestring
enabled, serverAs for useDynamicTool().

useComponentTree()

Registers a resource that describes the DOM under an element, for a model that needs to see the page's structure:

OptionDefaultDescription
rootRefRequired. A ref to the root element.
uri"react://component-tree"Must start with a scheme, like app://: "component-tree" throws URI must have a valid scheme (e.g., file://, https://, custom://) while rendering.
maxDepth10How many levels below the root.
includePropsfalseAdd each element's data-* attributes, except data-component, as props.
server

Reading it returns {"component":"Dashboard","tag":"div","children":[{"component":"Sidebar","tag":"div","children":[…],"props":{"data-open":"true"}}, …]}: component is the element's data-component, or its tag name.

Hooks in @frontmcp/react/state

useStoreResource(options), useReduxResource(options) and useValtioResource(options) do in a component what the provider's stores do: they register the state as resources and the actions as tools, while the component is mounted. They take the options of the matching adapter, createStore(), reduxStore() or valtioStore(), and the tool and resource names follow the same pattern: useStoreResource({ name: "counter", getState, subscribe, selectors, actions }) makes state://counter, state://counter/<selector> and counter_<action>. Without a name, Redux and Valtio use "redux" and "valtio".

The action tools are server tools, as with useDynamicTool(). The options can be written in the component: the hooks register again only when a name changes. Changed in 1.9: they registered again whenever the options object was a new one, and under a component that read the server's state the page froze, so options had to be made outside the component, and useReduxResource() needed a component of its own (Caveats).

RegisterCounter.tsx
import { useStoreResource } from "@frontmcp/react/state";

let counter = { value: 1 };
const listeners = new Set<() => void>();

const options = {
  name: "counter",
  getState: () => counter,
  subscribe: (cb: () => void) => {
    listeners.add(cb);
    return () => { listeners.delete(cb); };
  },
  selectors: { value: (state: unknown) => (state as typeof counter).value },
  actions: {
    increment: (by: unknown) => {
      counter = { value: counter.value + (typeof by === "number" ? by : 1) };
      listeners.forEach((listener) => listener());
      return counter.value;
    },
  },
};

export function RegisterCounter() {
  useStoreResource(options);
  return null;
}

Mounted under the provider, it lists the tool counter_increment and the resources state://counter and state://counter/value. Calling the tool with { args: [5] } returns {"success":true,"result":6}, and a component reading useStoreResource("state://counter") from the root entry point shows { value: 6 } without a refetch(). The tool and the resources go away when the component unmounts.

Changed in 1.8.7: these hooks registered nothing, because /state had its own copy of the provider's context. Use stores if you want the registration to live as long as the provider.

useApiClient()

useApiClient({ baseUrl, operations, headers?, prefix = "api", client?, fetch?, server? }), from @frontmcp/react/api, registers a tool per operation while the component is mounted, named <prefix>_<operationId>, with the operation's description and inputSchema:

  • A call fills the operation's path from the arguments, with each value URL-encoded: { id: "u 1" } for GET /users/{id} requests https://api.example.com/users/u%201.
  • Query parameters go in the URL, { fields: "name" } as ?fields=name, and header parameters are sent as headers. A body argument is sent as JSON, except for GET and HEAD.
  • headers is an object, or a function called on every request, and Content-Type: application/json is added. client replaces the fetch client with an HttpClient of your own, and fetch gives the default one a fetch function.
  • The result is text, {"status":200,"statusText":"","data":{…}}, with isError: true when the status is 400 or more. If the client throws, like on a network failure, the call has no result, and useCallTool()'s error is what it threw.

Parse the spec once, at module level, as below. An operations array made in the component works too: the tools are registered again only when an operation's name changes.

parseOpenApiSpec(spec) turns an OpenAPI 3 document into operations: { operationId, description, method, path, inputSchema, parameters }. Path, query and header parameters become properties, parameters records where each one goes, a JSON request body becomes body, and an operation without an operationId gets one from its method and path, like delete__users__id_. description is the summary, else the description, else "POST /users".

createFetchClient(fetch?) returns an HttpClient: request({ method, url, headers, body? }) sends body as JSON with Content-Type: application/json, and resolves to { status, statusText, data }, with the response parsed as JSON when it can be.

Changed in 1.8.7: useApiClient() registered nothing, for the same reason as the /state hooks, and the workaround was a useDynamicTool() for each operation.

Changed in 1.9: query and header parameters were listed in the tool's schema but not sent, and a new operations array registered every tool again on every render, which froze the page under a component that read the server's state.

useTools() and useAITools()

useAITools(platform, { server? }) and useTools(platform, { server? }), from @frontmcp/react/ai, give an AI SDK the server's tools in its format, components' tools included:

HookReturns
useAITools(platform){ tools, callTool(name, args?), loading, error }. tools is the list in the platform's format, null until loaded. callTool runs the tool and converts the result.
useTools(platform){ tools, processToolCalls(calls), loading, error }. processToolCalls takes the tool calls the model made, in the platform's shape, and returns the results to send back.

platform is "openai", "claude", "langchain", "vercel-ai" or, for useAITools(), "raw". For one tool a that takes { x: string }: "openai" gives [{ type: "function", function: { name: "a", description, parameters, strict: true } }] and callTool the tool's result as an object; "claude" gives [{ name, description, input_schema }] and callTool the result's content blocks, [{ type: "text", text: "{…}" }]; "langchain" gives [{ name, description, schema }]; "vercel-ai" gives { a: { description, parameters } }; "raw" gives [{ name, description, inputSchema }] and the whole MCP result. useTools("claude").processToolCalls([{ type: "tool_use", id, name, input }]) returns [{ type: "tool_result", tool_use_id, content }], and the OpenAI one [{ role: "tool", tool_call_id, content }].

formatToolsForPlatform(tools, platform) from @frontmcp/sdk converts a list by hand, and createToolCallHandler(server, platform) from @frontmcp/react/ai gives { callTool } for a server you hold, like the one from useFrontMcp(). The conversions are those of connect()'s variants.

Changed in 1.8.7: useAITools() and useTools() read their own copy of serverRegistry, which no provider filled, and stayed at { tools: null, loading: true }.


Usage

Showing a tool's result

Search.tsx
import { useCallTool } from "@frontmcp/react";

type SearchResult = { structuredContent?: { tickets: { id: string; title: string }[] }; isError?: boolean; content?: { text?: string }[] };

export function Search() {
  const [search, { data, loading, error }, reset] = useCallTool<{ query: string }, SearchResult>("search_tickets");

  return (
    <div>
      <button disabled={loading} onClick={() => search({ query: "login" })}>Search</button>
      <button onClick={reset}>Clear</button>
      {error && <p role="alert">Couldn't call the tool: {error.message}</p>}
      {data?.isError && <p role="alert">{data.content?.[0]?.text}</p>}
      <ul>{data?.structuredContent?.tickets.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
    </div>
  );
}

Giving the model a tool from a component

add_to_cart exists while the cart is on screen, and only for signed-in users. Its schema is defined once, outside the component.

CartControls.tsx
import { useState } from "react";
import { useDynamicTool } from "@frontmcp/react";
import { z } from "@frontmcp/sdk";

const addToCartInput = z.object({ itemId: z.string().describe("Product id"), quantity: z.number().optional() });

export function CartControls({ signedIn }: { signedIn: boolean }) {
  const [items, setItems] = useState<string[]>([]);

  useDynamicTool({
    name: "add_to_cart",
    description: "Add a product to the user's cart",
    schema: addToCartInput,
    enabled: signedIn,
    execute: async ({ itemId, quantity }) => {
      setItems((list) => [...list, `${itemId} × ${quantity ?? 1}`]);
      return { content: [{ type: "text", text: `Added ${itemId}` }] };
    },
  });

  return <ul>{items.map((item) => <li key={item}>{item}</li>)}</ul>;
}

Calling add_to_cart with { itemId: "sku-1", quantity: 2 } returns Added sku-1 and adds sku-1 × 2 to the list. With signedIn false, the tool isn't listed.

Keeping a resource live

bump changes a count and tells subscribers; every Count on the page reads it again.

Count.tsx
import { Resource, ResourceContext, Tool, ToolContext, useCallTool, useStoreResource } from "@frontmcp/react";

let count = 0;

@Resource({ uri: "app://count", name: "count", mimeType: "application/json" })
export class CountResource extends ResourceContext {
  async execute() {
    return { count };
  }
}

@Tool({ name: "bump", description: "Add one to the count", inputSchema: {} })
export class Bump extends ToolContext {
  async execute() {
    count++;
    this.notifyResourceUpdated("app://count");
    return { count };
  }
}

export function Count() {
  const { data } = useStoreResource("app://count");
  const [bump] = useCallTool("bump");
  return <button onClick={() => bump({})}>Count: {(data as { count: number } | null)?.count}</button>;
}

The button reads Count: 0, then Count: 1 after a click, with no refetch().

Registering API operations as tools

useApiClient() turns an OpenAPI document into tools. The operations are parsed once, outside the component, and the component renders nothing:

ApiTools.tsx
import { useApiClient, parseOpenApiSpec } from "@frontmcp/react/api";
import spec from "./openapi.json";

const operations = parseOpenApiSpec(spec);

export function ApiTools({ token }: { token: string }) {
  useApiClient({
    baseUrl: "https://api.example.com",
    operations,
    headers: () => ({ Authorization: `Bearer ${token}` }),
  });
  return null;
}

For a GET /users/{id} operation with operationId: "getUser", api_getUser is listed, and calling it with { id: "u 1" } requests https://api.example.com/users/u%201 with the Authorization header, and answers {"status":200,"statusText":"","data":{…}} as text. headers is a function, so each request reads the token the component has now.

Handing the tools to an AI SDK

Chat.tsx
import { useAITools } from "@frontmcp/react/ai";

export function useOpenAITools() {
  const { tools, callTool, loading } = useAITools("openai");
  return { tools, callTool, ready: !loading && tools !== null };
}

tools goes to the chat completion request, with the components' tools among the server's, and callTool(name, args) runs each tool call the model makes and returns its result converted for OpenAI. To handle a whole batch of tool calls in the platform's own shape, useTools("openai").processToolCalls(calls) returns the messages to send back. Keep API keys on a server of your own.


Troubleshooting

Dynamic tool "…" was not registered: …

The provider's onError got it, or the console, when the server refused a component's tool (useDynamicTool):

  • A tool named "…" is already registered: the server has a tool by that name, its own or another one registered at run time. Rename the component's tool.
  • runtime tool name must be 1-64 characters: shorten the name.
  • runtime tool "…" must name its app (one of: …): the server has several apps. Give the tool an app, or the provider dynamicToolApps.

data holds an error but error is null

The tool failed on the server, which is a result, not an error (useCallTool). Check data.isError; the message is in data.content[0].text.

FrontMCP not connected

The call ran before the provider connected. Wait for status === "connected" from useFrontMcp().

MCP error -32002: … Resource not found: state://…

useStoreResource() or useReadResource() read a state:// URI no store registers: the store's name differs, or nothing registered it yet. Pass the store in the provider's stores, or mount the /state hook that registers it (Hooks in @frontmcp/react/state).

A component's tool answers {"error":"validation_error",…}

Its useDynamicTool() schema rejected the input, before execute ran. The issues list what's wrong, with a path to each field.

URI must have a valid scheme (e.g., file://, https://, custom://)

useComponentTree() got a uri without a scheme. Use something like app://component-tree.

useAITools() stays { tools: null, loading: true }

The provider hasn't connected: autoConnect={false} before connect(), or the hook sits outside a FrontMcpProvider. It fills in once the provider's status is "connected" (useTools and useAITools).