Browser
A FrontMCP server can run inside your web app. Your bundler picks the SDK's browser build, and the server lives in the page, or in a Web Worker, for as long as the page is open. Nothing listens on a port: page code calls the server through create(), through a connect() client, or through a fetch handler it hands Request objects to, and React components and agents in the browser reach it through @frontmcp/react and WebMCP. Tools, prompts, plugins and agents run as they do on a server. What needs Node doesn't: the HTTP server, stdio, Redis, SQLite, the file system, and, in a production build, the OAuth server of local and remote auth. This page lists what works where, the browser storage that @frontmcp/utils keeps keys in, and what to watch for when the page calls a model.
import { create, connect, FrontMcpInstance } from "@frontmcp/sdk";
import { createKeyPersistence } from "@frontmcp/utils";
const server = await create({ info, tools }); // page code calls server.callTool(…)
const client = await connect({ info, apps }); // an MCP client in the page
const handler = await FrontMcpInstance.createFetchHandler(config); // (request: Request) => Promise<Response>
const keys = await createKeyPersistence(); // IndexedDB, else localStorage, else memory
Reference
The browser build
The main entry of @frontmcp/sdk has a browser condition: browser/index.mjs for import, browser/index.cjs for require(). A bundler that follows it, like Vite, picks the browser build by itself, in its dev server and in vite build; Node, and bundlers that target Node, keep the Node build. @frontmcp/utils swaps in browser versions of its crypto, async context, environment and runtime helpers the same way. The page needs no polyfills for Node's modules and no aliases: Running in a browser has the Vite details. Two things are up to you:
- Decorators. Set
experimentalDecoratorsintsconfig.json, as in any FrontMCP project. Without it, Vite 8 leaves@Tool(…)in the bundle as written, and the page stops as it loads, withInvalid or unexpected token. Or write tools as functions withtool(), which needs no decorators. - A
Bufferfor resources. The browser build reads Node'sBufferglobal while it turns what a resource returned into its contents, and a browser has none, so reading most resources fails. Put one in place: see Output validation failed.
frontmcp build --target browser writes your server's code as one ES module for your web app's bundler to take in (Targets). The page can just as well import your server's files directly, as the examples here do.
Entry points
| Entry point | In a browser |
|---|---|
create(config) | Works. Page code calls server.callTool(), readResource() and getPrompt() as in Node. |
FrontMcpInstance.createDirect(config) | Works. |
connect(config) and the adapters, like connectOpenAI() | Work: an MCP client in the page. |
FrontMcpInstance.createFetchHandler(config) | Works: a function from a Request to a Response, which page code calls with requests it builds. /healthz answers {"status":"ok",…,"transport":"web-fetch"}. |
@FrontMcp on a class | Starts the HTTP server, which the browser build doesn't have: the page reports ExpressHostAdapter is not available in browser environments. With serve: false the class does nothing, and you pass its options to one of the entry points above. |
FrontMcpInstance.bootstrap(), FrontMcpInstance.createHandler() | Reject with the same error. |
FrontMcpInstance.runStdio() | Rejects with Dynamic require of "node:console" is not supported: a page has no stdin and stdout. |
create() works the same in a Web Worker, which gets the same build. To add tools while the page runs, like a tool that exists only while a form is open, use server.registerTool() on the server create() returned.
What runs, and what needs Node
| In a browser | |
|---|---|
| Tools, prompts, apps, plugins | Run as on a server. ConfigPlugin reads its schema's defaults: a page has no .env or YAML files. |
| Agents | Run, with a model adapter that works in a page: see Model adapters. |
| Resources | Need a Buffer global. Without one, a resource that returns a string or a plain object fails with INVALID_OUTPUT; one that returns { text } or { contents } works. |
auth in public, static and transparent mode | Work. transparent checks your provider's tokens with the browser's WebCrypto. |
auth in local and remote mode | In a production build, the server refuses to start with JWT_SECRET is required in production. The browser build doesn't read JWT_SECRET from the environment, so a page can't set it. In development they start, signing with a random secret, and warn JWT_SECRET is not set; signing with a random per-process secret. |
redis | Ignored: ioredis can't load in a page. The server logs Failed to connect to redis, falling back to memory and keeps everything in memory. |
sqlite | The server doesn't start: Dynamic require of "@frontmcp/utils" is not supported. |
| Requests at the same time | Take turns, one at a time, unless the browser has TC39 AsyncContext; Chromium doesn't. See In a browser. |
App.esm() | Keeps the bundles it downloads in memory only: see Caching. |
The frontmcp CLI, the Nx plugin | Node programs that build and run servers. They don't go in a page. |
The settings FrontMCP reads from the environment on a server, like JWT_SECRET, VAULT_SECRET, REDIS_URL or FRONTMCP_PUBLIC_URL, have no effect in a page: the browser build doesn't read them. NODE_ENV is the exception (below).
In @frontmcp/utils
| Function | In a browser |
|---|---|
sha256Hex(), hmacSha256(), hkdfSha256(), encryptAesGcm(), decryptAesGcm(), randomBytes(), randomUUID(), generateCodeVerifier(), generateCodeChallenge() | Work, with WebCrypto. |
rsaVerify() | Works, with WebCrypto. It's async. |
rsaVerifySync() | Throws rsaVerifySync is only available in Node.js runtimes; use the async rsaVerify in browsers. |
generateRsaKeyPair(), rsaSignBase64Url(), pemToPublicJwk() | Throw <name> is only available in Node.js runtimes. |
FileSystemStorageAdapter, createKeyPersistence({ type: "filesystem" }) | Throw File system operations is not supported in the browser. Requires Node.js. |
createKeyPersistence() | Keeps keys in IndexedDB or localStorage. |
The runtime context
In the browser build, this.runtimeContext, and getRuntimeContext() from @frontmcp/utils, are { os: "browser", platform: "browser", runtime: "browser", deployment: "standalone", provider: "bare", target: "browser", env }, whatever the browser. So availableWhen: { runtime: ["browser"] } offers a tool only in the page, and ["node"] keeps it out: see Offering a tool only where it works.
NODE_ENV in a page
env, and everything FrontMCP does differently in production, follow NODE_ENV. In a page, that's the value your bundler writes in place of process.env.NODE_ENV: "production" from vite build, "development" from Vite's dev server. (A process.env.NODE_ENV the page sets on globalThis.process comes first: see In the Playground.) So the page you ship runs in production:
- A tool's error reaches clients as
Internal FrontMCP error. Please contact support with error ID: err_…, unless it's aPublicMcpError. Usage below. localandremoteauth refuse to start, because they needJWT_SECRET.- Each server logs
No distributed storage backend detected in productionto the console as it starts.
createKeyPersistence(options?)
Keeps keys and secrets in the browser's storage, so they're the same after a reload. Install @frontmcp/utils, at the same version as @frontmcp/sdk:
import { createKeyPersistence } from "@frontmcp/utils";
const keys = await createKeyPersistence();
const { secret } = await keys.getOrCreateSecret("desk-drafts"); // 32 random bytes, base64url; the same next visitOptions
| Option | Type | Default | Description |
|---|---|---|---|
type | "auto", "indexeddb", "localstorage", "memory" or "filesystem" | "auto" | Where keys are kept. In a browser, "auto" takes IndexedDB; localStorage if IndexedDB can't be opened; memory if neither works. In Node it takes files under baseDir. "filesystem" throws in a browser. |
encryptionKey | Uint8Array, 32 bytes | Derived | localStorage only: the AES-256-GCM key that values are encrypted with. Without it, one is derived with HKDF-SHA256 from a random salt, which is kept in localStorage too, and the page's origin. |
baseDir | string | ".frontmcp/keys" | Node only: the folder for "filesystem". |
throwOnInvalid | boolean | false | Throw when a stored value isn't a valid key, instead of returning null. |
enableCache | boolean | true | Keep keys in memory after the first read. |
Returns
A Promise of a KeyPersistence, already connected to its storage:
| Method | Description |
|---|---|
getOrCreateSecret(kid, { bytes? }) | The secret named kid, made with bytes random bytes (32 by default) the first time: { type: "secret", kid, secret, bytes, createdAt, version }, with secret in base64url. |
get(kid), getSecret(kid), getAsymmetric(kid) | A stored key, or null. |
set(key), delete(kid), has(kid), list() | Store a key, remove one, check for one, list their ids. |
clearCache(), getAdapter() | Forget the keys held in memory; the storage underneath. |
Where the keys are
| Storage | Where | What's there |
|---|---|---|
| IndexedDB | Database frontmcp, object store kv, key frontmcp:keys:<kid> | The key as JSON, unencrypted. |
| localStorage | frontmcp:keys:<kid>, plus the salt under __frontmcp_internal__:install_salt | {"v":"","_enc":{"alg":"A256GCM","iv":…,"tag":…,"data":…}}: the key, encrypted. |
| Memory | Gone when the page reloads. |
A Web Worker has IndexedDB but no localStorage, so "auto" takes IndexedDB there too.
IndexedDBStorageAdapter and LocalStorageAdapter, the classes underneath, aren't exported from @frontmcp/utils: importing them gives undefined. To keep keys in storage of your own, pass any StorageAdapter, like the exported MemoryStorageAdapter, to createKeyPersistenceWithStorage(adapter).
Model adapters in a page
An agent in the page calls its model from the browser. How the built-in adapters behave there:
| Adapter | In a browser |
|---|---|
OpenAIAdapter with apiKey | Works. It loads openai when it first needs it (Vite puts it in a chunk of its own), and the browser sends the requests, with the key in the Authorization header. |
AnthropicAdapter with apiKey | Fails at its first request with It looks like you're running in a browser-like environment.: Anthropic's client refuses browsers unless you tell it otherwise, and the adapter doesn't. |
Either, with client | Works with the client you made, like new Anthropic({ apiKey, dangerouslyAllowBrowser: true }). |
OpenAIAdapter with baseUrl | Sends its requests to baseUrl instead of OpenAI. (AnthropicAdapter with apiKey and baseUrl still fails as above: give its client the baseURL.) |
The options are on Built-in adapters. Whichever you pick, a key in the page is a key anyone who opens it can read. Point baseUrl at an endpoint of your own that adds the key on your server, as in Calling a model from the page.
Caveats
- The server lasts as long as the page. A reload, or a closed tab, ends it, with everything it kept in memory. Keep what must last in IndexedDB, localStorage or your own backend.
- The build doesn't warn.
vite buildfinishes the same withredis,sqliteorlocalauth configured; they fail when the page runs, as the table above says. - The examples on this page that aren't Playgrounds ran with
@frontmcp/sdkand@frontmcp/utils1.9.4, built with Vite 8.3 and run in Chromium, in a page and in a Web Worker. Requests to OpenAI and Anthropic were answered by a stand-in.
Usage
Running a server in the page
Page code builds the server and calls it; nothing listens. This Playground runs FrontMCP's browser build in a Web Worker in your browser, so the Call tab's answer says "runtime": "browser". The tests use the three entry points a page has, and pass in Node as well, where the runtime is "node":
import { Tool, ToolContext } from "@frontmcp/sdk";
@Tool({ name: "count_open_tickets", description: "Count the open tickets in the queue", inputSchema: {} })
export class CountOpenTickets extends ToolContext {
async execute() {
return { open: 3, runtime: this.runtimeContext.runtime };
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The handler never sends the request anywhere: any origin will do, and the path picks what answers, /healthz or the MCP endpoint at /. Use create() when page code only calls tools, connect() when something in the page expects an MCP client, like an AI SDK through connectOpenAI(), and the handler when code in the page speaks MCP over HTTP.
Bundling the page with Vite
The page's entry builds the server once, at the top level, and uses it:
import { create } from "@frontmcp/sdk";
import { CountOpenTickets } from "./count-open-tickets.tool";
const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [CountOpenTickets] });
const result = await server.callTool("count_open_tickets", {});
document.querySelector("#open")!.textContent = String(result.structuredContent?.open);{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}npm install @frontmcp/sdk
npm install -D vite typescript
npx vite buildThere's no vite.config.ts to write for FrontMCP. vite build warns only that some chunks are larger than 500 kB, and npx vite serves the page in development. To keep the server off the page's main thread, put the same code in a worker and start it with new Worker(new URL("./worker.ts", import.meta.url), { type: "module" }); Vite bundles the worker with its default settings, and the server answers there with the same runtime context.
Seeing what a built page tells clients
vite build makes the page a production build, so a tool's failure reaches the client the way it would from a server in production. A plain Error becomes an error id; a PublicMcpError keeps its message. The tests switch NODE_ENV as Vite does:
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
@Tool({ name: "close_ticket", description: "Close a support ticket", inputSchema: { id: z.string() } })
export class CloseTicket extends ToolContext {
async execute({ id }: { id: string }) {
if (!/^T-\d+$/.test(id)) {
// ✅ Kept word for word, in production too
this.fail(new PublicMcpError(`There's no ticket ${id}. Ticket ids look like T-1.`, "TICKET_NOT_FOUND"));
}
// 🚩 Hidden in production: the client gets an error id
throw new Error("The ticket store at 10.0.4.7:5432 refused the connection");
}
}Starting FrontMCP in your browser…
FrontMCP starts when this example comes into view.
The page's console still logs the original error, so you can find it while you debug. A message the model should read goes in a PublicMcpError: see The client sees "Internal FrontMCP error".
Keeping a key between visits
A help desk page keeps the support agent's unsent replies in the browser, encrypted with a key that's made on the first visit and read back on every later one:
import { base64urlDecode, createKeyPersistence, decryptAesGcm, encryptAesGcm, randomBytes } from "@frontmcp/utils";
const keys = await createKeyPersistence(); // IndexedDB in a page or a worker
const { secret } = await keys.getOrCreateSecret("desk-drafts");
const key = base64urlDecode(secret);
export async function sealDraft(text: string) {
const iv = randomBytes(12);
const { ciphertext, tag } = await encryptAesGcm(key, new TextEncoder().encode(text), iv);
return { iv, ciphertext, tag };
}
export async function openDraft({ iv, ciphertext, tag }: Awaited<ReturnType<typeof sealDraft>>) {
return new TextDecoder().decode(await decryptAesGcm(key, ciphertext, iv, tag));
}After a reload, getOrCreateSecret("desk-drafts") returns the same secret, read from the IndexedDB database frontmcp, and openDraft() gets the text back. With createKeyPersistence({ type: "localstorage" }) the key goes to localStorage, encrypted, beside the salt it's encrypted with.
Calling a model from the page
An agent in the page works like an agent on a server, except that its model adapter runs in the browser:
import { Agent, AgentContext, OpenAIAdapter, z } from "@frontmcp/sdk";
// 🚩 The key ships with the page: anyone who opens it can read it
// const model = new OpenAIAdapter({ model: "gpt-5", apiKey: "sk-…" });
// ✅ The browser calls your endpoint, which adds the key on your server and forwards the request
const model = new OpenAIAdapter({ model: "gpt-5", apiKey: "added-by-the-proxy", baseUrl: "https://desk.example.com/llm/v1" });
@Agent({
name: "triage",
description: "Suggest a priority for a support ticket. Pass the ticket's id.",
inputSchema: { ticketId: z.string() },
llm: { adapter: model },
})
export class Triage extends AgentContext {}With baseUrl, the adapter posts to https://desk.example.com/llm/v1/chat/completions with Authorization: Bearer added-by-the-proxy, and invoke_triage returns the reply as { response }. The apiKey can't be empty, so pass a placeholder that your endpoint ignores. For Anthropic, make the client yourself:
import Anthropic from "@anthropic-ai/sdk";
import { AnthropicAdapter } from "@frontmcp/sdk";
const model = new AnthropicAdapter({
model: "claude-sonnet-4-6",
client: new Anthropic({ apiKey: "added-by-the-proxy", baseURL: "https://desk.example.com/anthropic", dangerouslyAllowBrowser: true }),
});
Troubleshooting
ExpressHostAdapter is not available in browser environments
The page reports Failed to construct provider "…": ExpressHostAdapter is not available in browser environments as an uncaught error; the provider's name is minified. A class with @FrontMcp was imported into the page, and the decorator started the HTTP server, which the browser build replaces with a stand-in that throws. FrontMcpInstance.bootstrap() and createHandler() fail the same way. Add serve: false to the decorator, and serve the configuration with one of the entry points that never listen.
Invalid or unexpected token when the page loads
The bundle still has decorators in it, which browsers don't run: tsconfig.json doesn't set experimentalDecorators, so Vite left @Tool(…) and @App(…) as written. Set it, with emitDecoratorMetadata, as in Bundling the page with Vite.
Output validation failed. Please contact support. when reading a resource
Or Tool output validation failed from Vite's dev server, with code INVALID_OUTPUT, and in the console finalize: output validation failed {resource: …, errors: ReferenceError: Buffer is not defined. The browser build checks for Node's Buffer while it builds a resource's contents, before it looks at what execute() returned, and a page has none. A resource that returns a string or a plain object fails; one that returns { text }, { blob } or { contents: [...] } doesn't get that far, and works. To fix every resource, give the page a Buffer before the first read:
import { Buffer } from "buffer"; // npm install buffer
Object.assign(globalThis, { Buffer });Import ./buffer first in the page's entry. The Playgrounds on this site don't hit this: their worker already has a Buffer.
JWT_SECRET is required in production for auth.mode "local"
Or "remote". createFetchHandler(), create() or connect() rejects with JwtSecretRequiredError, in a page built for production. FrontMCP signs its tokens with JWT_SECRET, which it reads from the environment, and the browser build reads none. An OAuth server belongs on a server: run the server that signs users in elsewhere, and give the page's server transparent auth, which only checks tokens.
Failed to connect to redis, falling back to memory
The console goes on with Failed to connect to Redis: Failed to load ioredis: Dynamic require of "ioredis" is not supported, then [TransportService] Failed to connect to redis - session persistence disabled. The server keeps working, with everything in memory. A page can't open a TCP connection, so the redis option has no use there: remove it from the page's configuration.
Dynamic require of "@frontmcp/utils" is not supported
The configuration has sqlite, which needs Node's SQLite driver and the file system. Remove it from the page's configuration; keep what must survive a reload with createKeyPersistence(), or in IndexedDB yourself.
No distributed storage backend detected in production
The full line is [storage] Warning: No distributed storage backend detected in production. Using in-memory storage. Set REDIS_URL, UPSTASH_REDIS_REST_URL, or KV_REST_API_URL., once per server, in a production build. It's meant for servers with several instances, and a page has one: ignore it. The variables it names can't be set in a page.
It looks like you're running in a browser-like environment.
The message goes on with This is disabled by default, as it risks exposing your secret API credentials to attackers. AnthropicAdapter was given an apiKey, and the Anthropic client it made refuses to run in a browser. Make the client yourself with dangerouslyAllowBrowser: true and pass it as client, and send its requests through an endpoint of your own: see Calling a model from the page.
generateRsaKeyPair is only available in Node.js runtimes
Or the same for rsaSignBase64Url, pemToPublicJwk or rsaVerifySync. These @frontmcp/utils functions use Node's crypto and throw in the browser build. Verify RSA signatures with the async rsaVerify(), and make keys and signatures on a server.
File system operations is not supported in the browser. Requires Node.js.
Something asked for the file system: createKeyPersistence({ type: "filesystem" }), or a FileSystemStorageAdapter. Use "auto", "indexeddb" or "localstorage".
IndexedDBStorageAdapter or LocalStorageAdapter is undefined
Vite warns [IMPORT_IS_UNDEFINED]: the import "will always be undefined because there is no matching export in 'node_modules/@frontmcp/utils/esm/index.mjs'". @frontmcp/utils doesn't export them. Use createKeyPersistence() with type: "indexeddb" or "localstorage", which uses them for you.