From 1bdd74f35d38675d7e7ff3ee3736561a3f441e9a Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 5 Oct 2026 16:55:55 +0300 Subject: [PATCH 1/4] fix(sdk): build connect() clients without an HTTP host connect() (and connectOpenAI() / connectClaude()) builds its server through FrontMcpInstance.createForGraph(), which still selected the Express-backed server provider. #748 made createDirect() and createFetchHandler() pass serve: false; createForGraph() did not, so in the browser build connect() failed with "ExpressHostAdapter is not available in browser environments" (#761). It never listens, so it now passes serve: false too. createEdgeMcp() already passed it. --- .../no-listen-entry-points.browser-host.spec.ts | 11 +++++++++++ libs/sdk/src/front-mcp/front-mcp.ts | 4 ++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/libs/sdk/src/front-mcp/__tests__/no-listen-entry-points.browser-host.spec.ts b/libs/sdk/src/front-mcp/__tests__/no-listen-entry-points.browser-host.spec.ts index 5984824d5..86adadce2 100644 --- a/libs/sdk/src/front-mcp/__tests__/no-listen-entry-points.browser-host.spec.ts +++ b/libs/sdk/src/front-mcp/__tests__/no-listen-entry-points.browser-host.spec.ts @@ -1,6 +1,7 @@ import 'reflect-metadata'; import { App, Tool, ToolContext } from '../../common'; +import { connect } from '../../direct'; import { FrontMcpInstance } from '../front-mcp'; // The browser and worker builds resolve `#express-host` to this stub, whose constructor throws (#747). @@ -36,4 +37,14 @@ describe('entry points that never listen, with the browser build of the Express await server.dispose(); } }); + + it('connect returns a client that lists its tools', async () => { + const client = await connect({ ...config, info: { name: 'browser-host-connect', version: '1.0.0' } }); + try { + const tools = (await client.listTools()) as Array<{ name: string }>; + expect(tools.map((tool) => tool.name)).toEqual(['ping']); + } finally { + await client.close(); + } + }); }); diff --git a/libs/sdk/src/front-mcp/front-mcp.ts b/libs/sdk/src/front-mcp/front-mcp.ts index 9dc212fa6..a31a05aaf 100644 --- a/libs/sdk/src/front-mcp/front-mcp.ts +++ b/libs/sdk/src/front-mcp/front-mcp.ts @@ -399,8 +399,8 @@ export class FrontMcpInstance implements FrontMcpInterface { * const scopes = instance.getScopes(); */ public static async createForGraph(options: FrontMcpConfigInput): Promise { - // Parse config through Zod to apply defaults (providers, tools, etc.) - const parsedConfig = frontMcpMetadataSchema.parse(options); + // Never listens, so no HTTP host: connect() builds on this, and a browser build has no Express + const parsedConfig = frontMcpMetadataSchema.parse({ ...options, serve: false }); const frontMcp = new FrontMcpInstance(parsedConfig); await frontMcp.ready; return frontMcp; From 510c55bb9bf07c8d1697874a3be83edde7775783 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 5 Oct 2026 16:55:58 +0300 Subject: [PATCH 2/4] fix(sdk): start ConfigPlugin in the browser build loadConfig() and loadEnvFiles() resolved .env, .env.local and config.yml with pathResolve() from @frontmcp/utils, whose browser build throws, so every ConfigPlugin failed at startup in a browser (#761). Where the runtime context is the browser build, the file sources now count as absent, and process.env is read only where it exists, so the config comes from the schema defaults and process.env. The browser-bundle e2e app runs connect() and a ConfigPlugin server in Chromium. Its other result checks now wait for the page summary, as the first ones do, instead of reading the results before the async checks finish. The browser compatibility, config and build-for-browser pages describe both entry points and the 'browser' runtime value. --- .../e2e/browser/bundle.pw.spec.ts | 14 +++++ apps/e2e/demo-e2e-browser-bundle/src/main.ts | 42 +++++++++++++++ .../deployment/browser-compatibility.mdx | 6 +++ docs/frontmcp/extensibility/config-yaml.mdx | 4 ++ libs/sdk/src/builtin/config/config.plugin.ts | 13 ++--- .../__tests__/config-loader.browser.spec.ts | 51 +++++++++++++++++++ .../builtin/config/providers/config-loader.ts | 17 ++++--- .../builtin/config/providers/env-loader.ts | 22 +++++++- .../references/build-for-browser.md | 21 ++++---- 9 files changed, 161 insertions(+), 29 deletions(-) create mode 100644 libs/sdk/src/builtin/config/providers/__tests__/config-loader.browser.spec.ts diff --git a/apps/e2e/demo-e2e-browser-bundle/e2e/browser/bundle.pw.spec.ts b/apps/e2e/demo-e2e-browser-bundle/e2e/browser/bundle.pw.spec.ts index 2ab7909d1..3483c0cb9 100644 --- a/apps/e2e/demo-e2e-browser-bundle/e2e/browser/bundle.pw.spec.ts +++ b/apps/e2e/demo-e2e-browser-bundle/e2e/browser/bundle.pw.spec.ts @@ -37,8 +37,20 @@ 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).__BUNDLE_RESULTS__, + )) as Record; + + expect(results['connect']).toEqual({ pass: true, value: 'ping' }); + expect(results['ConfigPlugin']).toEqual({ pass: true, value: '{"pageSize":20}' }); + }); + 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).__BUNDLE_RESULTS__, )) as Record; @@ -52,6 +64,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).__BUNDLE_RESULTS__, )) as Record; @@ -64,6 +77,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).__BUNDLE_RESULTS__, )) as Record; diff --git a/apps/e2e/demo-e2e-browser-bundle/src/main.ts b/apps/e2e/demo-e2e-browser-bundle/src/main.ts index cc14419d7..65ea648e4 100644 --- a/apps/e2e/demo-e2e-browser-bundle/src/main.ts +++ b/apps/e2e/demo-e2e-browser-bundle/src/main.ts @@ -22,6 +22,9 @@ import { z } from '@frontmcp/lazy-zod'; // ── @frontmcp/sdk imports (decorators, types, core) ── import { App, + ConfigPlugin, + ConfigService, + connect, FrontMcp, FrontMcpInstance, Prompt, @@ -189,6 +192,45 @@ 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(); + } +}); + // ── @frontmcp/adapters/skills checks ── // // The harvester + classifier + registry are the load-bearing units that diff --git a/docs/frontmcp/deployment/browser-compatibility.mdx b/docs/frontmcp/deployment/browser-compatibility.mdx index d75ab59b0..f2bbbaacc 100644 --- a/docs/frontmcp/deployment/browser-compatibility.mdx +++ b/docs/frontmcp/deployment/browser-compatibility.mdx @@ -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()`): diff --git a/docs/frontmcp/extensibility/config-yaml.mdx b/docs/frontmcp/extensibility/config-yaml.mdx index a3ffe60e9..a75056cfa 100644 --- a/docs/frontmcp/extensibility/config-yaml.mdx +++ b/docs/frontmcp/extensibility/config-yaml.mdx @@ -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. + +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. + + ### configPath | Property | Type | Default | Description | diff --git a/libs/sdk/src/builtin/config/config.plugin.ts b/libs/sdk/src/builtin/config/config.plugin.ts index 295184b80..57e6400b1 100644 --- a/libs/sdk/src/builtin/config/config.plugin.ts +++ b/libs/sdk/src/builtin/config/config.plugin.ts @@ -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. @@ -146,15 +146,8 @@ export default class ConfigPlugin ({ + ...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({}); + }); +}); diff --git a/libs/sdk/src/builtin/config/providers/config-loader.ts b/libs/sdk/src/builtin/config/providers/config-loader.ts index 79b9fd044..a0e97249e 100644 --- a/libs/sdk/src/builtin/config/providers/config-loader.ts +++ b/libs/sdk/src/builtin/config/providers/config-loader.ts @@ -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. @@ -68,12 +75,7 @@ export async function loadConfig( } // 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); @@ -94,6 +96,7 @@ export async function loadConfig( * Load and parse a YAML config file. */ async function loadYamlConfig(basePath: string, configPath: string): Promise> { + if (!canReadConfigFiles()) return {}; // Try multiple extensions const extensions = ['', '.yml', '.yaml']; const baseName = configPath.replace(/\.(ya?ml)$/, ''); diff --git a/libs/sdk/src/builtin/config/providers/env-loader.ts b/libs/sdk/src/builtin/config/providers/env-loader.ts index 305b985ce..8dbc89e43 100644 --- a/libs/sdk/src/builtin/config/providers/env-loader.ts +++ b/libs/sdk/src/builtin/config/providers/env-loader.ts @@ -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. @@ -58,6 +58,23 @@ export function parseEnvContent(content: string): Record { 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 { + const env: Record = {}; + 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 @@ -65,7 +82,7 @@ export function parseEnvContent(content: string): Record { * @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(), @@ -73,6 +90,7 @@ export async function loadEnvFiles( localEnvPath = '.env.local', ): Promise> { const result: Record = {}; + if (!canReadConfigFiles()) return result; // Load base .env file const envFile = pathResolve(basePath, envPath); diff --git a/libs/skills/catalog/frontmcp-deployment/references/build-for-browser.md b/libs/skills/catalog/frontmcp-deployment/references/build-for-browser.md index f5b0290c6..bf7c6211c 100644 --- a/libs/skills/catalog/frontmcp-deployment/references/build-for-browser.md +++ b/libs/skills/catalog/frontmcp-deployment/references/build-for-browser.md @@ -46,16 +46,17 @@ frontmcp build --target browser -e ./src/client.ts # Custom entry file Not all FrontMCP features are available in browser environments: -| Feature | Browser Support | Notes | -| --------------------------- | --------------- | ----------------------------------------- | -| Tools (client-side) | Yes | Can define and run tools | -| Resources | Yes | Read-only access | -| Prompts | Yes | Full support | -| Redis | No | Use in-memory or connect to server | -| SQLite | No | No filesystem access | -| File system utilities | No | `@frontmcp/utils` fs ops throw in browser | -| Crypto (`@frontmcp/utils`) | Yes | Uses WebCrypto API | -| Direct client (`connect()`) | Yes | In-memory connection | +| Feature | Browser Support | Notes | +| --------------------------- | --------------- | ---------------------------------------------------------------------- | +| Tools (client-side) | Yes | Can define and run tools | +| Resources | Yes | Read-only access | +| Prompts | Yes | Full support | +| Redis | No | Use in-memory or connect to server | +| SQLite | No | No filesystem access | +| File system utilities | No | `@frontmcp/utils` fs ops throw in browser | +| Crypto (`@frontmcp/utils`) | Yes | Uses WebCrypto API | +| Direct client (`connect()`) | Yes | In-memory connection | +| `ConfigPlugin` | Yes | No `.env` / `config.yml` files: schema defaults and `process.env` only | ### Request context in the browser From 34368f3e20ef5ff70ed908b2ab95ebc2de34d6d7 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 5 Oct 2026 17:28:15 +0300 Subject: [PATCH 3/4] fix(sdk): make vectoriadb a dependency and bundle skill search in the browser vectoriadb was an optional peer that the SDK loaded with a literal import('vectoriadb') on the first skill search. A browser bundle of the SDK therefore failed with 'Could not resolve "vectoriadb"' unless the consumer installed it (#761), and installing it did not help: in 2.3.2 its worker entry imported Node's crypto and an undeclared tslib, fixed in vectoriadb 2.3.3 (agentfront/vectoriadb#17). vectoriadb (^2.3.3) is now a regular dependency. The SDK imports its worker-safe entry, vectoriadb/worker: the TF-IDF/BM25 index the skill search uses, with no dependencies beyond tslib and no Node built-ins. The index is still created on the first search; the optional-peer loading and the spec that pinned it are gone. The browser-bundle e2e app runs a skill search in Chromium, and the skills, testing and create-skill pages no longer call vectoriadb an optional peer. --- .../e2e/browser/bundle.pw.spec.ts | 10 ++ apps/e2e/demo-e2e-browser-bundle/src/main.ts | 25 ++++ docs/frontmcp/servers/skills.mdx | 9 +- docs/frontmcp/testing/overview.mdx | 3 +- libs/sdk/package.json | 9 +- ...emory-skill.provider.optional-peer.spec.ts | 114 ------------------ .../skill/providers/memory-skill.provider.ts | 54 ++------- .../references/create-skill.md | 2 +- libs/skills/catalog/frontmcp-testing/SKILL.md | 2 +- package.json | 2 +- yarn.lock | 20 ++- 11 files changed, 73 insertions(+), 177 deletions(-) delete mode 100644 libs/sdk/src/skill/__tests__/memory-skill.provider.optional-peer.spec.ts diff --git a/apps/e2e/demo-e2e-browser-bundle/e2e/browser/bundle.pw.spec.ts b/apps/e2e/demo-e2e-browser-bundle/e2e/browser/bundle.pw.spec.ts index 3483c0cb9..b650e1f05 100644 --- a/apps/e2e/demo-e2e-browser-bundle/e2e/browser/bundle.pw.spec.ts +++ b/apps/e2e/demo-e2e-browser-bundle/e2e/browser/bundle.pw.spec.ts @@ -48,6 +48,16 @@ test.describe('Browser bundle integration', () => { 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).__BUNDLE_RESULTS__, + )) as Record; + + 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"]'); diff --git a/apps/e2e/demo-e2e-browser-bundle/src/main.ts b/apps/e2e/demo-e2e-browser-bundle/src/main.ts index 65ea648e4..d85863abc 100644 --- a/apps/e2e/demo-e2e-browser-bundle/src/main.ts +++ b/apps/e2e/demo-e2e-browser-bundle/src/main.ts @@ -31,6 +31,7 @@ import { PromptContext, Resource, ResourceContext, + skill, Tool, ToolContext, } from '@frontmcp/sdk'; @@ -231,6 +232,30 @@ checkAsync('ConfigPlugin', async () => { } }); +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 diff --git a/docs/frontmcp/servers/skills.mdx b/docs/frontmcp/servers/skills.mdx index e6dbd85dc..e639ccd5a 100644 --- a/docs/frontmcp/servers/skills.mdx +++ b/docs/frontmcp/servers/skills.mdx @@ -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`. The SDK uses its worker-safe +TF-IDF/BM25 entry, which has no dependencies and 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. --- diff --git a/docs/frontmcp/testing/overview.mdx b/docs/frontmcp/testing/overview.mdx index 6962664b2..a3ecd6073 100644 --- a/docs/frontmcp/testing/overview.mdx +++ b/docs/frontmcp/testing/overview.mdx @@ -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: diff --git a/libs/sdk/package.json b/libs/sdk/package.json index c132cee2b..1248adcdc 100644 --- a/libs/sdk/package.json +++ b/libs/sdk/package.json @@ -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": { @@ -136,9 +135,6 @@ }, "openai": { "optional": true - }, - "vectoriadb": { - "optional": true } }, "dependencies": { @@ -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" diff --git a/libs/sdk/src/skill/__tests__/memory-skill.provider.optional-peer.spec.ts b/libs/sdk/src/skill/__tests__/memory-skill.provider.optional-peer.spec.ts deleted file mode 100644 index 24957845c..000000000 --- a/libs/sdk/src/skill/__tests__/memory-skill.provider.optional-peer.spec.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { type SkillContent } from '../../common/interfaces'; -import { MemorySkillProvider } from '../providers/memory-skill.provider'; - -/** - * Regression guard: `vectoriadb` is an OPTIONAL peer of the SDK and MUST be - * loaded lazily (issue 05 — "SDK eagerly imports its optional peer vectoriadb"). - * - * The original bug: `memory-skill.provider.ts` had a top-level value import - * (`import { TFIDFVectoria } from 'vectoriadb'`) that sat in the SDK's main - * entry. Because the peer is declared `optional` (npm does not auto-install it), - * every *standalone* consumer that never touched skills crashed at boot with: - * - * Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'vectoriadb' - * imported from .../node_modules/@frontmcp/sdk/esm/index.mjs - * - * The contract this file pins down: - * 1. Loading the provider module never requires the peer (no boot crash). - * 2. Constructing and initializing the provider, and registering, listing and - * loading skills, never load the peer — only a search does (issue #680: - * every app builds a skill registry at boot, so loading it there made - * every server depend on it). - * 3. The first *search* surfaces a clear, actionable install hint — not an - * opaque module-resolution error. - * 4. Teardown (`clear`/`dispose`) stays resilient when the peer is absent. - * - * We simulate "peer not installed" by mocking `vectoriadb` to throw on load, - * which is exactly what the runtime does when an optional peer is missing. - */ - -// Hoisted by @swc/jest above the imports below — every test in this file runs -// as if `vectoriadb` were not installed (the factory throws on require()). -jest.mock('vectoriadb', () => { - throw new Error("Cannot find package 'vectoriadb' (ERR_MODULE_NOT_FOUND)"); -}); - -const createTestSkill = (): SkillContent => ({ - id: 'guard-skill', - name: 'Guard Skill', - description: 'A skill used to exercise the optional-peer guard', - instructions: 'Step 1: do nothing', - tools: [{ name: 'tool1' }], -}); - -// The on-use error must name the optional peer so the failure is actionable. -// Exact per-branch wording (not-installed vs failed-to-load) is covered by the -// importOptionalPeer tests in optional-dependency.util.spec.ts. -const PEER_ERROR = /vectoriadb/; - -describe('MemorySkillProvider — optional peer `vectoriadb` (issue 05)', () => { - it('does not require the optional peer at module-evaluation time (no boot crash)', () => { - // If the provider regressed to a top-level value import of `vectoriadb`, - // evaluating the module under the throwing mock would throw here — which is - // precisely the ERR_MODULE_NOT_FOUND that crashed standalone installs. - expect(() => { - jest.isolateModules(() => { - require('../providers/memory-skill.provider'); - }); - }).not.toThrow(); - }); - - it('constructs without throwing even when the peer is unavailable', () => { - expect(() => new MemorySkillProvider()).not.toThrow(); - }); - - it('constructed-but-unused provider does not raise an unhandled rejection', async () => { - const unhandled: unknown[] = []; - const onUnhandled = (reason: unknown): void => { - unhandled.push(reason); - }; - process.on('unhandledRejection', onUnhandled); - try { - new MemorySkillProvider(); - await new Promise((resolve) => setTimeout(resolve, 0)); - } finally { - process.off('unhandledRejection', onUnhandled); - } - expect(unhandled).toEqual([]); - }); - - it('initialize() does not load the peer: a server whose skills are never searched boots without it', async () => { - const provider = new MemorySkillProvider(); - await expect(provider.initialize()).resolves.toBeUndefined(); - }); - - it('add(), list(), load() and count() work without the peer', async () => { - const provider = new MemorySkillProvider(); - await provider.initialize(); - await provider.add(createTestSkill()); - expect((await provider.list()).skills.map((s) => s.name)).toEqual(['Guard Skill']); - expect((await provider.load('guard-skill'))?.skill.name).toBe('Guard Skill'); - expect(await provider.count()).toBe(1); - }); - - it('search() surfaces the same clear install hint on first use', async () => { - const provider = new MemorySkillProvider(); - await expect(provider.search('anything')).rejects.toThrow(PEER_ERROR); - }); - - it('warm() surfaces the same clear install hint (it builds the search index)', async () => { - const provider = new MemorySkillProvider(); - await provider.add(createTestSkill()); - await expect(provider.warm()).rejects.toThrow(PEER_ERROR); - }); - - it('clear() stays resilient (resolves) when the peer is absent', async () => { - const provider = new MemorySkillProvider(); - await expect(provider.clear()).resolves.toBeUndefined(); - }); - - it('dispose() stays resilient (resolves) when the peer is absent', async () => { - const provider = new MemorySkillProvider(); - await expect(provider.dispose()).resolves.toBeUndefined(); - }); -}); diff --git a/libs/sdk/src/skill/providers/memory-skill.provider.ts b/libs/sdk/src/skill/providers/memory-skill.provider.ts index 55e31b6e2..350e604e4 100644 --- a/libs/sdk/src/skill/providers/memory-skill.provider.ts +++ b/libs/sdk/src/skill/providers/memory-skill.provider.ts @@ -1,13 +1,11 @@ // file: libs/sdk/src/skill/providers/memory-skill.provider.ts -import type { DocumentMetadata, TFIDFVectoria } from 'vectoriadb'; +import { TFIDFVectoria, type DocumentMetadata } from 'vectoriadb/worker'; import { sha256Hex } from '@frontmcp/utils'; import { type SkillContent } from '../../common/interfaces'; import { type SkillMetadata, type SkillVisibility } from '../../common/metadata'; -import { importOptionalPeer } from '../../scope/optional-dependency.util'; -import { importWithRequireFallback } from '../../utils/dynamic-import.utils'; import { type SkillIndexCache, type SkillIndexScoring } from '../skill-index-cache.interface'; import { type MutableSkillStorageProvider, @@ -179,13 +177,8 @@ function isSnapshotCapable(db: unknown): db is SnapshotCapableDb { export class MemorySkillProvider implements MutableSkillStorageProvider { readonly type: SkillStorageProviderType = 'memory'; - /** - * The search index. `vectoriadb` is loaded the first time a search needs it, - * never at construction: a server whose skills are only listed or loaded - * (or that has none) must not pay for, or depend on, the optional peer. - */ + /** The search index, created the first time a search needs it. */ private vectorDB?: TFIDFVectoria; - private vectorDBLoad?: Promise>; private skills: Map = new Map(); private defaultTopK: number; private defaultMinScore: number; @@ -201,8 +194,8 @@ export class MemorySkillProvider implements MutableSkillStorageProvider { /** * The index is rebuilt from `skills` when it is stale: adds/removes bump * `skillsVersion`, and the FIRST search after a mutation builds (or restores) - * the index exactly once. This keeps the expensive IDF/embedding pass — and - * the `vectoriadb` load itself — off the registration hot-path. + * the index exactly once. This keeps the expensive IDF/embedding pass off the + * registration hot-path. */ private skillsVersion = 0; private indexedVersion = -1; @@ -213,29 +206,9 @@ export class MemorySkillProvider implements MutableSkillStorageProvider { this.toolValidator = options.toolValidator; this.scoring = options.scoring ?? 'cosine'; this.indexCache = options.indexCache; - - // `vectoriadb` is an OPTIONAL peer of the SDK and is loaded on first search - // (see `db()`), so consumers that never search skills don't need it - // installed and a missing install surfaces as a clear on-use error. } - private async loadVectorDB(): Promise> { - // Route the lazy load through importOptionalPeer so a missing install and an - // install-but-failed-to-load are reported differently — never blindly - // "reinstall it" when the package is present but threw (#453). - const mod = await importOptionalPeer( - 'vectoriadb', - // A dynamic import inside Jest's VM throws unless the run uses - // --experimental-vm-modules; `vectoriadb` ships CommonJS, so fall back to `require`. - () => - importWithRequireFallback( - () => import('vectoriadb'), - () => require('vectoriadb') as typeof import('vectoriadb'), - ), - // Read only when the import fails: an ESM browser bundle has no `require` at all - (id) => require.resolve(id), - 'skill search', - ); + private createVectorDB(): TFIDFVectoria { // `scoring` is read by newer vectoriadb versions; older ones ignore the // extra field and use cosine. Built as a typed variable (not an object // literal) so passing it to the older constructor type isn't an excess- @@ -245,7 +218,7 @@ export class MemorySkillProvider implements MutableSkillStorageProvider { defaultSimilarityThreshold: this.defaultMinScore, }; if (this.scoring !== 'cosine') dbConfig.scoring = this.scoring; - return new mod.TFIDFVectoria(dbConfig); + return new TFIDFVectoria(dbConfig); } /** @@ -278,7 +251,7 @@ export class MemorySkillProvider implements MutableSkillStorageProvider { */ private async ensureIndexed(): Promise { if (this.indexReady) return; - const db = await this.db(); + const db = this.db(); // A mutation while this build awaits leaves the index stale for the next search. const version = this.skillsVersion; @@ -351,11 +324,8 @@ export class MemorySkillProvider implements MutableSkillStorageProvider { return sha256Hex(canonical); } - /** Load `vectoriadb` on first use (throws the clear install hint if absent). */ - private async db(): Promise> { - if (this.vectorDB) return this.vectorDB; - this.vectorDBLoad ??= this.loadVectorDB(); - this.vectorDB = await this.vectorDBLoad; + private db(): TFIDFVectoria { + this.vectorDB ??= this.createVectorDB(); return this.vectorDB; } @@ -368,10 +338,6 @@ export class MemorySkillProvider implements MutableSkillStorageProvider { } async initialize(): Promise { - // Deliberately does NOT load `vectoriadb`: every app builds a skill - // registry at boot, and loading the peer here made every server depend on - // it (and broke servers started inside Jest, whose VM rejects the dynamic - // import without --experimental-vm-modules). The first search loads it. this.initialized = true; } @@ -427,7 +393,7 @@ export class MemorySkillProvider implements MutableSkillStorageProvider { // Search using TF-IDF. Ensure the index is built/restored exactly once for // the current document set (cold-start fast path via the snapshot cache). - const db = await this.db(); + const db = this.db(); await this.ensureIndexed(); let results = await db.search(query, { topK, diff --git a/libs/skills/catalog/frontmcp-development/references/create-skill.md b/libs/skills/catalog/frontmcp-development/references/create-skill.md index 6006bd47f..24bf197b9 100644 --- a/libs/skills/catalog/frontmcp-development/references/create-skill.md +++ b/libs/skills/catalog/frontmcp-development/references/create-skill.md @@ -160,7 +160,7 @@ class ApiStandardsSkill extends SkillContext {} > **When file and URL instructions are read:** when the server starts, for every skill — the server indexes each skill for `skills/search` and checks its tools then, so a URL is fetched at every start whether or not a client reads the skill. A read that fails is logged (`Failed to load skill : …`) and tried again the first time the skill is loaded; the content is kept once a read succeeds. -> **Search index:** `skills/search` ranks with TF-IDF from the optional peer `vectoriadb`, loaded the first time a search runs — not at server start. Listing and loading skills (`skills/list`, `skills/load`, `skill://` resources) never need it, so a server whose skills are never searched runs without it installed, and a server started inside a Jest test needs no `--experimental-vm-modules`. The first search without the package fails with an error naming it. +> **Search index:** `skills/search` ranks with TF-IDF from `vectoriadb`, a dependency of `@frontmcp/sdk`. The SDK uses its worker-safe entry (no dependencies, no Node built-ins), so 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 at server start. ## SkillContext: loadInstructions() and build() diff --git a/libs/skills/catalog/frontmcp-testing/SKILL.md b/libs/skills/catalog/frontmcp-testing/SKILL.md index 9186e7df6..a17a81652 100644 --- a/libs/skills/catalog/frontmcp-testing/SKILL.md +++ b/libs/skills/catalog/frontmcp-testing/SKILL.md @@ -161,7 +161,7 @@ This is a router skill. Follow this order to pick a testing approach, then move | `test.beforeEach(async ({ mcp }) => …)` times out waiting for `done` | Older `@frontmcp/testing` handed the hook to Jest, which read the parameter as `done` | Upgrade — hooks that take a parameter now receive the test's fixtures (same `mcp` as the test); `beforeAll` / `afterAll` never get fixtures | | `test.use({ transport: 'sse' })` throws "SSE transport not yet implemented" | Older `@frontmcp/testing` had no legacy SSE client | Upgrade — `'sse'` is the legacy HTTP+SSE transport; the server must enable it with `transport: { protocol: { legacy: true } }` | | `Module @swc/jest in the transform option was not found` from `frontmcp test` | Older `@frontmcp/testing` did not install the transformer the injected config uses | Upgrade — `@frontmcp/testing` depends on `@swc/jest` / `@swc/core`, and `frontmcp test` resolves the transformer through it | -| `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING_FLAG` from a server started inside a spec | Older SDKs loaded the optional `vectoriadb` with `import()` at boot | Upgrade the SDK — `vectoriadb` loads on the first skill search, through `require` where available; no `--experimental-vm-modules` needed | +| `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING_FLAG` from a server started inside a spec | Older SDKs loaded the optional `vectoriadb` with `import()` at boot | Upgrade the SDK — `vectoriadb` is a regular dependency, imported without `import()`; no `--experimental-vm-modules` needed | ## Examples diff --git a/package.json b/package.json index 49dcbe56b..f77a07b72 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "mcp-from-openapi": "2.8.0", "openapi-types": "^12.1.3", "reflect-metadata": "^0.2.2", - "vectoriadb": "^2.3.2", + "vectoriadb": "^2.3.3", "yaml": "^2.9.0", "zod": "^4.6.1" }, diff --git a/yarn.lock b/yarn.lock index e0ce4cbad..a8cfcf867 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2713,6 +2713,7 @@ __metadata: reflect-metadata: "npm:^0.2.2" semver: "npm:^7.6.0" typescript: "npm:^5.9.3" + vectoriadb: "npm:^2.3.3" peerDependencies: "@anthropic-ai/sdk": ^0.30.0 || ^0.78.0 "@enclave-vm/core": ^2.16.0 @@ -2726,7 +2727,6 @@ __metadata: 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 peerDependenciesMeta: "@anthropic-ai/sdk": optional: true @@ -2744,8 +2744,6 @@ __metadata: optional: true openai: optional: true - vectoriadb: - optional: true languageName: unknown linkType: soft @@ -2847,7 +2845,7 @@ __metadata: tslib: "npm:^2.8.1" typescript: "npm:~5.9.2" typescript-eslint: "npm:^8.61.0" - vectoriadb: "npm:^2.3.2" + vectoriadb: "npm:^2.3.3" verdaccio: "npm:6.7.2" vite: "npm:^8.0.16" webpack-cli: "npm:^5.1.4" @@ -22743,6 +22741,20 @@ __metadata: languageName: node linkType: hard +"vectoriadb@npm:^2.3.3": + version: 2.3.3 + resolution: "vectoriadb@npm:2.3.3" + dependencies: + tslib: "npm:^2.8.1" + peerDependencies: + "@huggingface/transformers": ^3.2.2 + peerDependenciesMeta: + "@huggingface/transformers": + optional: true + checksum: 10c0/a58edd9eccb513dc0e7b21025461ea80395f7e305c4e1fe38531be38dbfd94f6fd1b24d3e4dea70edf8b91cfee7c6d539ca4c909ce96195e14b26f692ac7ba5d + languageName: node + linkType: hard + "verdaccio-audit@npm:13.0.2": version: 13.0.2 resolution: "verdaccio-audit@npm:13.0.2" From 0a6dec186fe1e628773b55b7ff071ad2091034ba Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 5 Oct 2026 17:41:07 +0300 Subject: [PATCH 4/4] fix(sdk): import vectoriadb from its package root The subpath vectoriadb/worker exists only in the package's exports map, which moduleResolution "node" ignores. tsconfig.base.json uses it, so the webpack builds that compile the SDK source, such as apps/demo, failed with TS2307 "Cannot find module 'vectoriadb/worker'". The SDK imports the package root instead. Its types resolve through the package's types field under any resolution mode, and its exports map lists the worker and browser conditions before import, so browser and worker bundles still get the TF-IDF entry without Node built-ins; Node gets the full entry. A browser bundle of the SDK includes only vectoriadb's worker.js, vectoria-tfidf.js, tfidf.embedding.service.js and sanitize.utils.js. --- docs/frontmcp/servers/skills.mdx | 6 +++--- libs/sdk/src/skill/providers/memory-skill.provider.ts | 2 +- .../catalog/frontmcp-development/references/create-skill.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/frontmcp/servers/skills.mdx b/docs/frontmcp/servers/skills.mdx index e639ccd5a..2511f73f0 100644 --- a/docs/frontmcp/servers/skills.mdx +++ b/docs/frontmcp/servers/skills.mdx @@ -701,9 +701,9 @@ Results are ranked by: 2. Priority field (higher = earlier) 3. Tool availability (complete skills rank higher) -The index is built with `vectoriadb`, a dependency of `@frontmcp/sdk`. The SDK uses its worker-safe -TF-IDF/BM25 entry, which has no dependencies and 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, +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. diff --git a/libs/sdk/src/skill/providers/memory-skill.provider.ts b/libs/sdk/src/skill/providers/memory-skill.provider.ts index 350e604e4..edb3d4a63 100644 --- a/libs/sdk/src/skill/providers/memory-skill.provider.ts +++ b/libs/sdk/src/skill/providers/memory-skill.provider.ts @@ -1,6 +1,6 @@ // file: libs/sdk/src/skill/providers/memory-skill.provider.ts -import { TFIDFVectoria, type DocumentMetadata } from 'vectoriadb/worker'; +import { TFIDFVectoria, type DocumentMetadata } from 'vectoriadb'; import { sha256Hex } from '@frontmcp/utils'; diff --git a/libs/skills/catalog/frontmcp-development/references/create-skill.md b/libs/skills/catalog/frontmcp-development/references/create-skill.md index 24bf197b9..9f743a0c1 100644 --- a/libs/skills/catalog/frontmcp-development/references/create-skill.md +++ b/libs/skills/catalog/frontmcp-development/references/create-skill.md @@ -160,7 +160,7 @@ class ApiStandardsSkill extends SkillContext {} > **When file and URL instructions are read:** when the server starts, for every skill — the server indexes each skill for `skills/search` and checks its tools then, so a URL is fetched at every start whether or not a client reads the skill. A read that fails is logged (`Failed to load skill : …`) and tried again the first time the skill is loaded; the content is kept once a read succeeds. -> **Search index:** `skills/search` ranks with TF-IDF from `vectoriadb`, a dependency of `@frontmcp/sdk`. The SDK uses its worker-safe entry (no dependencies, no Node built-ins), so 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 at server start. +> **Search index:** `skills/search` ranks with TF-IDF from `vectoriadb`, a dependency of `@frontmcp/sdk`. Under the `browser` and `worker` export conditions it resolves to its TF-IDF/BM25 entry (no Node built-ins), so 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 at server start. ## SkillContext: loadInstructions() and build()