@frontmcp/react

@frontmcp/react puts a FrontMCP server inside a React app. You build the server with create(), in the same process, and FrontMcpProvider connects a client to it. Components then call its tools, read its resources and get its prompts with hooks, render forms and results with components, and add tools and resources of their own that exist only while they're mounted. A component's tool is a tool of the server, so everything a model can call is in one place: an agent loop in the app that calls through the provider's server sees the server's tools and the components' tools together (Handing the tools to an AI SDK).

const server = await create({ info: { name, version }, tools?, resources?, prompts?, ... });

<FrontMcpProvider server={server} name? servers? components? stores? dynamicToolApps? autoConnect? onConnected? onError?>
  {children}
</FrontMcpProvider>

Reference

Installing

npm install @frontmcp/react react react-dom

@frontmcp/sdk and @frontmcp/utils come with it, at the same version. You don't need to import reflect-metadata.

Entry pointWhat's in it
@frontmcp/reactFrontMcpProvider, the hooks, the components, serverRegistry, DynamicRegistry and bindDynamicTools(), the store adapters, and from @frontmcp/sdk: create, clearCreateCache, connect and its variants, the decorators (Tool, Resource, ResourceTemplate, Prompt, App, Plugin, Adapter, FrontMcp), their function forms (tool(), resource(), …), the context classes and z.
@frontmcp/react/routerThe router bridge. Needs react-router-dom 7.
@frontmcp/react/stateStore hooks, which do in a component what stores do (Hooks).
@frontmcp/react/apiuseApiClient, parseOpenApiSpec and createFetchClient (Hooks).
@frontmcp/react/aiuseAITools, useTools and createToolCallHandler (Hooks).

Running in a browser

@frontmcp/sdk has a build for browsers, which a bundler that follows the browser export condition picks, as Vite does. A Vite 8 app (8.3, with @vitejs/plugin-react 6) needs only React's plugin:

vite.config.ts
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [react()] });

vite build finishes with no warning about FrontMCP's packages, and the app runs. The smallest app's bundle is about 2.9 MB, 780 KB gzipped. It also ran from Vite 8's dev server, and built and ran with Vite 7.3. This is how the pages in this section were run in Chromium; the rest of their examples ran in Node (Testing components in Node).

Changed in 1.9.3: @frontmcp/utils 1.9.2 loaded @upstash/redis, for a storage adapter a page doesn't use, without declaring it, so vite build stopped with Rolldown failed to resolve import "@upstash/redis" from ".../@frontmcp/utils/esm/index.mjs" and the dev server's page stayed blank, until the package was aliased to an empty module in vite.config.ts. The build also warned that node:crypto had been externalized for browser compatibility. That alias can go; a config that keeps it still builds and runs.

Changed in 1.9: the bundler had to supply what the SDK took from Node: vite-plugin-node-polyfills for process, global, Buffer and the Node modules, and an empty module in place of express. Without them the page failed with process is not defined before rendering anything, and a Vite 7 build stopped at "PassThrough" is not exported by "__vite-browser-external". A config that still has both builds and runs on 1.9.3, with a bundle of about 2.9 MB.

Changed in 1.8.7: a bundle used to fail as soon as it loaded, with (0 , A.createRequire) is not a function, because the SDK's ES module build imported Node's module at the top. It now looks for process.getBuiltinModule("module") and goes without it when there's none.

FrontMcpProvider

Wrap the part of the app that uses the server. See more examples below.

App.tsx
import { create, FrontMcpProvider } from "@frontmcp/react";
import { GreetTool } from "./greet.tool";
import { Greeter } from "./Greeter";

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

export function App() {
  return (
    <FrontMcpProvider server={server}>
      <Greeter />
    </FrontMcpProvider>
  );
}

Props

PropTypeDefaultDescription
serverDirectMcpServerRequired. From create() or FrontMcpInstance.createDirect().
childrenReactNodeRequired.
namestring"default"The server's name in serverRegistry, which hooks without a server option use.
serversRecord<string, DirectMcpServer>More servers, by name. Hooks reach them with { server: "name" }. Each connects after the main one.
componentsRecord<string, ComponentType>Components for DynamicRenderer, by URI, like { "component://Card": Card }.
storesStoreAdapter[]App state to expose as resources and tools. See Exposing app state.
dynamicToolAppsRecord<string, string>The app that components' tools join, by server name, like { default: "billing" }. Only for a server with several apps; a create() server has one. See useDynamicTool().
autoConnectbooleantrueConnect on mount. With false, call connect() from useFrontMcp().
onConnected(client: DirectClient) => voidCalled once the client is connected and the lists are loaded.
onError(error: Error) => voidCalled when connecting fails, and when a server refuses a tool a component registers. Without it, a refused tool is a console.warn.

