Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/e2e/demo-e2e-browser-bundle/e2e/browser/bundle.pw.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,30 @@ test.describe('Browser bundle integration', () => {
expect(results['createDirect']).toEqual({ pass: true, value: 'ping' });
});

test('connects a client and starts ConfigPlugin in the browser', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-testid="summary"]');
const results = (await page.evaluate(
() => (window as unknown as Record<string, unknown>).__BUNDLE_RESULTS__,
)) as Record<string, { pass: boolean; value: string }>;

expect(results['connect']).toEqual({ pass: true, value: 'ping' });
expect(results['ConfigPlugin']).toEqual({ pass: true, value: '{"pageSize":20}' });
});

test('searches skills in the browser', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-testid="summary"]');
const results = (await page.evaluate(
() => (window as unknown as Record<string, unknown>).__BUNDLE_RESULTS__,
)) as Record<string, { pass: boolean; value: string }>;

expect(results['searchSkills']).toEqual({ pass: true, value: 'deploy-app' });
});

test('env utilities return browser defaults', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-testid="summary"]');
const results = (await page.evaluate(
() => (window as unknown as Record<string, unknown>).__BUNDLE_RESULTS__,
)) as Record<string, { pass: boolean; value: string }>;
Expand All @@ -52,6 +74,7 @@ test.describe('Browser bundle integration', () => {

test('crypto works in browser', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-testid="summary"]');
const results = (await page.evaluate(
() => (window as unknown as Record<string, unknown>).__BUNDLE_RESULTS__,
)) as Record<string, { pass: boolean; value: string }>;
Expand All @@ -64,6 +87,7 @@ test.describe('Browser bundle integration', () => {

test('decorators work in browser', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('[data-testid="summary"]');
const results = (await page.evaluate(
() => (window as unknown as Record<string, unknown>).__BUNDLE_RESULTS__,
)) as Record<string, { pass: boolean; value: string }>;
Expand Down
67 changes: 67 additions & 0 deletions apps/e2e/demo-e2e-browser-bundle/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,16 @@ import { z } from '@frontmcp/lazy-zod';
// ── @frontmcp/sdk imports (decorators, types, core) ──
import {
App,
ConfigPlugin,
ConfigService,
connect,
FrontMcp,
FrontMcpInstance,
Prompt,
PromptContext,
Resource,
ResourceContext,
skill,
Tool,
ToolContext,
} from '@frontmcp/sdk';
Expand Down Expand Up @@ -189,6 +193,69 @@ checkAsync('createDirect', async () => {
}
});

// ── connect() and ConfigPlugin in the browser (#761) ──
checkAsync('connect', async () => {
const client = await connect({ ...browserEntryConfig, info: { name: 'browser-connect', version: '0.0.1' } });
try {
const tools = (await client.listTools()) as Array<{ name: string }>;
return tools.map((tool) => tool.name).join(',');
} finally {
await client.close();
}
});

@Tool({ name: 'page_size', inputSchema: {} })
class BrowserPageSizeTool extends ToolContext {
async execute() {
return { pageSize: this.get(ConfigService).getNumber('pageSize') };
}
}

@App({
id: 'browser-config',
name: 'Browser config',
tools: [BrowserPageSizeTool],
plugins: [ConfigPlugin.init({ schema: z.object({ pageSize: z.coerce.number().default(20) }) })],
})
class BrowserConfigApp {}

checkAsync('ConfigPlugin', async () => {
const server = await FrontMcpInstance.createDirect({
info: { name: 'browser-config', version: '0.0.1' },
apps: [BrowserConfigApp],
});
try {
const result = await server.callTool('page_size', {});
return JSON.stringify(result.structuredContent);
} finally {
await server.dispose();
}
});

const deploySkill = skill({
name: 'deploy-app',
description: 'Deploy the app to cloudflare workers',
instructions: 'Run the deploy.',
});
const testSkill = skill({
name: 'write-tests',
description: 'Write unit tests with jest',
instructions: 'Write tests.',
});

@App({ id: 'browser-skills', name: 'Browser skills', skills: [deploySkill, testSkill] })
class BrowserSkillsApp {}

checkAsync('searchSkills', async () => {
const client = await connect({ info: { name: 'browser-skills', version: '0.0.1' }, apps: [BrowserSkillsApp] });
try {
const { skills } = await client.searchSkills('cloudflare deploy');
return skills.map((found) => found.id).join(',');
} finally {
await client.close();
}
});

// ── @frontmcp/adapters/skills checks ──
//
// The harvester + classifier + registry are the load-bearing units that
Expand Down
6 changes: 6 additions & 0 deletions docs/frontmcp/deployment/browser-compatibility.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ FrontMCP supports browser environments for client-side use cases like JWT verifi

Use the packages from npm in a browser bundler that honors the `browser` export condition — Vite 7 and 8, build and dev server — without Node polyfills, `process` defines or an `express` alias. A bundler that resolves the `browser` condition gets `@frontmcp/sdk`'s browser build for an `import` and for a CommonJS `require()` alike (bundlers through the `module` condition; a CommonJS runtime with the `browser` condition, such as `jest-environment-jsdom`, gets a CommonJS copy of it), in which the Express host and the legacy SSE transport are stubs that throw if constructed, and the browser variants of `@frontmcp/protocol` (no stdio transports) and `@frontmcp/utils` (runtime context, paths, crypto, env). Node, and bundlers targeting Node, keep resolving the Node builds.

## Entry Points and Config in the Browser

- **Entry points that never listen:** `create()`, `FrontMcpInstance.createDirect()`, `FrontMcpInstance.createFetchHandler()` and `connect()` (with `connectOpenAI()` and `connectClaude()`) never build the Express host, so they work in the browser build. Up to 1.9.1-rc.2, `connect()` built it and threw `ExpressHostAdapter is not available in browser environments`.
- **`ConfigPlugin` and `loadConfig()`:** there is no file system to read, so `.env`, `.env.local` and `config.yml` count as absent. The config comes from the schema defaults and from `process.env` where the page defines one. Up to 1.9.1-rc.2, startup threw `path.resolve() is not available in browser environments`.
- **Runtime value:** the browser build reports the runtime context `{ runtime: 'browser', platform: 'browser', os: 'browser' }`. Entries with `availableWhen: { runtime: 'node' }` are therefore unavailable in a browser, even where a `process` polyfill makes the page look like Node.

## Request Context in the Browser

Every request runs in its own `FrontMcpContext` (auth info, session, request-scoped providers), and tools see the tool they run as and the surface they were called on. On Node and Workers, `AsyncLocalStorage` keeps these apart for overlapping requests. A browser has no `AsyncLocalStorage`, so the browser build of `@frontmcp/utils` picks one of two modes (`getAsyncContextMode()`):
Expand Down
4 changes: 4 additions & 0 deletions docs/frontmcp/extensibility/config-yaml.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,10 @@ console.log(config.database.url);

When `false` (default), only environment variables and `.env` files are loaded. Set to `true` to also load YAML configuration.

<Note>
In the browser build there is no file system, so the YAML file and the `.env` files count as absent. The config comes from the schema defaults and from `process.env` where the page defines one.
</Note>

### configPath

| Property | Type | Default | Description |
Expand Down
9 changes: 5 additions & 4 deletions docs/frontmcp/servers/skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -701,10 +701,11 @@ Results are ranked by:
2. Priority field (higher = earlier)
3. Tool availability (complete skills rank higher)

The index is built by the optional peer `vectoriadb`, which is loaded the first time a search runs —
not when the server starts. Listing and loading skills (`skills/list`, `skills/load`, the `skill://`
resources) never need it, so a server whose skills are never searched runs without it installed;
the first search on such a server fails with an error that names the package to install.
The index is built with `vectoriadb`, a dependency of `@frontmcp/sdk`. Under the `browser` and `worker`
export conditions it resolves to its TF-IDF/BM25 entry, which has no Node built-ins, so skill search works
the same in Node, on edge runtimes and in the browser build. The index is created the first time a search runs,
not when the server starts. Up to 1.9.1-rc.2 `vectoriadb` was an optional peer that a browser bundle
could not resolve unless it was installed.

---

Expand Down
3 changes: 1 addition & 2 deletions docs/frontmcp/testing/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ pnpm, which installs into a symlinked store at

A FrontMCP server started inside a spec — `FrontMcpInstance.createHandler()`, `createFetchHandler()`,
`createDirect()`, `create()` / `connect()` — runs under this config as is, with no
`--experimental-vm-modules`: the SDK falls back to `require` where Jest's VM refuses a dynamic import, and
loads the skill search index (`vectoriadb`) only when a skill search runs.
`--experimental-vm-modules`: the SDK falls back to `require` where Jest's VM refuses a dynamic import.

If one of _your_ dependencies ships ESM-only, add it to `test.esmPackages` in
`frontmcp.config.ts` rather than hand-writing a whole Jest config:
Expand Down
9 changes: 3 additions & 6 deletions libs/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,7 @@
"cors": "^2.8.5",
"express": "^4.18.0 || ^5.0.0",
"openai": "^4.0.0 || ^5.0.0 || ^6.0.0",
"raw-body": "^3.0.0",
"vectoriadb": "^2.3.0"
"raw-body": "^3.0.0"
},
"peerDependenciesMeta": {
"@anthropic-ai/sdk": {
Expand All @@ -136,9 +135,6 @@
},
"openai": {
"optional": true
},
"vectoriadb": {
"optional": true
}
},
"dependencies": {
Expand All @@ -154,7 +150,8 @@
"jose": "^6.1.3",
"js-yaml": "^4.1.1",
"reflect-metadata": "^0.2.2",
"semver": "^7.6.0"
"semver": "^7.6.0",
"vectoriadb": "^2.3.3"
},
"devDependencies": {
"typescript": "^5.9.3"
Expand Down
13 changes: 3 additions & 10 deletions libs/sdk/src/builtin/config/config.plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ConfigPluginConfigToken } from './config.symbols';
import type { ConfigPluginOptions, ConfigPluginOptionsInput } from './config.types';
import { loadConfig } from './providers/config-loader';
import { ConfigService } from './providers/config.service';
import { loadEnvFiles, populateProcessEnv } from './providers/env-loader';
import { loadEnvFiles, populateProcessEnv, processEnvEntries } from './providers/env-loader';

/**
* ConfigPlugin - Environment variable management for FrontMCP.
Expand Down Expand Up @@ -146,15 +146,8 @@ export default class ConfigPlugin<TConfig extends object = Record<string, string
env = await loadEnvFiles(basePath, pluginConfig.envPath, pluginConfig.localEnvPath);
}

// Merge with existing process.env
const merged = { ...env };
if (typeof process !== 'undefined' && process.env) {
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && merged[key] === undefined) {
merged[key] = value;
}
}
}
// Merge with existing process.env (the env files win here)
const merged = { ...processEnvEntries(), ...env };

// Populate process.env if enabled
if (pluginConfig.populateProcessEnv) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* ConfigPlugin in the browser build (#761): `@frontmcp/utils` resolves `pathResolve()` to a stub that
* throws and reports the runtime as `browser`, so the file sources are absent and the config comes
* from the schema defaults and `process.env`.
*/
import { z } from '@frontmcp/lazy-zod';

import { loadConfig } from '../config-loader';
import { loadEnvFiles } from '../env-loader';

const readFile = jest.fn();

jest.mock('@frontmcp/utils', () => ({
...jest.requireActual('@frontmcp/utils'),
getRuntimeContext: () => ({ runtime: 'browser', platform: 'browser', os: 'browser' }),
pathResolve: () => {
throw new Error('path.resolve() is not available in browser environments');
},
readFile: (...args: unknown[]) => readFile(...args),
}));

const schema = z.object({ pageSize: z.coerce.number().default(20), theme: z.string().default('light') });

describe('loadConfig in the browser build', () => {
afterEach(() => {
delete process.env['PAGESIZE'];
readFile.mockClear();
});

it('starts from the schema defaults without reading .env files', async () => {
await expect(loadConfig(schema)).resolves.toEqual({ pageSize: 20, theme: 'light' });
expect(readFile).not.toHaveBeenCalled();
});

it('skips the YAML file too', async () => {
await expect(loadConfig(schema, { loadYaml: true })).resolves.toEqual({ pageSize: 20, theme: 'light' });
expect(readFile).not.toHaveBeenCalled();
});

it('still reads process.env where there is one', async () => {
process.env['PAGESIZE'] = '50';

await expect(loadConfig(schema)).resolves.toEqual({ pageSize: 50, theme: 'light' });
});
});

describe('loadEnvFiles in the browser build', () => {
it('finds no .env files', async () => {
await expect(loadEnvFiles()).resolves.toEqual({});
});
});
17 changes: 10 additions & 7 deletions libs/sdk/src/builtin/config/providers/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import { type z } from '@frontmcp/lazy-zod';
import { fileExists, getCwd, pathResolve, readFile } from '@frontmcp/utils';

import { ConfigValidationError } from './config.service';
import { extractSchemaPaths, loadEnvFiles, mapEnvToNestedConfig, populateProcessEnv } from './env-loader';
import {
canReadConfigFiles,
extractSchemaPaths,
loadEnvFiles,
mapEnvToNestedConfig,
populateProcessEnv,
processEnvEntries,
} from './env-loader';

/**
* Options for the config loader.
Expand Down Expand Up @@ -68,12 +75,7 @@ export async function loadConfig<T extends object>(
}

// Merge with process.env (process.env takes precedence)
const allEnv = { ...envFromFiles };
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) {
allEnv[key] = value;
}
}
const allEnv = { ...envFromFiles, ...processEnvEntries() };

// Map flat env vars to nested structure using schema paths
const paths = extractSchemaPaths(schema);
Expand All @@ -94,6 +96,7 @@ export async function loadConfig<T extends object>(
* Load and parse a YAML config file.
*/
async function loadYamlConfig(basePath: string, configPath: string): Promise<Record<string, unknown>> {
if (!canReadConfigFiles()) return {};
// Try multiple extensions
const extensions = ['', '.yml', '.yaml'];
const baseName = configPath.replace(/\.(ya?ml)$/, '');
Expand Down
22 changes: 20 additions & 2 deletions libs/sdk/src/builtin/config/providers/env-loader.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type z } from '@frontmcp/lazy-zod';
import { fileExists, getCwd, getEnv, pathResolve, readFile, setEnv } from '@frontmcp/utils';
import { fileExists, getCwd, getEnv, getRuntimeContext, pathResolve, readFile, setEnv } from '@frontmcp/utils';

/**
* Parse a .env file content into key-value pairs.
Expand Down Expand Up @@ -58,21 +58,39 @@ export function parseEnvContent(content: string): Record<string, string> {
return result;
}

/** False in the browser build, which has no file system to read config files from. */
export function canReadConfigFiles(): boolean {
return getRuntimeContext().runtime !== 'browser';
}

/**
* Environment variables of the running process; empty where there is no `process`, as in a browser.
*/
export function processEnvEntries(): Record<string, string> {
const env: Record<string, string> = {};
const processEnv = typeof process === 'undefined' ? undefined : process.env;
for (const [key, value] of Object.entries(processEnv ?? {})) {
if (value !== undefined) env[key] = value;
}
return env;
}

/**
* Load environment variables from .env files.
* Follows NestJS-style priority: .env.local overrides .env
*
* @param basePath - Base directory to resolve files from
* @param envPath - Path to base .env file (relative to basePath)
* @param localEnvPath - Path to local override file (relative to basePath)
* @returns Record of merged environment variables
* @returns Record of merged environment variables, empty where config files cannot be read
*/
export async function loadEnvFiles(
basePath = getCwd(),
envPath = '.env',
localEnvPath = '.env.local',
): Promise<Record<string, string>> {
const result: Record<string, string> = {};
if (!canReadConfigFiles()) return result;

// Load base .env file
const envFile = pathResolve(basePath, envPath);
Expand Down
Loading
Loading