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..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 @@ -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).__BUNDLE_RESULTS__, + )) as Record; + + 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).__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"]'); const results = (await page.evaluate( () => (window as unknown as Record).__BUNDLE_RESULTS__, )) as Record; @@ -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).__BUNDLE_RESULTS__, )) as Record; @@ -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).__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..d85863abc 100644 --- a/apps/e2e/demo-e2e-browser-bundle/src/main.ts +++ b/apps/e2e/demo-e2e-browser-bundle/src/main.ts @@ -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'; @@ -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 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/docs/frontmcp/servers/skills.mdx b/docs/frontmcp/servers/skills.mdx index e6dbd85dc..2511f73f0 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`. 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/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/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/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; 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..edb3d4a63 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'; 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-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 diff --git a/libs/skills/catalog/frontmcp-development/references/create-skill.md b/libs/skills/catalog/frontmcp-development/references/create-skill.md index 6006bd47f..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 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`. 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() 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"