What it does

  1. On mount, it registers the server under name in serverRegistry, and each of servers under its key, wrapped so that components can add resources. The status is "idle".
  2. With autoConnect, it sets "connecting", calls the wrapped server's connect(), and lists its tools, resources, resource templates and prompts. The status is then "connected", or "error" with error set if connecting failed.
  3. It registers each tool a component adds with that component's server, through server.registerTool(), and removes it when the last component that added it unmounts. The server tells the client its tools changed, and the provider lists them again, as it does for a tool registered any other way. When a component adds or removes a resource, it lists the resources again.
  4. On unmount, it removes its servers from serverRegistry, and its components' tools from the servers.

The client

The provider's client is a DirectClient named mcp-client, so tools see this.clientInfo.name as "mcp-client" and this.platform as "generic-mcp". Calls go through every check a client call has. What the hooks return is the MCP result as the client got it: useCallTool() gives you { content, structuredContent }, or { content, isError: true, _meta } for a tool that failed.

A tool a component adds is a tool of the server you passed in. server.callTool("add_to_cart") reaches it, its calls run through the server's flow, with the app's plugin hooks, authorities and availableWhen, and every client of the server sees it, an in-page agent through @frontmcp/plugin-webmcp included. A resource a component adds lives in the provider's wrapper: the client and the hooks see it, and server.readResource() on the server you passed in answers Resource not found: app://cart.

Changed in 1.9: components' tools lived in the wrapper too. They skipped the server's flow, so no hook or authority saw their calls, server.callTool() answered Tool "add_to_cart" not found, and a component's tool named like a server tool replaced it for the provider's client. Such a tool is now refused (useDynamicTool()).

serverRegistry

The module's ServerRegistry, shared by every provider in the app:

MethodDescription
list()The registered names.
get(name), has(name)A ServerEntry: { server, client, status, error, tools, resources, resourceTemplates, prompts }.
connect(name), connectAll()Connect a registered server that isn't connected yet.
subscribe(listener)Called on every change. Returns an unsubscribe function.
register(name, server), unregister(name), update(name, partial), clear()What providers use.

bindDynamicTools()

The provider keeps its components' tools in a DynamicRegistry, and bindDynamicTools() makes each of them a tool of the server. Both are exported, for tools registered outside a provider:

import { bindDynamicTools, create, DynamicRegistry } from "@frontmcp/react";

const server = await create({ info: { name: "my-app", version: "1.0.0" }, tools: [] });
const registry = new DynamicRegistry();
const stop = bindDynamicTools(registry, server, { onError: (error, toolName) => console.warn(toolName, error.message) });

const remove = registry.registerTool({
  name: "hello",
  description: "Say hello",
  inputSchema: { type: "object", properties: {} },
  execute: async () => ({ content: [{ type: "text", text: "hi" }] }),
});

A moment later server.listTools() lists hello, and server.callTool("hello") returns hi: the tool is registered with server.registerTool(), and its calls run through the server's flow. remove() takes it away again, and stop() stops following the registry and removes every tool it added. A tool the server refuses goes to onError, like A tool named "greet" is already registered; without onError it's a console.warn. The other option, app, names the app tools join on a server with several, as the provider's dynamicToolApps does.

Caveats

  • A server refuses a component's tool when it already has a tool by that name, or the name is longer than 64 characters. The provider passes onError an error like Dynamic tool "greet" was not registered: A tool named "greet" is already registered, and the component keeps rendering without its tool.
  • On a server with several apps, a component's tool has to say which app it joins, with its own app option or the provider's dynamicToolApps. Otherwise it's refused with runtime tool "…" must name its app (one of: billing, support).
  • A component's resources stay in the provider's wrapper, so only the provider's client sees them.

Changed in 1.9: hooks given a new object on every render registered their tools again on every render, and each registration made the provider list the server again, so under a component that read the server's state the page froze: useStoreResource() and useValtioResource() from /state with options written in the component, useApiClient() with operations written there, and useReduxResource() always. They register again only when a name changes, and the provider ignores a listing that hasn't changed.

