# Browser

> Running a FrontMCP server inside a web page or a Web Worker: the SDK's browser build, the entry points that never listen, what works there and what needs Node, keeping keys in IndexedDB or localStorage, and calling a model from the page.

Source: https://frontmcp.dev/reference/deployment/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()`](https://frontmcp.dev/reference/sdk/create), through a [`connect()`](https://frontmcp.dev/reference/sdk/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`](https://frontmcp.dev/reference/react) and [WebMCP](https://frontmcp.dev/reference/plugins/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.

```ts
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](https://frontmcp.dev/reference/react#running-in-a-browser) has the Vite details. Two things are up to you:

- **Decorators.** Set `experimentalDecorators` in `tsconfig.json`, as in any FrontMCP project. Without it, Vite 8 leaves `@Tool(…)` in the bundle as written, and the page stops as it loads, with [`Invalid or unexpected token`](#invalid-or-unexpected-token-when-the-page-loads). Or write tools as functions with [`tool()`](https://frontmcp.dev/reference/sdk/tool#writing-a-tool-as-a-function), which needs no decorators.
- **A `Buffer` for resources.** The browser build reads Node's `Buffer` global 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](#output-validation-failed-please-contact-support-when-reading-a-resource).

`frontmcp build --target browser` writes your server's code as one ES module for your web app's bundler to take in ([Targets](https://frontmcp.dev/reference/deployment/production-build#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)`](https://frontmcp.dev/reference/sdk/create) | Works. Page code calls `server.callTool()`, `readResource()` and `getPrompt()` as in Node. |
| [`FrontMcpInstance.createDirect(config)`](https://frontmcp.dev/reference/sdk/create#frontmcpinstancecreatedirectconfig) | Works. |
| [`connect(config)`](https://frontmcp.dev/reference/sdk/connect) and the adapters, like `connectOpenAI()` | Work: an MCP client in the page. |
| [`FrontMcpInstance.createFetchHandler(config)`](https://frontmcp.dev/reference/sdk/create-fetch-handler) | 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`](#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()`](https://frontmcp.dev/reference/sdk/register-tool) on the server `create()` returned.

### What runs, and what needs Node

| | In a browser |
| --- | --- |
| Tools, prompts, apps, plugins | Run as on a server. [`ConfigPlugin`](https://frontmcp.dev/reference/server/config-files#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](#model-adapters-in-a-page). |
| 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`](#jwt_secret-is-required-in-production-for-authmode-local). 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`](#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`](#dynamic-require-of-frontmcputils-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](https://frontmcp.dev/reference/sdk/create-fetch-handler#in-a-browser). |
| [`App.esm()`](https://frontmcp.dev/reference/server/esm) | Keeps the bundles it downloads in memory only: see [Caching](https://frontmcp.dev/reference/server/esm#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](#node_env-in-a-page)).

#### 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()`](#createkeypersistenceoptions) | Keeps keys in IndexedDB or localStorage. |

### The runtime context

In the browser build, [`this.runtimeContext`](https://frontmcp.dev/reference/server/environment#thisruntimecontext-and-the-checks), 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](https://frontmcp.dev/reference/server/environment#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](https://frontmcp.dev/reference/server/environment#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 a `PublicMcpError`. [Usage below](#seeing-what-a-built-page-tells-clients).
- `local` and `remote` auth refuse to start, because they need `JWT_SECRET`.
- Each server logs [`No distributed storage backend detected in production`](#no-distributed-storage-backend-detected-in-production) to 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`:

```ts keys.ts
import { createKeyPersistence } from "@frontmcp/utils";

const keys = await createKeyPersistence();
const { secret } = await keys.getOrCreateSecret("desk-drafts"); // 32 random bytes, base64url; the same next visit
```

[See an example below.](#keeping-a-key-between-visits)

#### Options

| 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](https://frontmcp.dev/reference/sdk/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.`](#it-looks-like-youre-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](https://frontmcp.dev/reference/sdk/agent#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](#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 build` finishes the same with `redis`, `sqlite` or `local` auth configured; they fail when the page runs, as the table above says.
- **The examples on this page that aren't Playgrounds** ran with `@frontmcp/sdk` and `@frontmcp/utils` 1.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"`:

```ts count-open-tickets.tool.ts active
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 };
  }
}
```

```ts server.ts
import { App } from "@frontmcp/sdk";
import { CountOpenTickets } from "./count-open-tickets.tool";

@App({ id: "help-desk", name: "Help Desk", tools: [CountOpenTickets] })
export class HelpDeskApp {}

// What @FrontMcp would take. The page passes it to connect() or createFetchHandler() instead.
export const config = { info: { name: "help-desk", version: "1.0.0" }, apps: [HelpDeskApp] };
```

```ts page.test.ts
import { test, expect } from "@frontmcp/testing";
import { connect, create, FrontMcpInstance } from "@frontmcp/sdk";
import { CountOpenTickets } from "./count-open-tickets.tool";
import { config } from "./server";

// "browser" when this runs in your browser, "node" in Node
const runtime = typeof WorkerGlobalScope !== "undefined" ? "browser" : "node";

test("create(): page code calls the tools directly", async () => {
  const server = await create({ info: { name: "help-desk", version: "1.0.0" }, tools: [CountOpenTickets] });
  try {
    const result = await server.callTool("count_open_tickets", {});
    expect(result.structuredContent).toEqual({ open: 3, runtime });
  } finally {
    await server.dispose();
  }
});

test("connect(): an MCP client in the page", async () => {
  const client = await connect(config);
  try {
    const tools = await client.listTools();
    expect(tools.map((tool: { name: string }) => tool.name)).toEqual(["count_open_tickets"]);
  } finally {
    await client.close();
  }
});

test("createFetchHandler(): answers requests the page builds", async () => {
  const handler = await FrontMcpInstance.createFetchHandler(config);

  const health = await handler(new Request("https://desk.example.com/healthz"));
  expect(await health.json()).toMatchObject({ status: "ok", transport: "web-fetch" });

  const call = await handler(
    new Request("https://desk.example.com/", {
      method: "POST",
      headers: { "content-type": "application/json", "mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "count_open_tickets" },
      body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "count_open_tickets", arguments: {}, _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" } } }),
    }),
  );
  expect((await call.json()).result.structuredContent).toEqual({ open: 3, runtime });
});
```

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()`](https://frontmcp.dev/reference/sdk/connect#the-adapters), 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:

```ts src/main.ts
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);
```

```json tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}
```

```bash
npm install @frontmcp/sdk
npm install -D vite typescript
npx vite build
```

There'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:

```ts close-ticket.tool.ts active
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");
  }
}
```

```ts production.test.ts
import { test, expect } from "@frontmcp/testing";

async function withNodeEnv<T>(value: string, run: () => Promise<T>) {
  const before = process.env.NODE_ENV;
  process.env.NODE_ENV = value;
  try {
    return await run();
  } finally {
    if (before === undefined) delete process.env.NODE_ENV;
    else process.env.NODE_ENV = before;
  }
}

test("in development, as from Vite's dev server, the client sees the message", async ({ mcp }) => {
  const result = await withNodeEnv("development", () => mcp.tools.call("close_ticket", { id: "T-1" }));
  expect(result).toBeError("TOOL_EXECUTION_ERROR");
  expect(result.text()).toContain("The ticket store at 10.0.4.7:5432 refused the connection");
});

test("in production, as after vite build, only an error id", async ({ mcp }) => {
  const result = await withNodeEnv("production", () => mcp.tools.call("close_ticket", { id: "T-1" }));
  expect(result).toBeError("TOOL_EXECUTION_ERROR");
  expect(result.text()).toMatch(/^Internal FrontMCP error\. Please contact support with error ID: err_\w+$/);
});

test("a PublicMcpError keeps its message in production", async ({ mcp }) => {
  const result = await withNodeEnv("production", () => mcp.tools.call("close_ticket", { id: "42" }));
  expect(result).toBeError("TICKET_NOT_FOUND");
  expect(result.text()).toBe("There's no ticket 42. Ticket ids look like T-1.");
});
```

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"](https://frontmcp.dev/reference/sdk/tool#the-client-sees-internal-frontmcp-error-instead-of-my-message).

### 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:

```ts drafts.ts
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.

> **Pitfall: Script on your page can read every key it keeps**
IndexedDB holds the key in plain JSON. localStorage holds it encrypted, but the salt is stored next to it and the rest of the derivation is fixed, so any script running on your origin can derive the same key. Both keep a key out of plain sight in the browser's storage, not away from code on the page. Don't keep anything there that a script injected into the page mustn't have.

### 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:

```ts triage.agent.ts
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:

```ts
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](#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](#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:

```ts src/buffer.ts
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`](https://frontmcp.dev/reference/auth/modes) 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()`](#createkeypersistenceoptions), 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](#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()`](#createkeypersistenceoptions) with `type: "indexeddb"` or `"localstorage"`, which uses them for you.