Changed in 1.8.7: @frontmcp/react/state, /api and /ai each carried their own copy of the provider's context, so their hooks never saw a FrontMcpProvider from @frontmcp/react. useStoreResource() from /state and useApiClient() registered nothing, and useAITools() stayed { tools: null, loading: true }. Inline useDynamicTool() schemas, AgentSearch and AgentContent froze the page.


Usage

Getting started

A tool, the server, and a component that calls it. useCallTool() returns the call function, the call's state, and a reset function; data is the tool's result, so the message is in data.structuredContent.

greet.tool.ts
import { Tool, ToolContext } from "@frontmcp/react";
import { z } from "@frontmcp/sdk";

@Tool({ name: "greet", description: "Greet someone by name", inputSchema: { name: z.string() } })
export class GreetTool extends ToolContext {
  async execute({ name }: { name: string }) {
    return { message: `Hello, ${name}!` };
  }
}
Greeter.tsx
import { useCallTool, useFrontMcp } from "@frontmcp/react";

export function Greeter() {
  const { status } = useFrontMcp();
  const [greet, { data, loading, error }] = useCallTool<{ name: string }, { structuredContent?: { message: string } }>("greet");

  if (status !== "connected") return <p>{status}</p>;
  return (
    <div>
      <button disabled={loading} onClick={() => greet({ name: "Ada" })}>Greet</button>
      {data?.structuredContent && <p>{data.structuredContent.message}</p>}
      {error && <p role="alert">{error.message}</p>}
    </div>
  );
}

With the App above, clicking the button shows Hello, Ada!. error is set only when the call couldn't be made, like FrontMCP not connected: a tool that fails returns a result with isError: true in data (useCallTool).

Connecting later

Connect.tsx
import { useFrontMcp } from "@frontmcp/react";

export function Connect() {
  const { status, error, connect } = useFrontMcp();
  if (status === "idle") return <button onClick={connect}>Connect</button>;
  if (status === "error") return <p role="alert">{error?.message}</p>;
  return <p>{status}</p>;
}

// <FrontMcpProvider server={server} autoConnect={false} onError={(e) => report(e)}>
//   <Connect />
// </FrontMcpProvider>

Before connect(), the status is "idle", and a hook's call returns null with error FrontMCP not connected. A server whose connect() rejects ends in "error", and onError gets the error.

Using several servers

App.tsx
import { create, FrontMcpProvider, useCallTool, useListTools } from "@frontmcp/react";
import { SearchTool, TrackTool } from "./tools";

const main = await create({ info: { name: "main", version: "1.0.0" }, tools: [SearchTool] });
const analytics = await create({ info: { name: "analytics", version: "1.0.0" }, tools: [TrackTool] });

export function App() {
  return (
    <FrontMcpProvider server={main} servers={{ analytics }}>
      <Dashboard />
    </FrontMcpProvider>
  );
}

function Dashboard() {
  const [track] = useCallTool("track", { server: "analytics" }); // on analytics
  const tools = useListTools();                                   // main's tools only
  const analyticsTools = useListTools({ server: "analytics" });
  return <button onClick={() => track({ event: "click" })}>{tools.length} + {analyticsTools.length} tools</button>;
}

Without server, a hook uses the provider's own server: calling track there returns isError: true with Tool "track" not found.

Exposing app state with stores

Each adapter in stores becomes a resource with the whole state, a resource per selector, and a tool per action. The tools are the server's, like a component's; the resources stay in the provider's wrapper:

AdapterResourceSelector resourcesAction tools
createStore({ name, getState, subscribe, selectors?, actions? })state://<name>state://<name>/<selector><name>_<action>
reduxStore({ store, name = "redux", selectors?, actions? })state://reduxthe samethe same, and each action creator's result is dispatched
valtioStore({ proxy, subscribe, name = "valtio", paths?, mutations? })state://valtio, a JSON copy of the proxyone per paths entry, a dot path like "user.name"one per mutations entry

Names and selector keys may use letters, digits, _ and -; any other name throws (Troubleshooting). Pass valtio/utils' subscribe to valtioStore yourself.

App.tsx
import { create, createStore, FrontMcpProvider, reduxStore } from "@frontmcp/react";

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

const counterStore = createStore({
  name: "counter",
  getState: () => counter,
  subscribe: (cb) => {
    listeners.add(cb);
    return () => { listeners.delete(cb); };
  },
  selectors: { value: (s) => (s as typeof counter).value },
  actions: {
    increment: (by) => {
      counter = { value: counter.value + (typeof by === "number" ? by : 1) };
      listeners.forEach((l) => l());
      return counter.value;
    },
  },
});

const stores = [counterStore, reduxStore({ store: todoStore, actions: { addTodo: (text) => ({ type: "todos/add", text }) } })];

export const App = () => (
  <FrontMcpProvider server={server} stores={stores}>
    <Dashboard />
  </FrontMcpProvider>
);

This lists the tools counter_increment and redux_addTodo, and the resources state://counter, state://counter/value and state://redux. Reading state://counter/value returns 1 as JSON.

An action tool's input is { args?: unknown[] }. { args: [5] } calls increment(5), and the result is {"success":true,"result":6}. Without an args array, the action gets the whole input object: { by: 2 } calls increment({ by: 2 }). A Redux action's result is what dispatch() returned, the action itself.

Keep the stores array outside the component, as above: a new array on every render registers everything again.

The store's resources tell subscribers when the state changes, so useStoreResource("state://counter") shows the new value as soon as an action has run, without refetch(). Changed in 1.8.7: a store's resource never sent that notification, and the hook kept its first value until refetch().

Testing components in Node

Components run in Node as well, which is where most examples on these pages were checked: jsdom for the DOM, React's act() to let effects and calls settle.

npm install --save-dev jsdom @types/jsdom tsx
greeter.test.tsx
import { test } from "node:test";
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";

const { window } = new JSDOM("<!doctype html><body></body>", { url: "http://localhost/" });
Object.assign(globalThis, { window, document: window.document, HTMLElement: window.HTMLElement, Event: window.Event, IS_REACT_ACT_ENVIRONMENT: true });

test("greets", async () => {
  const { act } = await import("react");
  const { createRoot } = await import("react-dom/client");
  const { create, FrontMcpProvider } = await import("@frontmcp/react");
  const { GreetTool } = await import("./greet.tool");
  const { Greeter } = await import("./Greeter");

  const server = await create({ info: { name: "test", version: "1.0.0" }, tools: [GreetTool] });
  const container = document.body.appendChild(document.createElement("div"));
  await act(async () => createRoot(container).render(<FrontMcpProvider server={server}><Greeter /></FrontMcpProvider>));
  await act(async () => container.querySelector("button")!.click());

  assert.match(container.textContent!, /Hello, Ada!/);
  await server.dispose();
});

Run it with npx tsx --test greeter.test.tsx, with "jsx": "react-jsx" and "experimentalDecorators": true in tsconfig.json.


Troubleshooting

Module "node:crypto" has been externalized for browser compatibility

Vite warned this once, for @frontmcp/utils 1.9.2, which imported node:crypto at the top of its ES module build. The build finished and the app ran. @frontmcp/utils 1.9.3 doesn't import it there (Running in a browser).

Rolldown failed to resolve import "@upstash/redis"

Or Rollup failed to resolve import with Vite 7, or a blank page and Failed to resolve import "@upstash/redis" from the dev server. @frontmcp/utils 1.9.2 imports that package for its Upstash storage adapter without declaring it. Update @frontmcp/react to 1.9.3, where it's an optional peer that the build leaves out (Running in a browser).

process is not defined, or "PassThrough" is not exported by "__vite-browser-external"

A browser bundle of FrontMCP 1.8 or earlier failed this way, without Node's modules supplied by a plugin. Since 1.9 the SDK has a browser build, and 1.9.3 needs only React's plugin (Running in a browser).

FrontMCP not connected

A hook's call ran before the provider connected: with autoConnect={false} before connect(), or while the status is "connecting". Check status from useFrontMcp(), or disable the control until it's "connected".

Dynamic tool "…" was not registered: …

The server refused a tool a component registers, and the provider passed the error to onError, or to the console: the name is taken or too long, or the server has several apps and the tool didn't say which it joins (Caveats, useDynamicTool()).

useStoreRegistration: invalid store name

The full message is useStoreRegistration: invalid store name "my store". Names must match /^[a-zA-Z0-9_-]+$/., thrown while the provider mounts. Store names and selector keys become URIs and tool names, so use letters, digits, _ and -.

useServer() returns undefined

No server is registered under that name. useServer("main") finds a server only when a provider with name="main", or an entry of a provider's servers called main, has mounted. Without an argument it reads the name of the provider the hook sits under, which is "default" unless you set name.