diff --git a/apps/e2e/demo-e2e-esm/browser-app/index.html b/apps/e2e/demo-e2e-esm/browser-app/index.html
new file mode 100644
index 000000000..9b2e54987
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/browser-app/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+ FrontMCP Browser ESM E2E
+
+
+ Loading...
+
+
+
diff --git a/apps/e2e/demo-e2e-esm/browser-app/main.ts b/apps/e2e/demo-e2e-esm/browser-app/main.ts
new file mode 100644
index 000000000..bbbe0c374
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/browser-app/main.ts
@@ -0,0 +1,116 @@
+/**
+ * @file main.ts
+ * @description Full FrontMCP instance running in the browser with dynamic ESM tool loading.
+ *
+ * This is the browser entry point for the ESM E2E test. It boots a real FrontMCP
+ * DirectClient (no HTTP server) that loads ESM tool packages on the fly from
+ * a local ESM package server.
+ *
+ * Playwright tests read results from window.__ESM_RESULTS__ after the page loads.
+ */
+import 'reflect-metadata';
+import { connect, App, LogLevel } from '@frontmcp/sdk';
+
+// Read ESM server URL from query params (set by Playwright test)
+const params = new URLSearchParams(location.search);
+const esmServerUrl = params.get('esmServer') ?? 'http://127.0.0.1:50413';
+
+interface EsmTestResults {
+ success: boolean;
+ toolNames?: string[];
+ echoResult?: unknown;
+ addResult?: unknown;
+ greetResult?: unknown;
+ resourceUris?: string[];
+ promptNames?: string[];
+ error?: string;
+}
+
+declare global {
+ interface Window {
+ __ESM_RESULTS__?: EsmTestResults;
+ }
+}
+
+async function main(): Promise {
+ const app = document.getElementById('app');
+
+ try {
+ if (!app) {
+ throw new Error('Missing #app root element');
+ }
+ app.textContent = 'Connecting to FrontMCP...';
+
+ // Boot a FULL FrontMCP instance in the browser via DirectClient
+ const client = await connect(
+ {
+ info: { name: 'Browser ESM E2E', version: '0.1.0' },
+ loader: { url: esmServerUrl },
+ apps: [
+ App.esm('@test/esm-tools@^1.0.0', { namespace: 'esm' }),
+ App.esm('@test/esm-multi@^1.0.0', { namespace: 'multi' }),
+ ],
+ logging: { level: LogLevel.Warn },
+ },
+ { mode: 'cli' },
+ );
+
+ app.textContent = 'Loading tools...';
+
+ // List tools
+ const { tools } = await client.listTools();
+ const toolNames = tools.map((t) => t.name);
+
+ // Call tools
+ const echoResult = await client.callTool({
+ name: 'esm:echo',
+ arguments: { message: 'browser-hello' },
+ });
+
+ const addResult = await client.callTool({
+ name: 'esm:add',
+ arguments: { a: 5, b: 7 },
+ });
+
+ const greetResult = await client.callTool({
+ name: 'multi:greet',
+ arguments: { name: 'Browser' },
+ });
+
+ // List resources
+ const { resources } = await client.listResources();
+ const resourceUris = resources.map((r) => r.uri);
+
+ // List prompts
+ const { prompts } = await client.listPrompts();
+ const promptNames = prompts.map((p) => p.name);
+
+ // Report results
+ const results: EsmTestResults = {
+ success: true,
+ toolNames,
+ echoResult,
+ addResult,
+ greetResult,
+ resourceUris,
+ promptNames,
+ };
+
+ window.__ESM_RESULTS__ = results;
+ app.textContent = JSON.stringify(results, null, 2);
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ const stack = err instanceof Error ? err.stack : undefined;
+ const results: EsmTestResults = {
+ success: false,
+ error: message + (stack ? '\n' + stack : ''),
+ };
+ window.__ESM_RESULTS__ = results;
+ if (app) {
+ app.textContent = 'Error: ' + message;
+ }
+ console.error('FrontMCP browser ESM error:', err);
+ }
+}
+
+main();
diff --git a/apps/e2e/demo-e2e-esm/browser-app/vite.config.ts b/apps/e2e/demo-e2e-esm/browser-app/vite.config.ts
new file mode 100644
index 000000000..a88f62d7b
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/browser-app/vite.config.ts
@@ -0,0 +1,42 @@
+import { defineConfig } from 'vite';
+import { resolve } from 'path';
+
+const root = resolve(__dirname, '../../../..');
+
+export default defineConfig({
+ root: __dirname,
+ resolve: {
+ conditions: ['browser', 'development', 'import'],
+ alias: [
+ // @frontmcp/* → source files for local development (matches demo-e2e-browser-bundle pattern)
+ { find: '@frontmcp/sdk', replacement: resolve(root, 'libs/sdk/src/index.ts') },
+ { find: '@frontmcp/utils', replacement: resolve(root, 'libs/utils/src/index.ts') },
+ { find: '@frontmcp/auth', replacement: resolve(root, 'libs/auth/src/index.ts') },
+ { find: '@frontmcp/di', replacement: resolve(root, 'libs/di/src/index.ts') },
+ { find: '@frontmcp/protocol', replacement: resolve(root, 'libs/protocol/src/index.ts') },
+ ],
+ },
+ define: {
+ 'process.env.NODE_ENV': JSON.stringify('development'),
+ },
+ server: {
+ port: 4402,
+ },
+ preview: {
+ port: 4402,
+ },
+ build: {
+ outDir: resolve(root, 'dist/apps/e2e/demo-e2e-esm-browser'),
+ emptyOutDir: true,
+ rollupOptions: {
+ shimMissingExports: true,
+ onwarn(warning, warn) {
+ // Suppress circular dependency warnings from reflect-metadata
+ if (warning.code === 'CIRCULAR_DEPENDENCY') return;
+ // Suppress missing export warnings (type-only re-exports erased by esbuild)
+ if (warning.code === 'MISSING_EXPORT') return;
+ warn(warning);
+ },
+ },
+ },
+});
diff --git a/apps/e2e/demo-e2e-esm/e2e/browser/esm-browser.pw.spec.ts b/apps/e2e/demo-e2e-esm/e2e/browser/esm-browser.pw.spec.ts
new file mode 100644
index 000000000..4dc41c5fb
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/e2e/browser/esm-browser.pw.spec.ts
@@ -0,0 +1,151 @@
+/**
+ * Browser E2E Tests for Full FrontMCP with Dynamic ESM Tool Loading
+ *
+ * Uses Playwright to verify that a complete FrontMCP DirectClient runs in the browser,
+ * loading ESM tool packages on the fly from a local ESM package server.
+ *
+ * The browser app (browser-app/main.ts) boots a real FrontMCP instance via connect()
+ * with loadFrom() ESM apps, then reports results to window.__ESM_RESULTS__.
+ *
+ * Prerequisites:
+ * - ESM package server must be running on the configured port
+ * - Vite preview server must be serving the browser app
+ */
+import { test, expect } from '@playwright/test';
+import { spawn, type ChildProcess } from 'node:child_process';
+import { ESM_SERVER_PORT } from './helpers';
+
+let esmServerProcess: ChildProcess | null = null;
+
+// Start local ESM package server before all tests
+test.beforeAll(async () => {
+ await new Promise((resolve, reject) => {
+ esmServerProcess = spawn('npx', ['tsx', 'apps/e2e/demo-e2e-esm/src/esm-package-server/main.ts'], {
+ env: { ...process.env, ESM_SERVER_PORT: String(ESM_SERVER_PORT) },
+ stdio: 'pipe',
+ cwd: process.cwd(),
+ });
+
+ let started = false;
+ const timeout = setTimeout(() => {
+ if (!started) {
+ esmServerProcess?.kill('SIGTERM');
+ reject(new Error('ESM server startup timeout'));
+ }
+ }, 30000);
+
+ esmServerProcess.stdout?.on('data', (data: Buffer) => {
+ if (data.toString().includes('ESM Package Server started') && !started) {
+ started = true;
+ clearTimeout(timeout);
+ resolve();
+ }
+ });
+
+ esmServerProcess.stderr?.on('data', (data: Buffer) => {
+ console.error('[esm-server]', data.toString());
+ });
+
+ esmServerProcess.on('error', (err) => {
+ clearTimeout(timeout);
+ esmServerProcess?.kill('SIGTERM');
+ reject(err);
+ });
+ });
+});
+
+// Stop ESM package server after all tests
+test.afterAll(async () => {
+ if (esmServerProcess) {
+ try {
+ esmServerProcess.kill('SIGTERM');
+ } catch {
+ // Process may have already exited
+ }
+ esmServerProcess = null;
+ }
+});
+
+test.describe('Full FrontMCP in Browser with ESM Loading', () => {
+ test('loads and reports ESM tools successfully', async ({ page }) => {
+ await page.goto(`/?esmServer=http://127.0.0.1:${ESM_SERVER_PORT}`);
+
+ // Wait for FrontMCP to finish loading (window.__ESM_RESULTS__ becomes defined)
+ await page.waitForFunction(() => (window as unknown as { __ESM_RESULTS__?: unknown }).__ESM_RESULTS__, {
+ timeout: 60000,
+ });
+
+ const results = await page.evaluate(() => (window as unknown as { __ESM_RESULTS__: unknown }).__ESM_RESULTS__);
+ const r = results as { success: boolean; error?: string; toolNames: string[] };
+
+ expect(r.success).toBe(true);
+ expect(r.toolNames).toContain('esm:echo');
+ expect(r.toolNames).toContain('esm:add');
+ expect(r.toolNames).toContain('multi:greet');
+ });
+
+ test('ESM echo tool executes correctly in browser', async ({ page }) => {
+ await page.goto(`/?esmServer=http://127.0.0.1:${ESM_SERVER_PORT}`);
+ await page.waitForFunction(() => (window as unknown as { __ESM_RESULTS__?: unknown }).__ESM_RESULTS__, {
+ timeout: 60000,
+ });
+
+ const results = await page.evaluate(() => (window as unknown as { __ESM_RESULTS__: unknown }).__ESM_RESULTS__);
+ const r = results as { success: boolean; echoResult: { content: Array<{ text: string }> } };
+
+ expect(r.success).toBe(true);
+ expect(r.echoResult.content[0].text).toContain('browser-hello');
+ });
+
+ test('ESM add tool computes correctly in browser', async ({ page }) => {
+ await page.goto(`/?esmServer=http://127.0.0.1:${ESM_SERVER_PORT}`);
+ await page.waitForFunction(() => (window as unknown as { __ESM_RESULTS__?: unknown }).__ESM_RESULTS__, {
+ timeout: 60000,
+ });
+
+ const results = await page.evaluate(() => (window as unknown as { __ESM_RESULTS__: unknown }).__ESM_RESULTS__);
+ const r = results as { success: boolean; addResult: { content: Array<{ text: string }> } };
+
+ expect(r.success).toBe(true);
+ expect(r.addResult.content[0].text).toBe('12'); // 5 + 7
+ });
+
+ test('ESM multi-package greet tool works in browser', async ({ page }) => {
+ await page.goto(`/?esmServer=http://127.0.0.1:${ESM_SERVER_PORT}`);
+ await page.waitForFunction(() => (window as unknown as { __ESM_RESULTS__?: unknown }).__ESM_RESULTS__, {
+ timeout: 60000,
+ });
+
+ const results = await page.evaluate(() => (window as unknown as { __ESM_RESULTS__: unknown }).__ESM_RESULTS__);
+ const r = results as { success: boolean; greetResult: { content: Array<{ text: string }> } };
+
+ expect(r.success).toBe(true);
+ expect(r.greetResult.content[0].text).toContain('Browser');
+ });
+
+ test('ESM resources are discoverable in browser', async ({ page }) => {
+ await page.goto(`/?esmServer=http://127.0.0.1:${ESM_SERVER_PORT}`);
+ await page.waitForFunction(() => (window as unknown as { __ESM_RESULTS__?: unknown }).__ESM_RESULTS__, {
+ timeout: 60000,
+ });
+
+ const results = await page.evaluate(() => (window as unknown as { __ESM_RESULTS__: unknown }).__ESM_RESULTS__);
+ const r = results as { success: boolean; resourceUris: string[] };
+
+ expect(r.success).toBe(true);
+ expect(r.resourceUris).toContain('esm://status');
+ });
+
+ test('ESM prompts are discoverable in browser', async ({ page }) => {
+ await page.goto(`/?esmServer=http://127.0.0.1:${ESM_SERVER_PORT}`);
+ await page.waitForFunction(() => (window as unknown as { __ESM_RESULTS__?: unknown }).__ESM_RESULTS__, {
+ timeout: 60000,
+ });
+
+ const results = await page.evaluate(() => (window as unknown as { __ESM_RESULTS__: unknown }).__ESM_RESULTS__);
+ const r = results as { success: boolean; promptNames: string[] };
+
+ expect(r.success).toBe(true);
+ expect(r.promptNames).toContain('multi:greeting-prompt');
+ });
+});
diff --git a/apps/e2e/demo-e2e-esm/e2e/browser/helpers.ts b/apps/e2e/demo-e2e-esm/e2e/browser/helpers.ts
new file mode 100644
index 000000000..93a908be9
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/e2e/browser/helpers.ts
@@ -0,0 +1,13 @@
+/**
+ * @file helpers.ts
+ * @description Playwright test helpers for ESM browser E2E tests.
+ *
+ * The browser app is served by Vite (configured in playwright.config.ts webServer).
+ * These helpers provide shared constants and utilities for the test suite.
+ */
+
+/** Port for the local ESM package server */
+export const ESM_SERVER_PORT = 50413;
+
+/** Port for the Vite preview server */
+export const VITE_PORT = 4402;
diff --git a/apps/e2e/demo-e2e-esm/e2e/esm-bin.e2e.spec.ts b/apps/e2e/demo-e2e-esm/e2e/esm-bin.e2e.spec.ts
new file mode 100644
index 000000000..f1821d475
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/e2e/esm-bin.e2e.spec.ts
@@ -0,0 +1,178 @@
+/**
+ * E2E Tests for ESM Package Loading in CLI/Bin Mode
+ *
+ * Tests the ESM loading pipeline through createForCli() — the same path used
+ * by the FrontMCP CLI binary. Verifies:
+ * - Tools are discoverable and callable in CLI mode (no HTTP server)
+ * - Cache directory follows the expected environment-aware default
+ *
+ * Set DEBUG_E2E=1 for verbose logging.
+ */
+import * as path from 'node:path';
+import * as os from 'node:os';
+import { mkdtemp, rm, fileExists } from '@frontmcp/utils';
+import { TestServer } from '@frontmcp/testing';
+
+const DEBUG = process.env['DEBUG_E2E'] === '1';
+const log = DEBUG ? console.log.bind(console) : () => {};
+
+// ESM package server instance
+let esmServer: TestServer | null = null;
+const ESM_SERVER_PORT = 50420;
+
+// Temp cache dir to simulate homedir-style cache (avoid polluting actual homedir)
+let testCacheDir: string;
+
+beforeAll(async () => {
+ // Create a temp dir for cache testing
+ testCacheDir = await mkdtemp(path.join(os.tmpdir(), 'frontmcp-esm-cli-test-'));
+
+ log(`[E2E] Starting ESM package server on port ${ESM_SERVER_PORT}...`);
+ try {
+ esmServer = await TestServer.start({
+ command: 'npx tsx apps/e2e/demo-e2e-esm/src/esm-package-server/main.ts',
+ project: 'esm-package-server-cli',
+ port: ESM_SERVER_PORT,
+ startupTimeout: 30000,
+ healthCheckPath: '/@test/esm-tools',
+ debug: DEBUG,
+ env: { ESM_SERVER_PORT: String(ESM_SERVER_PORT) },
+ });
+ log('[E2E] ESM package server started:', esmServer.info.baseUrl);
+ } catch (error) {
+ console.error('[E2E] Failed to start ESM package server:', error);
+ throw error;
+ }
+}, 60000);
+
+afterAll(async () => {
+ if (esmServer) {
+ log('[E2E] Stopping ESM package server...');
+ await esmServer.stop();
+ esmServer = null;
+ }
+ // Clean up temp cache dir
+ try {
+ await rm(testCacheDir, { recursive: true, force: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+}, 30000);
+
+describe('ESM CLI/Bin Mode E2E', () => {
+ let client: Awaited>;
+
+ beforeAll(async () => {
+ const { connect, App, LogLevel } = await import('@frontmcp/sdk');
+
+ if (!esmServer) throw new Error('ESM package server was not started');
+ const esmServerUrl = `http://127.0.0.1:${esmServer.info.port}`;
+
+ client = await connect(
+ {
+ info: { name: 'ESM CLI Test', version: '0.1.0' },
+ loader: { url: esmServerUrl },
+ apps: [
+ App.esm('@test/esm-tools@^1.0.0', {
+ namespace: 'esm',
+ cacheTTL: 60000,
+ }),
+ ],
+ logging: { level: LogLevel.Warn },
+ },
+ { mode: 'cli' },
+ );
+ log('[E2E] CLI-mode client connected');
+ }, 60000);
+
+ afterAll(async () => {
+ if (client) {
+ await client.close();
+ }
+ });
+
+ // ═══════════════════════════════════════════════════════════════
+ // TOOL DISCOVERY IN CLI MODE
+ // ═══════════════════════════════════════════════════════════════
+
+ it('lists ESM-loaded tools in CLI mode', async () => {
+ const tools = await client.listTools();
+ const toolNames = tools.map((t) => t.name);
+ log('[TEST] CLI tools:', toolNames);
+
+ expect(toolNames).toContain('esm:echo');
+ expect(toolNames).toContain('esm:add');
+ });
+
+ // ═══════════════════════════════════════════════════════════════
+ // TOOL EXECUTION IN CLI MODE
+ // ═══════════════════════════════════════════════════════════════
+
+ it('calls ESM tool echo in CLI mode', async () => {
+ const result = await client.callTool('esm:echo', { message: 'cli-test' });
+ const text = (result.content as Array<{ type: string; text: string }>)[0]?.text;
+ expect(text).toContain('cli-test');
+ });
+
+ it('calls ESM tool add in CLI mode', async () => {
+ const result = await client.callTool('esm:add', { a: 10, b: 20 });
+ const text = (result.content as Array<{ type: string; text: string }>)[0]?.text;
+ expect(text).toBe('30');
+ });
+
+ // ═══════════════════════════════════════════════════════════════
+ // CACHE DIRECTORY VERIFICATION
+ // ═══════════════════════════════════════════════════════════════
+
+ it('cache directory follows environment-aware logic', async () => {
+ // When running in a project with node_modules, the default cache
+ // goes to {cwd}/node_modules/.cache/frontmcp-esm/
+ const projectCacheDir = path.join(process.cwd(), 'node_modules', '.cache', 'frontmcp-esm');
+
+ // Since we're running inside the monorepo (has node_modules),
+ // cache should be project-local
+ const hasNodeModules = await fileExists(path.join(process.cwd(), 'node_modules'));
+ if (hasNodeModules) {
+ // Prior tests loaded ESM tools which should have populated the cache
+ const cacheExists = await fileExists(projectCacheDir);
+ log('[TEST] Project-local cache dir:', projectCacheDir, 'exists:', cacheExists);
+ expect(cacheExists).toBe(true);
+ } else {
+ // If no node_modules (unlikely in this test), homedir should be used
+ const homedirCache = path.join(os.homedir(), '.frontmcp', 'esm-cache');
+ const homedirCacheExists = await fileExists(homedirCache);
+ log('[TEST] Homedir cache dir:', homedirCache, 'exists:', homedirCacheExists);
+ expect(homedirCacheExists).toBe(true);
+ }
+ });
+
+ it('second client with different namespace loads independently', async () => {
+ const { connect, App, LogLevel } = await import('@frontmcp/sdk');
+ if (!esmServer) throw new Error('ESM package server was not started');
+ const esmServerUrl = `http://127.0.0.1:${esmServer.info.port}`;
+
+ const customClient = await connect(
+ {
+ info: { name: 'ESM CLI Custom Cache', version: '0.1.0' },
+ loader: { url: esmServerUrl },
+ apps: [
+ App.esm('@test/esm-tools@^1.0.0', {
+ namespace: 'custom',
+ cacheTTL: 60000,
+ }),
+ ],
+ logging: { level: LogLevel.Warn },
+ },
+ { mode: 'cli' },
+ );
+
+ // Tools should still work regardless of cache location
+ try {
+ const tools = await customClient.listTools();
+ const toolNames = tools.map((t) => t.name);
+ expect(toolNames).toContain('custom:echo');
+ } finally {
+ await customClient.close();
+ }
+ });
+});
diff --git a/apps/e2e/demo-e2e-esm/e2e/esm-hot-reload.e2e.spec.ts b/apps/e2e/demo-e2e-esm/e2e/esm-hot-reload.e2e.spec.ts
new file mode 100644
index 000000000..ec962e7fd
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/e2e/esm-hot-reload.e2e.spec.ts
@@ -0,0 +1,162 @@
+/**
+ * E2E Tests for ESM Hot-Reload (Version Polling)
+ *
+ * Tests the auto-update pipeline:
+ * 1. Start with @test/esm-tools v1.0.0 (2 tools: echo, add)
+ * 2. Publish v1.1.0 with 3 tools (echo, add, multiply) via /_admin/publish
+ * 3. Wait for the VersionPoller to detect the new version
+ * 4. Verify the new tool appears in tools/list
+ * 5. Call the new tool to confirm it works
+ *
+ * Set DEBUG_E2E=1 for verbose logging.
+ */
+import { test, expect, TestServer } from '@frontmcp/testing';
+
+const DEBUG = process.env['DEBUG_E2E'] === '1';
+const log = DEBUG ? console.log.bind(console) : () => {};
+
+// ESM package server instance
+let esmServer: TestServer | null = null;
+
+// Port configuration
+const ESM_SERVER_PORT = 50411;
+
+const UPDATED_BUNDLE = `
+module.exports = {
+ default: {
+ name: '@test/esm-tools',
+ version: '1.1.0',
+ tools: [
+ {
+ name: 'echo',
+ description: 'Echoes the input message back',
+ inputSchema: {
+ type: 'object',
+ properties: { message: { type: 'string' } },
+ required: ['message'],
+ },
+ execute: async (input) => ({
+ content: [{ type: 'text', text: JSON.stringify(input) }],
+ }),
+ },
+ {
+ name: 'add',
+ description: 'Adds two numbers',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ a: { type: 'number' },
+ b: { type: 'number' },
+ },
+ required: ['a', 'b'],
+ },
+ execute: async (input) => ({
+ content: [{ type: 'text', text: String(Number(input.a) + Number(input.b)) }],
+ }),
+ },
+ {
+ name: 'multiply',
+ description: 'Multiplies two numbers',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ a: { type: 'number' },
+ b: { type: 'number' },
+ },
+ required: ['a', 'b'],
+ },
+ execute: async (input) => ({
+ content: [{ type: 'text', text: String(Number(input.a) * Number(input.b)) }],
+ }),
+ },
+ ],
+ },
+};
+`;
+
+// Start local ESM package server before all tests
+beforeAll(async () => {
+ log(`[E2E] Starting ESM package server on port ${ESM_SERVER_PORT}...`);
+ try {
+ esmServer = await TestServer.start({
+ command: 'npx tsx apps/e2e/demo-e2e-esm/src/esm-package-server/main.ts',
+ project: 'esm-package-server-hot-reload',
+ port: ESM_SERVER_PORT,
+ startupTimeout: 30000,
+ healthCheckPath: '/@test/esm-tools',
+ debug: DEBUG,
+ env: { ESM_SERVER_PORT: String(ESM_SERVER_PORT) },
+ });
+ log('[E2E] ESM package server started:', esmServer.info.baseUrl);
+ // Propagate actual port for the test fixture's MCP demo server
+ process.env['ESM_SERVER_PORT'] = String(esmServer.info.port);
+ } catch (error) {
+ console.error('[E2E] Failed to start ESM package server:', error);
+ throw error;
+ }
+}, 60000);
+
+// Stop ESM package server after all tests
+afterAll(async () => {
+ delete process.env['ESM_SERVER_PORT'];
+ if (esmServer) {
+ log('[E2E] Stopping ESM package server...');
+ await esmServer.stop();
+ esmServer = null;
+ }
+}, 30000);
+
+test.describe('ESM Hot-Reload E2E', () => {
+ test.use({
+ server: 'apps/e2e/demo-e2e-esm/src/main-hot-reload.ts',
+ project: 'demo-e2e-esm-hot-reload',
+ publicMode: true,
+ logLevel: DEBUG ? 'debug' : 'warn',
+ startupTimeout: 60000,
+ });
+
+ test('detects new version and registers new tools', async ({ mcp }) => {
+ // Step 1: Verify initial state — only echo and add
+ const initialTools = await mcp.tools.list();
+ const initialNames = initialTools.map((t: { name: string }) => t.name);
+ log('[TEST] Initial tools:', initialNames);
+
+ expect(initialTools).toContainTool('esm:echo');
+ expect(initialTools).toContainTool('esm:add');
+ expect(initialNames).not.toContain('esm:multiply');
+
+ // Step 2: Publish v1.1.0 with 3 tools (adds multiply)
+ const publishUrl = `http://127.0.0.1:${esmServer!.info.port}/_admin/publish`;
+ const publishRes = await fetch(publishUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ package: '@test/esm-tools',
+ version: '1.1.0',
+ bundle: UPDATED_BUNDLE,
+ }),
+ });
+ expect(publishRes.ok).toBe(true);
+ log('[TEST] Published v1.1.0');
+
+ // Step 3: Poll tools/list until esm:multiply appears (max ~30s)
+ let multiplyFound = false;
+ for (let i = 0; i < 15; i++) {
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ const tools = await mcp.tools.list();
+ const names = tools.map((t: { name: string }) => t.name);
+ log(`[TEST] Poll ${i + 1}: tools =`, names);
+ if (names.includes('esm:multiply')) {
+ multiplyFound = true;
+ break;
+ }
+ }
+
+ expect(multiplyFound).toBe(true);
+
+ // Step 4: Call the new tool
+ const result = await mcp.tools.call('esm:multiply', { a: 3, b: 4 });
+ expect(result).toBeSuccessful();
+ expect(result).toHaveTextContent('12');
+ });
+});
diff --git a/apps/e2e/demo-e2e-esm/e2e/esm.e2e.spec.ts b/apps/e2e/demo-e2e-esm/e2e/esm.e2e.spec.ts
new file mode 100644
index 000000000..417f80e61
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/e2e/esm.e2e.spec.ts
@@ -0,0 +1,187 @@
+/**
+ * E2E Tests for ESM Package Loading
+ *
+ * Tests the full ESM loading pipeline through a real FrontMCP runtime:
+ * @FrontMcp config → AppRegistry → AppEsmInstance → EsmModuleLoader
+ * → fetch from local server → cache → normalize → register → tools/list → tools/call
+ *
+ * Uses a local HTTP server (esm-package-server) as a custom ESM registry,
+ * simulating an on-premise esm.sh. No real network calls — everything hits 127.0.0.1.
+ *
+ * Set DEBUG_E2E=1 environment variable for verbose logging.
+ */
+import { test, expect, TestServer } from '@frontmcp/testing';
+
+const DEBUG = process.env['DEBUG_E2E'] === '1';
+const log = DEBUG ? console.log.bind(console) : () => {};
+
+// ESM package server instance
+let esmServer: TestServer | null = null;
+
+// Port configuration
+const ESM_SERVER_PORT = 50400;
+
+// Start local ESM package server before all tests
+beforeAll(async () => {
+ log(`[E2E] Starting ESM package server on port ${ESM_SERVER_PORT}...`);
+ try {
+ esmServer = await TestServer.start({
+ command: 'npx tsx apps/e2e/demo-e2e-esm/src/esm-package-server/main.ts',
+ project: 'esm-package-server',
+ port: ESM_SERVER_PORT,
+ startupTimeout: 30000,
+ healthCheckPath: '/@test/esm-tools',
+ debug: DEBUG,
+ env: { ESM_SERVER_PORT: String(ESM_SERVER_PORT) },
+ });
+ log('[E2E] ESM package server started:', esmServer.info.baseUrl);
+ // Propagate actual port for the test fixture's MCP demo server
+ process.env['ESM_SERVER_PORT'] = String(esmServer.info.port);
+ } catch (error) {
+ console.error('[E2E] Failed to start ESM package server:', error);
+ throw error;
+ }
+}, 60000);
+
+// Stop ESM package server after all tests
+afterAll(async () => {
+ delete process.env['ESM_SERVER_PORT'];
+ if (esmServer) {
+ log('[E2E] Stopping ESM package server...');
+ await esmServer.stop();
+ esmServer = null;
+ }
+}, 30000);
+
+test.describe('ESM Package Loading E2E', () => {
+ test.use({
+ server: 'apps/e2e/demo-e2e-esm/src/main.ts',
+ project: 'demo-e2e-esm',
+ publicMode: true,
+ logLevel: DEBUG ? 'debug' : 'warn',
+ startupTimeout: 60000,
+ });
+
+ // ═══════════════════════════════════════════════════════════════
+ // CONNECTIVITY
+ // ═══════════════════════════════════════════════════════════════
+
+ test('should connect to ESM gateway server', async ({ mcp }) => {
+ expect(mcp.isConnected()).toBe(true);
+ expect(mcp.serverInfo.name).toBe('Demo E2E ESM');
+ });
+
+ // ═══════════════════════════════════════════════════════════════
+ // TOOL DISCOVERY
+ // ═══════════════════════════════════════════════════════════════
+
+ test('lists ESM-loaded tools from esm-tools package', async ({ mcp }) => {
+ const tools = await mcp.tools.list();
+ log(
+ '[TEST] Found tools:',
+ tools.map((t: unknown) => {
+ if (typeof t === 'object' && t !== null && 'name' in t) {
+ return (t as { name: string }).name;
+ }
+ return String(t);
+ }),
+ );
+
+ expect(tools).toContainTool('esm:echo');
+ expect(tools).toContainTool('esm:add');
+ });
+
+ test('lists ESM-loaded tools from esm-multi package', async ({ mcp }) => {
+ const tools = await mcp.tools.list();
+ expect(tools).toContainTool('multi:greet');
+ });
+
+ // ═══════════════════════════════════════════════════════════════
+ // TOOL EXECUTION
+ // ═══════════════════════════════════════════════════════════════
+
+ test('calls ESM tool echo', async ({ mcp }) => {
+ const result = await mcp.tools.call('esm:echo', { message: 'hello' });
+ expect(result).toBeSuccessful();
+ expect(result).toHaveTextContent(JSON.stringify({ message: 'hello' }));
+ });
+
+ test('calls ESM tool add', async ({ mcp }) => {
+ const result = await mcp.tools.call('esm:add', { a: 2, b: 3 });
+ expect(result).toBeSuccessful();
+ expect(result).toHaveTextContent('5');
+ });
+
+ test('calls ESM tool greet from multi package', async ({ mcp }) => {
+ const result = await mcp.tools.call('multi:greet', { name: 'Alice' });
+ expect(result).toBeSuccessful();
+ expect(result).toHaveTextContent('Hello, Alice!');
+ });
+
+ // ═══════════════════════════════════════════════════════════════
+ // RESOURCE DISCOVERY
+ // ═══════════════════════════════════════════════════════════════
+
+ test('lists ESM-loaded resources', async ({ mcp }) => {
+ const resources = await mcp.resources.list();
+ log(
+ '[TEST] Found resources:',
+ resources.map((r: unknown) => {
+ if (typeof r === 'object' && r !== null && 'name' in r) {
+ return (r as { name: string }).name;
+ }
+ return String(r);
+ }),
+ );
+
+ expect(resources).toContainResource('esm://status');
+ });
+
+ // ═══════════════════════════════════════════════════════════════
+ // PROMPT DISCOVERY
+ // ═══════════════════════════════════════════════════════════════
+
+ test('lists ESM-loaded prompts', async ({ mcp }) => {
+ const prompts = await mcp.prompts.list();
+ log(
+ '[TEST] Found prompts:',
+ prompts.map((p: unknown) => {
+ if (typeof p === 'object' && p !== null && 'name' in p) {
+ return (p as { name: string }).name;
+ }
+ return String(p);
+ }),
+ );
+
+ expect(prompts).toContainPrompt('multi:greeting-prompt');
+ });
+
+ // ═══════════════════════════════════════════════════════════════
+ // MULTI-APP ISOLATION
+ // ═══════════════════════════════════════════════════════════════
+
+ test('ESM tools from different packages have separate namespaces', async ({ mcp }) => {
+ const tools = await mcp.tools.list();
+ const toolNames = tools.map((t: unknown) => {
+ if (typeof t === 'object' && t !== null && 'name' in t) {
+ return (t as { name: string }).name;
+ }
+ return String(t);
+ });
+
+ const esmTools = toolNames.filter((n: string) => n.startsWith('esm:'));
+ const multiTools = toolNames.filter((n: string) => n.startsWith('multi:'));
+
+ expect(esmTools.length).toBeGreaterThanOrEqual(2);
+ expect(multiTools.length).toBeGreaterThanOrEqual(1);
+ });
+
+ // ═══════════════════════════════════════════════════════════════
+ // ERROR HANDLING
+ // ═══════════════════════════════════════════════════════════════
+
+ test('should handle non-existent ESM tool gracefully', async ({ mcp }) => {
+ const result = await mcp.tools.call('esm:non-existent-tool', {});
+ expect(result.isError).toBe(true);
+ });
+});
diff --git a/apps/e2e/demo-e2e-esm/jest.e2e.config.ts b/apps/e2e/demo-e2e-esm/jest.e2e.config.ts
new file mode 100644
index 000000000..588664fad
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/jest.e2e.config.ts
@@ -0,0 +1,42 @@
+import type { Config } from '@jest/types';
+import { createRequire } from 'module';
+
+const require = createRequire(import.meta.url);
+const e2eCoveragePreset = require('../../../jest.e2e.coverage.preset.js');
+
+const config: Config.InitialOptions = {
+ displayName: 'demo-e2e-esm',
+ preset: '../../../jest.preset.js',
+ testEnvironment: 'node',
+ testMatch: ['/e2e/**/*.e2e.spec.ts'],
+ testTimeout: 60000,
+ maxWorkers: 1,
+ setupFilesAfterEnv: ['/../../../libs/testing/src/setup.ts'],
+ transformIgnorePatterns: ['node_modules/(?!(jose)/)'],
+ transform: {
+ '^.+\\.[tj]s$': [
+ '@swc/jest',
+ {
+ jsc: {
+ parser: {
+ syntax: 'typescript',
+ decorators: true,
+ },
+ transform: {
+ decoratorMetadata: true,
+ },
+ target: 'es2022',
+ },
+ },
+ ],
+ },
+ moduleNameMapper: {
+ '^@frontmcp/testing$': '/../../../libs/testing/src/index.ts',
+ '^@frontmcp/sdk$': '/../../../libs/sdk/src/index.ts',
+ '^@frontmcp/adapters$': '/../../../libs/adapters/src/index.ts',
+ },
+ coverageDirectory: '../../../coverage/e2e/demo-e2e-esm',
+ ...e2eCoveragePreset,
+};
+
+export default config;
diff --git a/apps/e2e/demo-e2e-esm/jest.perf.config.ts b/apps/e2e/demo-e2e-esm/jest.perf.config.ts
new file mode 100644
index 000000000..00ba205bd
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/jest.perf.config.ts
@@ -0,0 +1,51 @@
+import type { Config } from '@jest/types';
+
+const config: Config.InitialOptions = {
+ displayName: 'demo-e2e-esm-perf',
+ preset: '../../../jest.preset.js',
+ testEnvironment: 'node',
+ testMatch: ['/e2e/**/*.perf.spec.ts'],
+ testTimeout: 120000,
+ maxWorkers: 1,
+ setupFilesAfterEnv: [
+ '/../../../libs/testing/src/setup.ts',
+ '/../../../libs/testing/src/perf/perf-setup.ts',
+ ],
+ transformIgnorePatterns: ['node_modules/(?!(jose)/)'],
+ transform: {
+ '^.+\\.[tj]s$': [
+ '@swc/jest',
+ {
+ jsc: {
+ parser: {
+ syntax: 'typescript',
+ decorators: true,
+ },
+ transform: {
+ decoratorMetadata: true,
+ },
+ target: 'es2022',
+ },
+ },
+ ],
+ },
+ moduleNameMapper: {
+ '^@frontmcp/testing$': '/../../../libs/testing/src/index.ts',
+ '^@frontmcp/sdk$': '/../../../libs/sdk/src/index.ts',
+ '^@frontmcp/adapters$': '/../../../libs/adapters/src/index.ts',
+ '^@frontmcp/utils$': '/../../../libs/utils/src/index.ts',
+ },
+ reporters: [
+ 'default',
+ [
+ '/../../../libs/testing/src/perf/jest-perf-reporter.js',
+ {
+ outputDir: 'perf-results/demo-e2e-esm',
+ baselinePath: 'perf-results/baseline.json',
+ verbose: true,
+ },
+ ],
+ ],
+};
+
+export default config;
diff --git a/apps/e2e/demo-e2e-esm/playwright.config.ts b/apps/e2e/demo-e2e-esm/playwright.config.ts
new file mode 100644
index 000000000..a9e05c6a7
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/playwright.config.ts
@@ -0,0 +1,21 @@
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './e2e/browser',
+ testMatch: '**/*.pw.spec.ts',
+ fullyParallel: false,
+ workers: 1,
+ timeout: 90_000,
+ use: {
+ headless: true,
+ baseURL: 'http://127.0.0.1:4402',
+ ...devices['Desktop Chrome'],
+ },
+ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+ webServer: {
+ command:
+ 'npx vite build --config apps/e2e/demo-e2e-esm/browser-app/vite.config.ts && npx vite preview --config apps/e2e/demo-e2e-esm/browser-app/vite.config.ts',
+ port: 4402,
+ reuseExistingServer: !process.env['CI'],
+ },
+});
diff --git a/apps/e2e/demo-e2e-esm/project.json b/apps/e2e/demo-e2e-esm/project.json
new file mode 100644
index 000000000..c137cec68
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/project.json
@@ -0,0 +1,70 @@
+{
+ "name": "demo-e2e-esm",
+ "$schema": "../../../node_modules/nx/schemas/project-schema.json",
+ "sourceRoot": "apps/e2e/demo-e2e-esm/src",
+ "projectType": "application",
+ "tags": ["scope:demo", "type:e2e", "feature:esm"],
+ "targets": {
+ "build": {
+ "executor": "@nx/webpack:webpack",
+ "outputs": ["{options.outputPath}"],
+ "defaultConfiguration": "development",
+ "options": {
+ "target": "node",
+ "compiler": "tsc",
+ "outputPath": "dist/apps/e2e/demo-e2e-esm",
+ "main": "apps/e2e/demo-e2e-esm/src/main.ts",
+ "tsConfig": "apps/e2e/demo-e2e-esm/tsconfig.app.json",
+ "webpackConfig": "apps/e2e/demo-e2e-esm/webpack.config.js",
+ "generatePackageJson": true
+ },
+ "configurations": {
+ "development": {},
+ "production": {
+ "optimization": true
+ }
+ }
+ },
+ "serve": {
+ "executor": "nx:run-commands",
+ "dependsOn": ["build"],
+ "options": {
+ "command": "node dist/apps/e2e/demo-e2e-esm/main.js",
+ "cwd": "{workspaceRoot}"
+ }
+ },
+ "test": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/coverage/apps/e2e/demo-e2e-esm"],
+ "options": {
+ "jestConfig": "apps/e2e/demo-e2e-esm/jest.e2e.config.ts",
+ "passWithNoTests": true
+ }
+ },
+ "test:e2e": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/coverage/apps/e2e/demo-e2e-esm-e2e"],
+ "options": {
+ "jestConfig": "apps/e2e/demo-e2e-esm/jest.e2e.config.ts",
+ "runInBand": true,
+ "passWithNoTests": true
+ }
+ },
+ "test:perf": {
+ "executor": "@nx/jest:jest",
+ "outputs": ["{workspaceRoot}/perf-results/demo-e2e-esm"],
+ "options": {
+ "jestConfig": "apps/e2e/demo-e2e-esm/jest.perf.config.ts",
+ "runInBand": true,
+ "passWithNoTests": true
+ }
+ },
+ "test:pw": {
+ "executor": "nx:run-commands",
+ "options": {
+ "command": "npx playwright test --config apps/e2e/demo-e2e-esm/playwright.config.ts",
+ "cwd": "{workspaceRoot}"
+ }
+ }
+ }
+}
diff --git a/apps/e2e/demo-e2e-esm/src/esm-package-server/fixtures/decorated-package.ts b/apps/e2e/demo-e2e-esm/src/esm-package-server/fixtures/decorated-package.ts
new file mode 100644
index 000000000..0749d463a
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/src/esm-package-server/fixtures/decorated-package.ts
@@ -0,0 +1,129 @@
+/**
+ * @file decorated-package.ts
+ * @description A real TypeScript ESM package fixture that uses actual decorators
+ * from @frontmcp/sdk for all core components. This file is transpiled with esbuild
+ * (all deps externalized) and served by the ESM package server as @test/esm-decorated@1.0.0.
+ *
+ * Uses named exports — the ESM loader scans all exports and detects decorated classes
+ * by their decorator metadata, grouping them into the appropriate manifest arrays.
+ *
+ * Components:
+ * - 2 Tools: echo, add
+ * - 1 Resource: status
+ * - 1 Prompt: greeting-prompt
+ * - 1 Skill: review-code (inline instructions)
+ * - 1 Job: process-data (input/output schemas)
+ */
+import 'reflect-metadata';
+import { Tool, ToolContext, Resource, Prompt, Skill, Job, JobContext } from '@frontmcp/sdk';
+import { z } from 'zod';
+
+// ═══════════════════════════════════════════════════════════════════
+// TOOLS
+// ═══════════════════════════════════════════════════════════════════
+
+@Tool({
+ name: 'echo',
+ description: 'Echoes the input message back',
+ inputSchema: { message: z.string() },
+})
+export class EchoTool extends ToolContext {
+ async execute({ message }: { message: string }) {
+ return message;
+ }
+}
+
+@Tool({
+ name: 'add',
+ description: 'Adds two numbers together',
+ inputSchema: {
+ a: z.number().describe('First number'),
+ b: z.number().describe('Second number'),
+ },
+})
+export class AddTool extends ToolContext {
+ async execute({ a, b }: { a: number; b: number }) {
+ return a + b;
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// RESOURCES
+// ═══════════════════════════════════════════════════════════════════
+
+@Resource({
+ name: 'status',
+ uri: 'esm://status',
+ mimeType: 'application/json',
+ description: 'Server status from decorated package',
+})
+export class StatusResource {
+ execute() {
+ return {
+ contents: [
+ {
+ uri: 'esm://status',
+ text: JSON.stringify({ status: 'ok', source: 'decorated-package' }),
+ },
+ ],
+ };
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// PROMPTS
+// ═══════════════════════════════════════════════════════════════════
+
+@Prompt({
+ name: 'greeting-prompt',
+ description: 'A greeting prompt template',
+ arguments: [{ name: 'name', description: 'Name to greet', required: true }],
+})
+export class GreetingPrompt {
+ execute(args: Record) {
+ return {
+ messages: [
+ {
+ role: 'user' as const,
+ content: {
+ type: 'text' as const,
+ text: `Please greet ${args?.['name'] ?? 'someone'} warmly.`,
+ },
+ },
+ ],
+ };
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// SKILLS
+// ═══════════════════════════════════════════════════════════════════
+
+@Skill({
+ name: 'review-code',
+ description: 'Reviews code for best practices and issues',
+ instructions: 'Step 1: Read the code file.\nStep 2: Check for common issues.\nStep 3: Suggest improvements.',
+ tools: ['file_read', 'code_search'],
+ tags: ['code-review', 'quality'],
+})
+export class ReviewCodeSkill {}
+
+// ═══════════════════════════════════════════════════════════════════
+// JOBS
+// ═══════════════════════════════════════════════════════════════════
+
+@Job({
+ name: 'process-data',
+ description: 'Processes an array of items and returns a count',
+ inputSchema: {
+ items: z.array(z.string()).describe('Items to process'),
+ },
+ outputSchema: {
+ processed: z.number().describe('Number of items processed'),
+ },
+})
+export class ProcessDataJob extends JobContext {
+ async execute({ items }: { items: string[] }) {
+ return { processed: items.length };
+ }
+}
diff --git a/apps/e2e/demo-e2e-esm/src/esm-package-server/fixtures/prompts-only-package.ts b/apps/e2e/demo-e2e-esm/src/esm-package-server/fixtures/prompts-only-package.ts
new file mode 100644
index 000000000..cd076c645
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/src/esm-package-server/fixtures/prompts-only-package.ts
@@ -0,0 +1,32 @@
+/**
+ * @file prompts-only-package.ts
+ * @description ESM fixture with only @Prompt decorated classes as named exports.
+ * The ESM loader detects decorated classes automatically — no manifest needed.
+ */
+import 'reflect-metadata';
+import { Prompt } from '@frontmcp/sdk';
+
+@Prompt({
+ name: 'summarize',
+ description: 'Summarize the provided text',
+ arguments: [
+ { name: 'text', description: 'Text to summarize', required: true },
+ { name: 'style', description: 'Summary style (brief/detailed)', required: false },
+ ],
+})
+export class SummarizePrompt {
+ execute(args: Record) {
+ const style = args?.['style'] ?? 'brief';
+ return {
+ messages: [
+ {
+ role: 'user' as const,
+ content: {
+ type: 'text' as const,
+ text: `Please provide a ${style} summary of: ${args?.['text'] ?? ''}`,
+ },
+ },
+ ],
+ };
+ }
+}
diff --git a/apps/e2e/demo-e2e-esm/src/esm-package-server/fixtures/resources-only-package.ts b/apps/e2e/demo-e2e-esm/src/esm-package-server/fixtures/resources-only-package.ts
new file mode 100644
index 000000000..c7d3db53f
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/src/esm-package-server/fixtures/resources-only-package.ts
@@ -0,0 +1,45 @@
+/**
+ * @file resources-only-package.ts
+ * @description ESM fixture with only @Resource decorated classes as named exports.
+ * The ESM loader detects decorated classes automatically — no manifest needed.
+ */
+import 'reflect-metadata';
+import { Resource } from '@frontmcp/sdk';
+
+@Resource({
+ name: 'config',
+ uri: 'esm://config',
+ mimeType: 'application/json',
+ description: 'Application configuration',
+})
+export class ConfigResource {
+ execute() {
+ return {
+ contents: [
+ {
+ uri: 'esm://config',
+ text: JSON.stringify({ env: 'test', version: '1.0.0' }),
+ },
+ ],
+ };
+ }
+}
+
+@Resource({
+ name: 'health',
+ uri: 'esm://health',
+ mimeType: 'application/json',
+ description: 'Health check endpoint',
+})
+export class HealthResource {
+ execute() {
+ return {
+ contents: [
+ {
+ uri: 'esm://health',
+ text: JSON.stringify({ healthy: true, uptime: 12345 }),
+ },
+ ],
+ };
+ }
+}
diff --git a/apps/e2e/demo-e2e-esm/src/esm-package-server/fixtures/tools-only-package.ts b/apps/e2e/demo-e2e-esm/src/esm-package-server/fixtures/tools-only-package.ts
new file mode 100644
index 000000000..2f7d0eb34
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/src/esm-package-server/fixtures/tools-only-package.ts
@@ -0,0 +1,33 @@
+/**
+ * @file tools-only-package.ts
+ * @description ESM fixture with only @Tool decorated classes as named exports.
+ * The ESM loader detects decorated classes automatically — no manifest needed.
+ */
+import 'reflect-metadata';
+import { Tool, ToolContext } from '@frontmcp/sdk';
+import { z } from 'zod';
+
+@Tool({
+ name: 'multiply',
+ description: 'Multiplies two numbers',
+ inputSchema: {
+ x: z.number().describe('First number'),
+ y: z.number().describe('Second number'),
+ },
+})
+export class MultiplyTool extends ToolContext {
+ async execute({ x, y }: { x: number; y: number }) {
+ return x * y;
+ }
+}
+
+@Tool({
+ name: 'uppercase',
+ description: 'Converts text to uppercase',
+ inputSchema: { text: z.string() },
+})
+export class UppercaseTool extends ToolContext {
+ async execute({ text }: { text: string }) {
+ return text.toUpperCase();
+ }
+}
diff --git a/apps/e2e/demo-e2e-esm/src/esm-package-server/main.ts b/apps/e2e/demo-e2e-esm/src/esm-package-server/main.ts
new file mode 100644
index 000000000..b6133d34e
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/src/esm-package-server/main.ts
@@ -0,0 +1,388 @@
+/**
+ * @file main.ts
+ * @description Local ESM package server for E2E testing.
+ * Serves fake npm registry metadata and CJS fixture bundles for the loader's
+ * cache-to-native-import bridge.
+ *
+ * Packages served:
+ * - @test/esm-tools v1.0.0 — 2 tools: echo, add (plain objects)
+ * - @test/esm-multi v1.0.0 — 1 tool + 1 resource + 1 prompt (plain objects)
+ * - @test/esm-decorated v1.0.0 — 2 tools + 1 resource + 1 prompt (real @Tool/@Resource/@Prompt decorators, esbuild-transpiled)
+ *
+ * URL patterns (same as esm.sh / npm registry):
+ * - GET /{packageName} → npm registry JSON (versions, dist-tags)
+ * - GET /{packageName}@{ver} → CJS bundle source code
+ */
+
+import * as http from 'node:http';
+import { buildSync } from 'esbuild';
+import * as path from 'node:path';
+
+const rawPort = parseInt(process.env['PORT'] ?? process.env['ESM_SERVER_PORT'] ?? '50400', 10);
+const port = Number.isInteger(rawPort) && rawPort > 0 && rawPort <= 65535 ? rawPort : 50400;
+
+// ═══════════════════════════════════════════════════════════════════
+// FIXTURE BUNDLES (CJS format — the cache bridge unwraps the nested default on import)
+// ═══════════════════════════════════════════════════════════════════
+
+const ESM_TOOLS_BUNDLE = `
+module.exports = {
+ default: {
+ name: '@test/esm-tools',
+ version: '1.0.0',
+ tools: [
+ {
+ name: 'echo',
+ description: 'Echoes the input message back',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ message: { type: 'string', description: 'Message to echo' },
+ },
+ required: ['message'],
+ },
+ execute: async (input) => ({
+ content: [{ type: 'text', text: JSON.stringify(input) }],
+ }),
+ },
+ {
+ name: 'add',
+ description: 'Adds two numbers together',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ a: { type: 'number', description: 'First number' },
+ b: { type: 'number', description: 'Second number' },
+ },
+ required: ['a', 'b'],
+ },
+ execute: async (input) => ({
+ content: [{ type: 'text', text: String(Number(input.a) + Number(input.b)) }],
+ }),
+ },
+ ],
+ },
+};
+`;
+
+const ESM_MULTI_BUNDLE = `
+module.exports = {
+ default: {
+ name: '@test/esm-multi',
+ version: '1.0.0',
+ tools: [
+ {
+ name: 'greet',
+ description: 'Greets a user by name',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ name: { type: 'string', description: 'Name to greet' },
+ },
+ required: ['name'],
+ },
+ execute: async (input) => ({
+ content: [{ type: 'text', text: 'Hello, ' + (input.name || 'world') + '!' }],
+ }),
+ },
+ ],
+ resources: [
+ {
+ name: 'status',
+ description: 'Server status',
+ uri: 'esm://status',
+ mimeType: 'application/json',
+ read: async () => ({
+ contents: [{ uri: 'esm://status', text: JSON.stringify({ status: 'ok', source: 'esm-multi' }) }],
+ }),
+ },
+ ],
+ prompts: [
+ {
+ name: 'greeting-prompt',
+ description: 'A greeting prompt template',
+ arguments: [
+ { name: 'name', description: 'Name to greet', required: true },
+ ],
+ execute: async (args) => ({
+ messages: [
+ {
+ role: 'user',
+ content: { type: 'text', text: 'Please greet ' + (args.name || 'someone') + ' warmly.' },
+ },
+ ],
+ }),
+ },
+ ],
+ },
+};
+`;
+
+// ═══════════════════════════════════════════════════════════════════
+// DECORATED FIXTURE (esbuild-transpiled TypeScript with real decorators)
+// ═══════════════════════════════════════════════════════════════════
+
+function buildFixture(filename: string): string {
+ const result = buildSync({
+ entryPoints: [path.join(__dirname, 'fixtures', filename)],
+ bundle: true,
+ format: 'cjs',
+ platform: 'node',
+ target: 'es2022',
+ write: false,
+ external: ['@frontmcp/sdk', 'zod', 'reflect-metadata'],
+ });
+ // Strip esbuild's CJS annotation comment that contains the word "import",
+ // which would cause the ESM loader's isEsmSource() to misdetect this as ESM
+ return result.outputFiles[0].text.replace(/\/\/ Annotate the CommonJS export names for ESM import in node:\n/g, '');
+}
+
+const ESM_DECORATED_BUNDLE = buildFixture('decorated-package.ts');
+const ESM_TOOLS_DECORATED_BUNDLE = buildFixture('tools-only-package.ts');
+const ESM_RESOURCES_DECORATED_BUNDLE = buildFixture('resources-only-package.ts');
+const ESM_PROMPTS_DECORATED_BUNDLE = buildFixture('prompts-only-package.ts');
+
+// ═══════════════════════════════════════════════════════════════════
+// PACKAGE REGISTRY
+// ═══════════════════════════════════════════════════════════════════
+
+interface VersionEntry {
+ bundle: string;
+ publishedAt: string;
+ etag: string;
+}
+
+interface PackageEntry {
+ name: string;
+ versions: Record;
+ 'dist-tags': Record;
+}
+
+function createVersionEntry(packageName: string, version: string, bundle: string): VersionEntry {
+ return {
+ bundle,
+ publishedAt: new Date().toISOString(),
+ etag: `"${packageName}@${version}:${Date.now()}"`,
+ };
+}
+
+const packages = new Map();
+
+packages.set('@test/esm-tools', {
+ name: '@test/esm-tools',
+ versions: { '1.0.0': createVersionEntry('@test/esm-tools', '1.0.0', ESM_TOOLS_BUNDLE) },
+ 'dist-tags': { latest: '1.0.0' },
+});
+
+packages.set('@test/esm-multi', {
+ name: '@test/esm-multi',
+ versions: { '1.0.0': createVersionEntry('@test/esm-multi', '1.0.0', ESM_MULTI_BUNDLE) },
+ 'dist-tags': { latest: '1.0.0' },
+});
+
+packages.set('@test/esm-decorated', {
+ name: '@test/esm-decorated',
+ versions: { '1.0.0': createVersionEntry('@test/esm-decorated', '1.0.0', ESM_DECORATED_BUNDLE) },
+ 'dist-tags': { latest: '1.0.0' },
+});
+
+packages.set('@test/esm-tools-decorated', {
+ name: '@test/esm-tools-decorated',
+ versions: { '1.0.0': createVersionEntry('@test/esm-tools-decorated', '1.0.0', ESM_TOOLS_DECORATED_BUNDLE) },
+ 'dist-tags': { latest: '1.0.0' },
+});
+
+packages.set('@test/esm-resources-decorated', {
+ name: '@test/esm-resources-decorated',
+ versions: { '1.0.0': createVersionEntry('@test/esm-resources-decorated', '1.0.0', ESM_RESOURCES_DECORATED_BUNDLE) },
+ 'dist-tags': { latest: '1.0.0' },
+});
+
+packages.set('@test/esm-prompts-decorated', {
+ name: '@test/esm-prompts-decorated',
+ versions: { '1.0.0': createVersionEntry('@test/esm-prompts-decorated', '1.0.0', ESM_PROMPTS_DECORATED_BUNDLE) },
+ 'dist-tags': { latest: '1.0.0' },
+});
+
+// ═══════════════════════════════════════════════════════════════════
+// REQUEST HANDLER
+// ═══════════════════════════════════════════════════════════════════
+
+function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
+ const url = req.url ?? '/';
+ const urlObj = new URL(url, `http://127.0.0.1:${port}`);
+ let pathname: string;
+ try {
+ pathname = decodeURIComponent(urlObj.pathname).slice(1); // Remove leading /
+ } catch {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid URL encoding' }));
+ return;
+ }
+
+ // Admin endpoint for runtime publishing (used by hot-reload E2E tests)
+ if (req.method === 'POST' && pathname === '_admin/publish') {
+ handleAdminPublish(req, res);
+ return;
+ }
+
+ // Check if this is a versioned request: @scope/name@1.0.0
+ const versionMatch = pathname.match(/^(.+?)@(\d+\.\d+\.\d+.*)$/);
+
+ if (versionMatch) {
+ serveBundleRequest(res, versionMatch[1], versionMatch[2]);
+ } else {
+ serveRegistryRequest(res, pathname);
+ }
+}
+
+/**
+ * POST /_admin/publish — publish a new version at runtime.
+ * Body: { "package": "@test/esm-tools", "version": "2.0.0", "bundle": "module.exports = ..." }
+ */
+const MAX_ADMIN_BODY_BYTES = 1_048_576; // 1 MB
+
+function handleAdminPublish(req: http.IncomingMessage, res: http.ServerResponse): void {
+ const chunks: Buffer[] = [];
+ let bodyBytes = 0;
+ let aborted = false;
+ req.on('error', () => {
+ if (!aborted) {
+ aborted = true;
+ if (!res.headersSent) {
+ res.writeHead(499, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Client disconnected' }));
+ }
+ }
+ });
+ req.on('data', (chunk: Buffer) => {
+ if (aborted) return;
+ bodyBytes += chunk.length;
+ if (bodyBytes > MAX_ADMIN_BODY_BYTES) {
+ aborted = true;
+ res.writeHead(413, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Payload too large' }));
+ req.destroy();
+ return;
+ }
+ chunks.push(chunk);
+ });
+ req.on('end', () => {
+ if (aborted) return;
+ const body = Buffer.concat(chunks).toString('utf8');
+ try {
+ const data = JSON.parse(body) as {
+ package: string;
+ version: string;
+ bundle: string;
+ };
+
+ if (!data.package || !data.version || !data.bundle) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Missing required fields: package, version, bundle' }));
+ return;
+ }
+
+ let pkg = packages.get(data.package);
+ if (!pkg) {
+ pkg = {
+ name: data.package,
+ versions: {},
+ 'dist-tags': { latest: data.version },
+ };
+ packages.set(data.package, pkg);
+ }
+
+ pkg.versions[data.version] = createVersionEntry(data.package, data.version, data.bundle);
+ pkg['dist-tags']['latest'] = data.version;
+
+ console.log(`[admin] Published ${data.package}@${data.version}`);
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, package: data.package, version: data.version }));
+ } catch {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid JSON body' }));
+ }
+ });
+}
+
+function serveRegistryRequest(res: http.ServerResponse, packageName: string): void {
+ const pkg = packages.get(packageName);
+ if (!pkg) {
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Not found' }));
+ return;
+ }
+
+ const versions: Record = {};
+ const time: Record = {};
+
+ for (const [ver, entry] of Object.entries(pkg.versions)) {
+ versions[ver] = { version: ver, name: pkg.name };
+ time[ver] = entry.publishedAt;
+ }
+
+ const registryData = {
+ name: pkg.name,
+ 'dist-tags': pkg['dist-tags'],
+ versions,
+ time,
+ };
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(registryData));
+}
+
+function serveBundleRequest(res: http.ServerResponse, packageName: string, version: string): void {
+ const pkg = packages.get(packageName);
+ if (!pkg) {
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
+ res.end(`Package "${packageName}" not found`);
+ return;
+ }
+
+ const versionEntry = pkg.versions[version];
+ if (!versionEntry) {
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
+ res.end(`Version "${version}" not found for "${packageName}"`);
+ return;
+ }
+
+ res.writeHead(200, {
+ 'Content-Type': 'application/javascript',
+ ETag: versionEntry.etag,
+ });
+ res.end(versionEntry.bundle);
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// SERVER STARTUP
+// ═══════════════════════════════════════════════════════════════════
+
+const server = http.createServer(handleRequest);
+
+server.on('error', (err: NodeJS.ErrnoException) => {
+ if (err.code === 'EADDRINUSE') {
+ console.error(`Port ${port} is already in use. Packages: ${[...packages.keys()].join(', ')}`);
+ } else {
+ console.error(`Server error: ${err.message}`);
+ }
+ process.exit(1);
+});
+
+server.listen(port, '127.0.0.1', () => {
+ const baseUrl = `http://127.0.0.1:${port}`;
+ console.log(`ESM Package Server started:`);
+ console.log(` Registry URL: ${baseUrl}`);
+ console.log(` ESM Base URL: ${baseUrl}`);
+ console.log(` Port: ${port}`);
+ console.log(` Packages: ${[...packages.keys()].join(', ')}`);
+});
+
+process.on('SIGINT', () => {
+ server.close(() => process.exit(0));
+});
+process.on('SIGTERM', () => {
+ server.close(() => process.exit(0));
+});
diff --git a/apps/e2e/demo-e2e-esm/src/main-hot-reload.ts b/apps/e2e/demo-e2e-esm/src/main-hot-reload.ts
new file mode 100644
index 000000000..89bbe69cf
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/src/main-hot-reload.ts
@@ -0,0 +1,23 @@
+import { FrontMcp, App, LogLevel } from '@frontmcp/sdk';
+
+const port = parseInt(process.env['PORT'] ?? '3116', 10);
+const esmServerUrl = `http://127.0.0.1:${parseInt(process.env['ESM_SERVER_PORT'] ?? '50400', 10)}`;
+
+@FrontMcp({
+ info: { name: 'Demo E2E ESM Hot-Reload', version: '0.1.0' },
+ loader: { url: esmServerUrl },
+ apps: [
+ App.esm('@test/esm-tools@^1.0.0', {
+ namespace: 'esm',
+ autoUpdate: { enabled: true, intervalMs: 2000 },
+ cacheTTL: 1000,
+ }),
+ ],
+ logging: { level: LogLevel.Warn },
+ http: { port },
+ auth: { mode: 'public' },
+ transport: {
+ protocol: { json: true, legacy: true, strictSession: false },
+ },
+})
+export default class Server {}
diff --git a/apps/e2e/demo-e2e-esm/src/main.ts b/apps/e2e/demo-e2e-esm/src/main.ts
new file mode 100644
index 000000000..db93d1698
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/src/main.ts
@@ -0,0 +1,21 @@
+import { FrontMcp, App, LogLevel } from '@frontmcp/sdk';
+
+const port = parseInt(process.env['PORT'] ?? '3115', 10);
+const esmServerUrl = `http://127.0.0.1:${parseInt(process.env['ESM_SERVER_PORT'] ?? '50400', 10)}`;
+
+@FrontMcp({
+ info: { name: 'Demo E2E ESM', version: '0.1.0' },
+ loader: { url: esmServerUrl },
+ apps: [
+ App.esm('@test/esm-tools@^1.0.0', { namespace: 'esm', cacheTTL: 60000 }),
+ App.esm('@test/esm-multi@^1.0.0', { namespace: 'multi' }),
+ App.esm('@test/esm-decorated@^1.0.0', { namespace: 'dec' }),
+ ],
+ logging: { level: LogLevel.Warn },
+ http: { port },
+ auth: { mode: 'public' },
+ transport: {
+ protocol: { json: true, legacy: true, strictSession: false },
+ },
+})
+export default class Server {}
diff --git a/apps/e2e/demo-e2e-esm/tsconfig.app.json b/apps/e2e/demo-e2e-esm/tsconfig.app.json
new file mode 100644
index 000000000..3fdc8911e
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/tsconfig.app.json
@@ -0,0 +1,13 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": "../../../dist/out-tsc",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "types": ["node"],
+ "emitDecoratorMetadata": true,
+ "experimentalDecorators": true
+ },
+ "exclude": ["jest.config.ts", "jest.e2e.config.ts", "src/**/*.spec.ts", "e2e/**/*.ts"],
+ "include": ["src/**/*.ts"]
+}
diff --git a/apps/e2e/demo-e2e-esm/tsconfig.json b/apps/e2e/demo-e2e-esm/tsconfig.json
new file mode 100644
index 000000000..f2fd67cbf
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "esModuleInterop": true,
+ "experimentalDecorators": true,
+ "emitDecoratorMetadata": true
+ },
+ "files": [],
+ "include": [],
+ "references": [
+ {
+ "path": "./tsconfig.app.json"
+ }
+ ],
+ "extends": "../../../tsconfig.base.json"
+}
diff --git a/apps/e2e/demo-e2e-esm/webpack.config.js b/apps/e2e/demo-e2e-esm/webpack.config.js
new file mode 100644
index 000000000..fe6283896
--- /dev/null
+++ b/apps/e2e/demo-e2e-esm/webpack.config.js
@@ -0,0 +1,28 @@
+const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin');
+const { join } = require('path');
+
+module.exports = {
+ output: {
+ path: join(__dirname, '../../../dist/apps/e2e/demo-e2e-esm'),
+ ...(process.env.NODE_ENV !== 'production' && {
+ devtoolModuleFilenameTemplate: '[absolute-resource-path]',
+ }),
+ },
+ mode: 'development',
+ devtool: 'eval-cheap-module-source-map',
+ plugins: [
+ new NxAppWebpackPlugin({
+ target: 'node',
+ compiler: 'tsc',
+ main: './src/main.ts',
+ sourceMap: true,
+ tsConfig: './tsconfig.app.json',
+ assets: [],
+ externalDependencies: 'all',
+ optimization: false,
+ outputHashing: 'none',
+ generatePackageJson: false,
+ buildLibsFromSource: true,
+ }),
+ ],
+};
diff --git a/docs/docs.json b/docs/docs.json
index b4e7827ec..d03f52dbc 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -92,6 +92,7 @@
"frontmcp/servers/elicitation",
"frontmcp/servers/skills",
"frontmcp/servers/discovery",
+ "frontmcp/servers/esm-packages",
"frontmcp/servers/flows",
"frontmcp/servers/jobs",
"frontmcp/servers/workflows"
@@ -158,7 +159,8 @@
"frontmcp/features/deployment-targets",
"frontmcp/features/plugin-system",
"frontmcp/features/testing-framework",
- "frontmcp/features/skill-based-workflows"
+ "frontmcp/features/skill-based-workflows",
+ "frontmcp/features/esm-dynamic-loading"
]
},
{
@@ -247,7 +249,8 @@
"frontmcp/guides/create-plugin",
"frontmcp/guides/customize-flow-stages",
"frontmcp/guides/building-tool-ui",
- "frontmcp/guides/prompts-and-resources"
+ "frontmcp/guides/prompts-and-resources",
+ "frontmcp/guides/publishing-esm-packages"
]
},
{
@@ -339,7 +342,8 @@
"frontmcp/sdk-reference/errors/decorator-errors",
"frontmcp/sdk-reference/errors/normalization-errors",
"frontmcp/sdk-reference/errors/sdk-errors",
- "frontmcp/sdk-reference/errors/auth-internal-errors"
+ "frontmcp/sdk-reference/errors/auth-internal-errors",
+ "frontmcp/sdk-reference/errors/esm-errors"
]
}
]
diff --git a/docs/frontmcp/deployment/browser-compatibility.mdx b/docs/frontmcp/deployment/browser-compatibility.mdx
index 1d75836bd..67a18d1cb 100644
--- a/docs/frontmcp/deployment/browser-compatibility.mdx
+++ b/docs/frontmcp/deployment/browser-compatibility.mdx
@@ -17,6 +17,7 @@ FrontMCP supports browser environments for client-side use cases like JWT verifi
| **Storage adapters** | `MemoryStorageAdapter`, `IndexedDBStorageAdapter`, `LocalStorageAdapter` |
| **Agent adapters** | `OpenAIAdapter`, `AnthropicAdapter` (HTTP-based, no Node dependencies) |
| **MCP client** | Direct client connections |
+| **ESM Dynamic Loading** | In-memory cache mode, Blob URL module evaluation via `App.esm()` |
## What's Node-only
@@ -92,6 +93,20 @@ When using LLM adapters in the browser, API keys are exposed to the client. Use
---
+## ESM Dynamic Loading in Browser
+
+The [`App.esm()`](/frontmcp/servers/esm-packages) API works in browser environments with these differences:
+
+- **Cache**: Memory-only (no disk persistence). Bundles are stored in an in-memory `Map`.
+- **Module evaluation**: Bundles are evaluated via `Blob` + `URL.createObjectURL` instead of writing to the file system.
+- **Same API**: No code changes needed — the environment is auto-detected.
+
+
+Browser-loaded ESM packages must avoid Node.js-only modules (`fs`, `crypto`, `path`) at the top level. Use dynamic imports for platform-specific code.
+
+
+---
+
## Crypto Operations
All `@frontmcp/utils` crypto functions use the WebCrypto API in browser and `node:crypto` in Node.js:
diff --git a/docs/frontmcp/features/esm-dynamic-loading.mdx b/docs/frontmcp/features/esm-dynamic-loading.mdx
new file mode 100644
index 000000000..46910a19b
--- /dev/null
+++ b/docs/frontmcp/features/esm-dynamic-loading.mdx
@@ -0,0 +1,74 @@
+---
+title: ESM Dynamic Loading
+slug: features/esm-dynamic-loading
+icon: box-open
+description: Load npm packages at runtime as MCP apps with caching and auto-update
+---
+
+FrontMCP can **dynamically load npm packages at runtime** and register their tools, resources, and prompts alongside your local apps. Packages are fetched from a CDN, cached locally (memory + disk), and executed in-process — with optional background polling for automatic updates.
+
+## Three App Types
+
+FrontMCP supports three ways to compose apps:
+
+| Type | Declaration | Execution | Use Case |
+|------|------------|-----------|----------|
+| **Local** | `@App` class | In-process | First-party code you own |
+| **ESM Package** | `App.esm()` | In-process | Community or internal npm packages |
+| **Remote** | `App.remote()` | HTTP proxy | External MCP servers |
+
+```ts
+import { FrontMcp, App } from '@frontmcp/sdk';
+
+@FrontMcp({
+ info: { name: 'Platform', version: '1.0.0' },
+ apps: [
+ CrmApp, // Local app
+ App.esm('@acme/analytics@^2.0.0'), // ESM package
+ App.remote('https://ext.example.com/mcp'), // Remote app
+ ],
+})
+export default class Server {}
+```
+
+## Key Capabilities
+
+1. **Dynamic Loading** — Import npm packages at server startup via `App.esm()` without bundling them into your build
+2. **Two-Tier Caching** — In-memory + disk cache with configurable TTL for fast startup and offline resilience
+3. **Version Polling** — Background semver-aware polling with automatic hot-reload when new versions are published
+4. **Private Registries** — Token-based authentication for private npm registries and custom CDN endpoints
+5. **Browser Support** — Same `App.esm()` API works in browser environments with in-memory-only caching
+
+## Minimal Example
+
+```ts
+import { FrontMcp, App } from '@frontmcp/sdk';
+
+@FrontMcp({
+ info: { name: 'My Server', version: '1.0.0' },
+ apps: [
+ App.esm('@acme/mcp-tools@^1.0.0', {
+ namespace: 'acme',
+ autoUpdate: { enabled: true },
+ }),
+ ],
+})
+export default class Server {}
+```
+
+## Next Steps
+
+
+
+ Full API reference for App.esm(), caching, auth, and configuration
+
+
+ Create and publish npm packages loadable by FrontMCP servers
+
+
+ Manage ESM packages with `frontmcp package esm-update`
+
+
+ Error classes for loading, caching, and authentication failures
+
+
diff --git a/docs/frontmcp/features/multi-app-composition.mdx b/docs/frontmcp/features/multi-app-composition.mdx
index d6aac48ee..aac4ce8b4 100644
--- a/docs/frontmcp/features/multi-app-composition.mdx
+++ b/docs/frontmcp/features/multi-app-composition.mdx
@@ -76,6 +76,26 @@ class CrmApp {}
Tools in `CrmApp` can access both `SharedCacheProvider` and `CrmDatabaseProvider`, while tools in `AnalyticsApp` can only access `SharedCacheProvider`.
+## ESM Package Apps
+
+You can also compose apps from npm packages or remote MCP servers at runtime via `App.esm()` and `App.remote()`:
+
+```ts
+import { FrontMcp, App } from '@frontmcp/sdk';
+
+@FrontMcp({
+ info: { name: 'Platform', version: '1.0.0' },
+ apps: [
+ CrmApp, // Local app
+ App.esm('@acme/analytics@^2.0.0'), // ESM package
+ App.remote('https://ext.example.com/mcp'), // Remote app
+ ],
+})
+export default class Server {}
+```
+
+ESM packages get their own tool, resource, and prompt registries with full hook and lifecycle support, but do not support plugins or adapters (use local `@App` classes for those).
+
## Learn More
@@ -85,4 +105,7 @@ Tools in `CrmApp` can access both `SharedCacheProvider` and `CrmDatabaseProvider
Server-level composition and configuration
+
+ Load npm packages at runtime as MCP apps
+
diff --git a/docs/frontmcp/features/overview.mdx b/docs/frontmcp/features/overview.mdx
index 6704e4707..2cfbfc819 100644
--- a/docs/frontmcp/features/overview.mdx
+++ b/docs/frontmcp/features/overview.mdx
@@ -40,6 +40,9 @@ FrontMCP is a TypeScript-first framework for building production-ready MCP serve
Compose jobs into DAG-based multi-step pipelines
+
+ Load npm packages at runtime with caching, auto-update, and private registry auth
+
---
@@ -51,6 +54,7 @@ graph TD
FM["@FrontMcp Server"] --> A1["@App: CRM"]
FM --> A2["@App: Analytics"]
FM --> A3["@App: Admin"]
+ FM --> A4["App.esm()"]
A1 --> T1["@Tool: CreateLead"]
A1 --> T2["@Tool: GetContacts"]
@@ -89,3 +93,4 @@ graph TD
| **Workflows** | DAG-based multi-step pipelines | `@Workflow` | [Workflows](/frontmcp/servers/workflows) |
| **Flows** | Request lifecycle pipelines | `@Flow` | [Flows](/frontmcp/servers/flows) |
| **Apps** | Modular capability containers | `@App` | [Apps](/frontmcp/servers/apps) |
+| **ESM Packages** | Runtime npm package loading | `App.esm()` | [ESM Packages](/frontmcp/servers/esm-packages) |
diff --git a/docs/frontmcp/getting-started/cli-reference.mdx b/docs/frontmcp/getting-started/cli-reference.mdx
index ac5251505..a83fbc5ed 100644
--- a/docs/frontmcp/getting-started/cli-reference.mdx
+++ b/docs/frontmcp/getting-started/cli-reference.mdx
@@ -55,6 +55,29 @@ Install, configure, and manage MCP apps from npm, local paths, or git repositori
| `install ` | Install an MCP app from npm, local path, or git |
| `uninstall ` | Remove an installed MCP app |
| `configure ` | Re-run setup questionnaire for an installed app |
+| `esm-update [app-name]` | Check and apply ESM package updates |
+
+---
+
+### ESM Update Options
+
+| Option | Description |
+| -------------- | ----------------------------------------------- |
+| `--check-only` | Only check for updates, don't apply them |
+| `--all` | Update all ESM-installed apps |
+
+```bash
+# Check all ESM apps for updates
+frontmcp package esm-update --all --check-only
+
+# Apply updates for a specific app
+frontmcp package esm-update my-esm-app
+
+# Apply all pending updates
+frontmcp package esm-update --all
+```
+
+See [ESM Packages](/frontmcp/servers/esm-packages) for full documentation on ESM dynamic loading.
---
diff --git a/docs/frontmcp/guides/publishing-esm-packages.mdx b/docs/frontmcp/guides/publishing-esm-packages.mdx
new file mode 100644
index 000000000..1c37f81d1
--- /dev/null
+++ b/docs/frontmcp/guides/publishing-esm-packages.mdx
@@ -0,0 +1,363 @@
+---
+title: Publishing ESM Packages
+slug: guides/publishing-esm-packages
+icon: box-archive
+description: Create and publish npm packages that FrontMCP servers can load at runtime
+---
+
+This guide walks you through creating an npm package that FrontMCP servers can load at runtime via [`App.esm()`](/frontmcp/servers/esm-packages). By the end, you'll have a published package with tools, resources, and prompts that any FrontMCP server can consume.
+
+## Prerequisites
+
+
+- Node.js 18+ and npm
+- An npm account (or access to a private registry)
+- Familiarity with [FrontMCP tools](/frontmcp/servers/tools), [resources](/frontmcp/servers/resources), and [prompts](/frontmcp/servers/prompts)
+
+
+---
+
+## The Package Manifest Contract
+
+Every ESM package must export a `FrontMcpPackageManifest` — an object declaring what the package provides:
+
+```ts
+interface FrontMcpPackageManifest {
+ name: string; // Package name (should match npm name)
+ version: string; // Package version (should match npm version)
+ description?: string; // Optional description
+ tools?: unknown[]; // Tool classes or plain tool objects
+ prompts?: unknown[]; // Prompt classes or plain prompt objects
+ resources?: unknown[]; // Resource classes or plain resource objects
+ skills?: unknown[]; // Skill definitions
+ agents?: unknown[]; // Agent classes or plain agent objects
+ jobs?: unknown[]; // Job classes or plain job objects
+ workflows?: unknown[]; // Workflow classes or plain workflow objects
+ providers?: unknown[]; // Shared providers for DI
+}
+```
+
+---
+
+## Step 1: Scaffold the Package
+
+```
+my-mcp-tools/
+├── package.json
+├── tsconfig.json
+├── src/
+│ ├── index.ts # Default export: FrontMcpPackageManifest
+│ └── tools/
+│ ├── echo.ts
+│ └── add.ts
+```
+
+---
+
+## Step 2: Create Tools
+
+FrontMCP ESM packages support two styles for defining tools:
+
+
+```ts Plain Object (No Decorators)
+// src/tools/echo.ts
+export const echoTool = {
+ name: 'echo',
+ description: 'Echoes the input message back',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ message: { type: 'string', description: 'Message to echo' },
+ },
+ required: ['message'],
+ },
+ execute: async (input: { message: string }) => ({
+ content: [{ type: 'text', text: input.message }],
+ }),
+};
+```
+
+```ts Decorated Class
+// src/tools/echo.ts
+import { Tool, ToolContext } from '@frontmcp/sdk';
+import { z } from 'zod';
+
+const Input = z.object({
+ message: z.string().describe('Message to echo'),
+});
+
+@Tool({
+ name: 'echo',
+ description: 'Echoes the input message back',
+ inputSchema: Input,
+})
+export class EchoTool extends ToolContext {
+ async execute(input: z.infer) {
+ return this.text(input.message);
+ }
+}
+```
+
+
+### Plain Object Contract
+
+When using plain objects, each tool must have:
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `name` | `string` | Yes | Tool name |
+| `description` | `string` | No | Human-readable description |
+| `inputSchema` | `object` | No | JSON Schema for input validation |
+| `execute` | `function` | Yes | `(input) => Promise` |
+
+The `execute` function receives the parsed input and must return a `CallToolResult` with a `content` array.
+
+---
+
+## Step 3: Create Resources & Prompts
+
+
+```ts Plain Object Resource
+export const statusResource = {
+ name: 'status',
+ description: 'Server status endpoint',
+ uri: 'my-tools://status',
+ mimeType: 'application/json',
+ read: async () => ({
+ contents: [{
+ uri: 'my-tools://status',
+ text: JSON.stringify({ status: 'ok' }),
+ }],
+ }),
+};
+```
+
+```ts Plain Object Prompt
+export const greetingPrompt = {
+ name: 'greeting',
+ description: 'A greeting prompt template',
+ arguments: [
+ { name: 'name', description: 'Name to greet', required: true },
+ ],
+ execute: async (args: { name: string }) => ({
+ messages: [{
+ role: 'user',
+ content: {
+ type: 'text',
+ text: `Please greet ${args.name} warmly.`,
+ },
+ }],
+ }),
+};
+```
+
+
+### Resource Contract
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `name` | `string` | Yes | Resource name |
+| `description` | `string` | No | Human-readable description |
+| `uri` | `string` | Yes | Resource URI (e.g., `my-tools://status`) |
+| `mimeType` | `string` | No | MIME type of the resource content |
+| `read` | `function` | Yes | `() => Promise` |
+
+### Prompt Contract
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `name` | `string` | Yes | Prompt name |
+| `description` | `string` | No | Human-readable description |
+| `arguments` | `array` | No | Array of `{ name, description?, required? }` |
+| `execute` | `function` | Yes | `(args) => Promise` |
+
+---
+
+## Step 4: Export the Manifest
+
+The package must export a `FrontMcpPackageManifest`. Three export formats are supported:
+
+
+```ts Default Export (Recommended)
+// src/index.ts
+import { echoTool } from './tools/echo';
+import { addTool } from './tools/add';
+import { statusResource } from './resources/status';
+
+export default {
+ name: '@acme/mcp-tools',
+ version: '1.0.0',
+ description: 'ACME MCP tools for task management',
+ tools: [echoTool, addTool],
+ resources: [statusResource],
+};
+```
+
+```ts Decorated Class Re-export
+// src/index.ts
+import { EchoTool } from './tools/echo';
+import { AddTool } from './tools/add';
+
+export default {
+ name: '@acme/mcp-tools',
+ version: '1.0.0',
+ tools: [EchoTool, AddTool], // @Tool-decorated classes
+};
+```
+
+```ts Named Exports
+// src/index.ts
+export const name = '@acme/mcp-tools';
+export const version = '1.0.0';
+export { echoTool, addTool } from './tools';
+export const tools = [echoTool, addTool];
+```
+
+
+
+The **default export** format is recommended for clarity and compatibility. FrontMCP's manifest normalizer tries the default export first, then falls back to named exports.
+
+
+---
+
+## Step 5: Configure package.json
+
+```json
+{
+ "name": "@acme/mcp-tools",
+ "version": "1.0.0",
+ "type": "module",
+ "main": "./dist/index.cjs",
+ "module": "./dist/index.mjs",
+ "exports": {
+ ".": {
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.cjs"
+ }
+ },
+ "files": ["dist"],
+ "peerDependencies": {
+ "@frontmcp/sdk": ">=1.0.0",
+ "zod": ">=3.0.0"
+ },
+ "devDependencies": {
+ "@frontmcp/sdk": "^1.0.0",
+ "zod": "^3.23.0",
+ "tsup": "^8.0.0",
+ "typescript": "^5.0.0"
+ },
+ "scripts": {
+ "build": "tsup src/index.ts --format esm,cjs --dts",
+ "prepublishOnly": "npm run build"
+ }
+}
+```
+
+
+Declare `@frontmcp/sdk` and `zod` as **peer dependencies**, not regular dependencies. The consuming server provides these at runtime. This keeps bundle sizes small and avoids version conflicts.
+
+
+---
+
+## Step 6: Build & Publish
+
+```bash
+# Build the package
+npm run build
+
+# Publish to npm
+npm publish --access public
+
+# Or publish to a private registry
+npm publish --registry https://npm.pkg.github.com
+```
+
+---
+
+## Step 7: Consume with App.esm()
+
+Once published, any FrontMCP server can load your package:
+
+```ts
+import { FrontMcp, App } from '@frontmcp/sdk';
+
+@FrontMcp({
+ info: { name: 'My Server', version: '1.0.0' },
+ apps: [
+ App.esm('@acme/mcp-tools@^1.0.0', {
+ namespace: 'acme',
+ }),
+ ],
+})
+export default class Server {}
+```
+
+The server will discover `acme:echo`, `acme:add`, and `acme:status` at startup.
+
+---
+
+## Manifest Normalization
+
+
+FrontMCP's `normalizeEsmExport()` function tries three paths in order:
+
+1. **Default export as manifest** — Checks if `module.default` has `name` and `version` fields. If so, validates against the Zod schema.
+2. **Default export as decorated class** — Checks if `module.default` is a class with `frontmcp:type` reflect-metadata. If so, extracts the `@FrontMcp` configuration.
+3. **Named exports** — Scans the module for named exports matching manifest primitive keys (`tools`, `prompts`, `resources`, etc.) and assembles them into a manifest.
+
+If none of these paths produce a valid manifest, an `EsmManifestInvalidError` is thrown.
+
+
+---
+
+## Best Practices
+
+
+
+ Tools loaded from ESM packages are often namespaced (e.g., `acme:echo`). Include clear descriptions so AI models understand what each tool does without seeing the source code.
+
+
+ `@frontmcp/sdk` and `zod` should be peer dependencies. Bundling them causes version conflicts and inflates package size.
+
+
+ Use `tsup` or a similar tool to produce both `.mjs` and `.cjs` outputs. FrontMCP handles both formats, but dual output maximizes compatibility.
+
+
+ ESM consumers use semver ranges (`^1.0.0`, `~2.1.0`). Breaking changes should bump the major version. New features bump minor. Bug fixes bump patch.
+
+
+ Set up a local ESM server or use `App.esm()` with a file path during development to verify your manifest is correct before publishing.
+
+
+
+---
+
+## Troubleshooting
+
+
+
+ Verify your default export has `name`, `version`, and a `tools` array. Check that each tool object has at least `name` and `execute` fields. Enable debug logging on the server to see manifest normalization output.
+
+
+ Check that the package is published and accessible. For private registries, verify the `token` or `tokenEnvVar` is set correctly. Try `npm view @your/package versions` to confirm the version exists.
+
+
+ Ensure your package doesn't import Node.js-only modules (`fs`, `crypto`, `path`) at the top level if it needs to work in browsers. Use dynamic imports for platform-specific code.
+
+
+ In browser environments, avoid any file system operations. The ESM cache is memory-only. Modules are evaluated via `Blob` URLs, so ensure your code doesn't rely on `__filename` or `__dirname`.
+
+
+
+---
+
+## Related
+
+
+
+ Full reference for App.esm(), caching, auth, and hot-reload
+
+
+ Error classes for loading, caching, and authentication
+
+
diff --git a/docs/frontmcp/plugins/cache-plugin.mdx b/docs/frontmcp/plugins/cache-plugin.mdx
index 22dd7b86a..72f8884b4 100644
--- a/docs/frontmcp/plugins/cache-plugin.mdx
+++ b/docs/frontmcp/plugins/cache-plugin.mdx
@@ -300,12 +300,9 @@ CachePlugin.init({
```ts Combined with Remote Apps
@FrontMcp({
apps: [
- {
- name: 'mintlify-docs',
- urlType: 'url',
- url: 'https://mintlify.com/docs/mcp',
+ App.remote('https://mintlify.com/docs/mcp', {
namespace: 'mintlify',
- },
+ }),
],
plugins: [
CachePlugin.init({
diff --git a/docs/frontmcp/sdk-reference/errors/esm-errors.mdx b/docs/frontmcp/sdk-reference/errors/esm-errors.mdx
new file mode 100644
index 000000000..a8710bd0f
--- /dev/null
+++ b/docs/frontmcp/sdk-reference/errors/esm-errors.mdx
@@ -0,0 +1,145 @@
+---
+title: ESM Errors
+slug: sdk-reference/errors/esm-errors
+icon: box-open
+description: Error classes for ESM package loading, version resolution, manifest validation, caching, and registry authentication
+---
+
+Error classes thrown during [ESM package loading](/frontmcp/servers/esm-packages). Import them from `@frontmcp/sdk`:
+
+```ts
+import {
+ EsmPackageLoadError,
+ EsmVersionResolutionError,
+ EsmManifestInvalidError,
+ EsmCacheError,
+ EsmRegistryAuthError,
+ EsmInvalidSpecifierError,
+} from '@frontmcp/sdk';
+```
+
+## Overview
+
+| Error | Base Class | Error Code | HTTP | Description |
+|-------|-----------|------------|------|-------------|
+| `EsmPackageLoadError` | `InternalMcpError` | `ESM_PACKAGE_LOAD_ERROR` | 500 | Bundle fetch or module evaluation failed |
+| `EsmVersionResolutionError` | `InternalMcpError` | `ESM_VERSION_RESOLUTION_ERROR` | 500 | npm registry version resolution failed |
+| `EsmManifestInvalidError` | `PublicMcpError` | `-32602` (INVALID_PARAMS) | 400 | Package manifest is invalid or missing required fields |
+| `EsmCacheError` | `InternalMcpError` | `ESM_CACHE_ERROR` | 500 | Cache read/write operation failed |
+| `EsmRegistryAuthError` | `PublicMcpError` | `-32001` (UNAUTHORIZED) | 401 | Private registry authentication failed |
+| `EsmInvalidSpecifierError` | `PublicMcpError` | `-32602` (INVALID_PARAMS) | 400 | Package specifier string is malformed |
+
+---
+
+## EsmPackageLoadError
+
+Thrown when loading an ESM package fails — including network errors, CDN timeouts, and module evaluation failures.
+
+```ts
+class EsmPackageLoadError extends InternalMcpError {
+ readonly packageName: string;
+ readonly version?: string;
+ readonly originalError?: Error;
+}
+```
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `packageName` | `string` | Full package name (e.g., `@acme/tools`) |
+| `version` | `string?` | Resolved version that failed to load |
+| `originalError` | `Error?` | Underlying fetch or evaluation error |
+
+---
+
+## EsmVersionResolutionError
+
+Thrown when the npm registry cannot resolve a version for the given semver range — including network errors, 404s, and no matching version.
+
+```ts
+class EsmVersionResolutionError extends InternalMcpError {
+ readonly packageName: string;
+ readonly range: string;
+ readonly originalError?: Error;
+}
+```
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `packageName` | `string` | Full package name |
+| `range` | `string` | Semver range that failed to resolve (e.g., `^3.0.0`) |
+| `originalError` | `Error?` | Underlying registry error |
+
+---
+
+## EsmManifestInvalidError
+
+Thrown when a package's default export does not conform to the `FrontMcpPackageManifest` contract — missing `name`, `version`, or invalid primitive arrays.
+
+```ts
+class EsmManifestInvalidError extends PublicMcpError {
+ readonly packageName: string;
+ readonly details?: string;
+ readonly mcpErrorCode = -32602; // INVALID_PARAMS
+}
+```
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `packageName` | `string` | Full package name |
+| `details` | `string?` | Zod validation error details |
+
+---
+
+## EsmCacheError
+
+Thrown when a cache operation (read, write, cleanup) fails — typically due to file system permission issues or disk space.
+
+```ts
+class EsmCacheError extends InternalMcpError {
+ readonly operation: string;
+ readonly packageName?: string;
+ readonly originalError?: Error;
+}
+```
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `operation` | `string` | Cache operation that failed (e.g., `'get'`, `'put'`, `'cleanup'`) |
+| `packageName` | `string?` | Package involved in the operation |
+| `originalError` | `Error?` | Underlying I/O error |
+
+---
+
+## EsmRegistryAuthError
+
+Thrown when authentication to a private npm registry fails — invalid token, expired credentials, or wrong registry URL.
+
+```ts
+class EsmRegistryAuthError extends PublicMcpError {
+ readonly registryUrl?: string;
+ readonly details?: string;
+ readonly mcpErrorCode = -32001; // UNAUTHORIZED
+}
+```
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `registryUrl` | `string?` | Registry URL that rejected authentication |
+| `details` | `string?` | Additional error details |
+
+---
+
+## EsmInvalidSpecifierError
+
+Thrown when a package specifier string is malformed — does not match the expected `@scope/name@range` or `name@range` pattern.
+
+```ts
+class EsmInvalidSpecifierError extends PublicMcpError {
+ readonly specifier: string;
+ readonly mcpErrorCode = -32602; // INVALID_PARAMS
+}
+```
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `specifier` | `string` | The invalid specifier string |
diff --git a/docs/frontmcp/sdk-reference/errors/overview.mdx b/docs/frontmcp/sdk-reference/errors/overview.mdx
index 08c1c398a..ee82c2f2a 100644
--- a/docs/frontmcp/sdk-reference/errors/overview.mdx
+++ b/docs/frontmcp/sdk-reference/errors/overview.mdx
@@ -132,6 +132,9 @@ class InternalMcpError extends McpError {
EncryptionContextNotSetError, VaultLoadError, TokenLeakDetectedError
+
+ EsmPackageLoadError, EsmVersionResolutionError, EsmManifestInvalidError, EsmCacheError, EsmRegistryAuthError, EsmInvalidSpecifierError
+
## Usage Patterns
diff --git a/docs/frontmcp/servers/agents.mdx b/docs/frontmcp/servers/agents.mdx
index 16d4e61f2..a2e77a5df 100644
--- a/docs/frontmcp/servers/agents.mdx
+++ b/docs/frontmcp/servers/agents.mdx
@@ -139,6 +139,29 @@ Each agent is automatically exposed as a tool:
- `invoke_calculator-agent`
- `invoke_writer-agent`
+### Loading from npm or Remote Servers
+
+Mix local agents with those loaded from npm or proxied from remote servers:
+
+```ts
+import { App, Agent } from '@frontmcp/sdk';
+
+@App({
+ id: 'my-app',
+ name: 'My Application',
+ agents: [
+ ResearchAgent, // Local class
+ Agent.esm('@acme/agents@^1.0.0', 'writer'), // Single agent from npm
+ Agent.remote('https://api.example.com/mcp', 'assistant'), // Single agent from remote
+ ],
+})
+class MyApp {}
+```
+
+
+`Agent.esm()` and `Agent.remote()` load individual agents. For loading **entire apps**, use [`App.esm()`](/frontmcp/servers/esm-packages) or [`App.remote()`](/frontmcp/servers/apps#remote-apps).
+
+
---
## LLM Configuration
diff --git a/docs/frontmcp/servers/apps.mdx b/docs/frontmcp/servers/apps.mdx
index f38505ffe..f00295ea9 100644
--- a/docs/frontmcp/servers/apps.mdx
+++ b/docs/frontmcp/servers/apps.mdx
@@ -114,35 +114,30 @@ export default class BillingApp {}
## Remote Apps
-Connect to external MCP servers and proxy their tools, resources, and prompts through your gateway:
+Connect to external MCP servers and proxy their tools, resources, and prompts through your gateway using `App.remote()`:
```ts
+import { FrontMcp, App } from '@frontmcp/sdk';
+
@FrontMcp({
info: { name: 'Gateway', version: '1.0.0' },
apps: [
// Remote MCP server connected via URL
- {
- name: 'mintlify-docs',
- urlType: 'url',
- url: 'https://mintlify.com/docs/mcp',
+ App.remote('https://mintlify.com/docs/mcp', {
namespace: 'mintlify', // Tools prefixed as 'mintlify:ToolName'
transportOptions: {
timeout: 60000,
retryAttempts: 2,
},
- standalone: false,
- },
+ }),
// Local MCP server on different port
- {
- name: 'local-service',
- urlType: 'url',
- url: 'http://localhost:3099/',
+ App.remote('http://localhost:3099/', {
namespace: 'local',
transportOptions: {
timeout: 30000,
retryAttempts: 3,
},
- },
+ }),
],
})
export default class GatewayServer {}
@@ -150,14 +145,19 @@ export default class GatewayServer {}
### Remote App Options
-| Option | Type | Description |
-| ------------------ | --------- | ----------------------------------------------------------------------- |
-| `name` | `string` | Unique identifier for the remote app |
-| `urlType` | `'url'` | Must be `'url'` for remote apps |
-| `url` | `string` | MCP server endpoint URL |
-| `namespace` | `string` | Prefix for tool/resource/prompt names (e.g., `mintlify:SearchMintlify`) |
-| `transportOptions` | `object` | Connection settings (timeout, retry attempts) |
-| `standalone` | `boolean` | If `true`, don't merge with parent scope |
+`App.remote(url, options?)` accepts the MCP server URL as the first argument and an optional options object:
+
+| Option | Type | Description |
+| ------------------ | --------------------------------- | ----------------------------------------------------------------------- |
+| `name` | `string` | Override the auto-derived app name (defaults to hostname) |
+| `namespace` | `string` | Prefix for tool/resource/prompt names (e.g., `mintlify:SearchMintlify`) |
+| `description` | `string` | Human-readable description |
+| `standalone` | `boolean \| 'includeInParent'` | Isolation mode (default: `false`) |
+| `transportOptions` | `object` | Connection settings (timeout, retry attempts) |
+| `remoteAuth` | `RemoteAuthConfig` | Authentication config for the remote server |
+| `refreshInterval` | `number` | Interval (ms) to refresh capabilities from the remote server |
+| `cacheTTL` | `number` | TTL (ms) for cached capabilities |
+| `filter` | `AppFilterConfig` | Include/exclude filter for selectively importing primitives |
### Transport Options
@@ -175,12 +175,9 @@ Remote tools don't have cache metadata, so use the `toolPatterns` option in Cach
```ts
@FrontMcp({
apps: [
- {
- name: 'external-api',
- urlType: 'url',
- url: 'https://api.example.com/mcp',
+ App.remote('https://api.example.com/mcp', {
namespace: 'api',
- },
+ }),
],
plugins: [
CachePlugin.init({
@@ -213,6 +210,66 @@ See the [Cache Plugin documentation](/frontmcp/plugins/cache-plugin#caching-remo
---
+## ESM Package Apps
+
+Load npm packages at runtime and register their tools, resources, and prompts in-process — no HTTP proxy needed — using `App.esm()`:
+
+```ts
+import { FrontMcp, App } from '@frontmcp/sdk';
+
+@FrontMcp({
+ info: { name: 'Gateway', version: '1.0.0' },
+ apps: [
+ App.esm('@acme/mcp-tools@^1.0.0', {
+ namespace: 'acme',
+ autoUpdate: { enabled: true },
+ }),
+ App.esm('@acme/analytics@^2.0.0', { namespace: 'analytics' }),
+ ],
+})
+export default class Server {}
+```
+
+
+Unlike [Remote Apps](#remote-apps) which proxy requests to external MCP servers over HTTP, ESM packages are fetched from a CDN, cached locally, and executed **in-process**. They support two-tier caching, background version polling with hot-reload, and private registry authentication.
+
+
+See the full [ESM Packages reference](/frontmcp/servers/esm-packages) for `App.esm()` API, caching, authentication, and auto-update configuration.
+
+---
+
+## Per-Primitive Loading
+
+In addition to loading entire apps, you can load **individual primitives** (tools, resources, prompts, agents, skills, jobs) from npm packages or remote servers using static methods on each decorator:
+
+```ts
+import { FrontMcp, App, Tool, Resource, Prompt } from '@frontmcp/sdk';
+
+@App({
+ id: 'my-app',
+ name: 'My App',
+ tools: [
+ LocalEchoTool, // Local @Tool class
+ Tool.esm('@acme/tools@^1.0.0', 'echo'), // Single tool from npm
+ Tool.remote('https://api.example.com/mcp', 'search'), // Proxy single tool from remote
+ ],
+ resources: [
+ LocalConfig,
+ Resource.esm('@acme/tools@^1.0.0', 'status'), // Single resource from npm
+ ],
+ prompts: [
+ Prompt.remote('https://api.example.com/mcp', 'greeting'), // Proxy single prompt
+ ],
+})
+class MyApp {}
+```
+
+
+Per-primitive loading gives you fine-grained control. Instead of importing an entire package's tools, you can pick exactly the ones you need. See each component's documentation page ([Tools](/frontmcp/servers/tools), [Resources](/frontmcp/servers/resources), [Prompts](/frontmcp/servers/prompts), [Agents](/frontmcp/servers/agents), [Skills](/frontmcp/servers/skills), [Jobs](/frontmcp/servers/jobs)) for details.
+
+
+---
+
## Best Practices
**Do:**
@@ -227,3 +284,10 @@ See the [Cache Plugin documentation](/frontmcp/plugins/cache-plugin#caching-remo
- Put unrelated functionality in the same app
- Mix authentication strategies within a single app
- Create apps with only one tool (use the server directly)
+- Use `App.esm()` for external MCP servers (use `App.remote()` instead)
+
+**Choosing between app types:**
+
+- **Local `@App`**: First-party code you own and maintain
+- **`App.esm()`**: Community or internal packages distributed via npm
+- **`App.remote()`**: External MCP servers running as separate processes
diff --git a/docs/frontmcp/servers/esm-packages.mdx b/docs/frontmcp/servers/esm-packages.mdx
new file mode 100644
index 000000000..ec94195f3
--- /dev/null
+++ b/docs/frontmcp/servers/esm-packages.mdx
@@ -0,0 +1,400 @@
+---
+title: ESM Packages
+sidebarTitle: ESM Packages
+slug: servers/esm-packages
+icon: box-open
+description: Load npm packages at runtime as MCP apps with caching, auto-update, and private registry support
+---
+
+ESM Packages let you **dynamically load npm packages at runtime** and register their tools, resources, and prompts into your FrontMCP server. Unlike [Remote Apps](/frontmcp/servers/apps#remote-apps) which proxy requests to external MCP servers over HTTP, ESM packages are fetched from a CDN, cached locally, and executed **in-process** — giving you the same performance as local apps with the flexibility of npm distribution.
+
+## Why ESM Packages?
+
+
+
+ Load community or internal packages at runtime without bundling them into your server build
+
+
+ Background version polling with semver-aware hot-reload — tools update without restarts
+
+
+ Token-based authentication for private npm registries and custom CDN endpoints
+
+
+ Works in Node.js (disk + memory cache) and browser environments (memory-only cache)
+
+
+
+---
+
+## Quick Start
+
+```ts
+import { FrontMcp, App } from '@frontmcp/sdk';
+
+@FrontMcp({
+ info: { name: 'My Server', version: '1.0.0' },
+ apps: [
+ App.esm('@acme/mcp-tools@^1.0.0'),
+ ],
+})
+export default class Server {}
+```
+
+The `App.esm()` method creates an app entry that loads the npm package `@acme/mcp-tools` (any version matching `^1.0.0`) at server startup. Its tools, resources, and prompts are automatically registered.
+
+---
+
+## How It Works
+
+
+
+ `App.esm('@acme/tools@^1.0.0')` parses the npm package specifier into scope, name, and semver range.
+
+
+ The npm registry is queried to resolve the semver range (e.g., `^1.0.0`) to a concrete version (e.g., `1.2.3`).
+
+
+ The two-tier cache is checked — first in-memory, then disk. On a hit, the cached bundle is used directly.
+
+
+ On a cache miss, the bundle is fetched from the esm.sh CDN (or a custom loader URL) and stored in both cache tiers.
+
+
+ The bundle is evaluated (ESM via native `import()`, CJS via bridge wrapper) and the default export is extracted.
+
+
+ The export is normalized into a `FrontMcpPackageManifest` — supporting plain objects, decorated classes, or named exports.
+
+
+ Tools, resources, and prompts from the manifest are registered into standard registries with full hook and lifecycle support.
+
+
+
+---
+
+## `App.esm()` API
+
+```ts
+import { App } from '@frontmcp/sdk';
+
+App.esm(specifier: string, options?: EsmAppOptions): RemoteAppMetadata
+```
+
+### Parameters
+
+
+ npm package specifier in the format `@scope/name@range` or `name@range`.
+ The range defaults to `latest` if omitted.
+
+
+### Options
+
+
+ Override the auto-derived app name (defaults to the package's full name).
+
+
+
+ Prefix for all tools, resources, and prompts from this package. For example, `namespace: 'acme'` turns a tool named `echo` into `acme:echo`.
+
+
+
+ Human-readable description for the app entry.
+
+
+
+ Isolation mode. `true` creates a separate scope; `'includeInParent'` lists it under the parent while keeping isolation.
+
+
+
+ Per-app loader override. Takes precedence over the gateway-level `loader`. See [Gateway-Level Loader](#gateway-level-loader) for fields.
+
+
+
+ Enable background version polling. When a new version matching the semver range is published, the package is automatically reloaded. Default interval: 300,000ms (5 minutes).
+
+
+
+ Local cache time-to-live in milliseconds. Default: 86,400,000ms (24 hours).
+
+
+
+ Import map overrides for ESM resolution. Maps package names to alternative URLs.
+
+
+
+ Include/exclude filter for selectively importing primitives. See [Per-Primitive Loading](/frontmcp/servers/apps#per-primitive-loading) for details.
+
+
+### Examples
+
+
+```ts Simple
+App.esm('@acme/tools@^1.0.0')
+```
+
+```ts With Namespace
+App.esm('@acme/tools@^1.0.0', {
+ namespace: 'acme',
+})
+```
+
+```ts With Auto-Update
+App.esm('@acme/tools@^1.0.0', {
+ namespace: 'acme',
+ autoUpdate: { enabled: true, intervalMs: 30000 },
+ cacheTTL: 60000,
+})
+```
+
+```ts Private Registry
+App.esm('@internal/tools@latest', {
+ namespace: 'internal',
+ loader: {
+ registryUrl: 'https://npm.pkg.github.com',
+ tokenEnvVar: 'GITHUB_TOKEN',
+ },
+})
+```
+
+
+---
+
+## Package Specifier Format
+
+| Pattern | Example | Description |
+|---------|---------|-------------|
+| `@scope/name@range` | `@acme/tools@^1.0.0` | Scoped package with semver range |
+| `@scope/name@tag` | `@acme/tools@latest` | Scoped package with dist-tag |
+| `name@range` | `my-tools@~2.0.0` | Unscoped package with semver range |
+| `name` | `my-tools` | Unscoped package, defaults to `latest` |
+
+Supported semver ranges include `^1.0.0`, `~1.0.0`, `>=1.0.0 <2.0.0`, exact versions like `1.2.3`, and dist-tags like `latest`, `next`, `beta`.
+
+---
+
+## Gateway-Level Loader
+
+Set a default loader at the server level that applies to all npm apps:
+
+```ts
+@FrontMcp({
+ info: { name: 'Gateway', version: '1.0.0' },
+ loader: {
+ url: 'https://custom-cdn.corp.com',
+ tokenEnvVar: 'NPM_TOKEN',
+ },
+ apps: [
+ App.esm('@acme/tools@^1.0.0', { namespace: 'acme' }),
+ App.esm('@acme/analytics@^2.0.0', { namespace: 'analytics' }),
+ // Both apps use the gateway-level loader config
+ ],
+})
+export default class Server {}
+```
+
+Individual apps can override the gateway loader via the `loader` option in `App.esm()`.
+
+### PackageLoader Fields
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `url` | `string` | `https://esm.sh` (bundles), `https://registry.npmjs.org` (registry) | Base URL for both registry API and bundle fetching |
+| `registryUrl` | `string` | Same as `url` | Separate registry URL for version resolution (if different from bundle URL) |
+| `token` | `string` | — | Bearer token for authentication |
+| `tokenEnvVar` | `string` | — | Environment variable name containing the bearer token |
+
+
+When `url` is set but `registryUrl` is not, both the registry API and bundle downloads use `url`. When `registryUrl` is also set, the registry uses `registryUrl` while bundles use `url`.
+
+
+---
+
+## Auto-Update & Hot-Reload
+
+Enable background version polling to automatically reload packages when new versions are published:
+
+```ts
+App.esm('@acme/tools@^1.0.0', {
+ autoUpdate: {
+ enabled: true,
+ intervalMs: 60000, // Check every 60 seconds
+ },
+})
+```
+
+When a new version matching the semver range is detected:
+
+1. The new bundle is fetched and cached
+2. All tools, resources, and prompts from the old version are unregistered
+3. The new manifest is registered
+4. MCP clients receive `tools/list_changed` and `resources/list_changed` notifications
+
+
+In production, use conservative polling intervals (5+ minutes) to avoid excessive registry traffic. The default interval is 5 minutes (300,000ms).
+
+
+### CLI Update
+
+You can also check for and apply updates via the CLI:
+
+```bash
+# Check all ESM apps for updates
+frontmcp package esm-update --all --check-only
+
+# Apply updates for a specific app
+frontmcp package esm-update my-esm-app
+
+# Apply all pending updates
+frontmcp package esm-update --all
+```
+
+---
+
+## Caching
+
+ESM packages use a **two-tier cache** for fast startup and offline resilience:
+
+| Tier | Environment | Persistence | Speed |
+|------|-------------|-------------|-------|
+| **Memory** | All | Process lifetime | Instant |
+| **Disk** | Node.js only | Survives restarts | Fast (local I/O) |
+
+### Cache Locations (Node.js)
+
+- **Project mode**: `node_modules/.cache/frontmcp-esm/` (relative to project root)
+- **CLI mode**: `~/.frontmcp/esm-cache/` (user home directory)
+
+Each cached package is stored as a hashed directory containing the bundle file (`.mjs` or `.cjs`) and a `meta.json` with version, etag, and timestamp.
+
+### Cache TTL
+
+Configure how long cached bundles remain valid:
+
+```ts
+App.esm('@acme/tools@^1.0.0', {
+ cacheTTL: 3600000, // 1 hour
+})
+```
+
+Default TTL is **24 hours** (86,400,000ms). After expiry, the next load triggers a fresh fetch.
+
+### Browser Mode
+
+In browser environments, only the in-memory cache is used. Bundles are evaluated via `Blob` + `URL.createObjectURL` and stored in a `Map`. See [Browser Compatibility](/frontmcp/deployment/browser-compatibility) for details.
+
+---
+
+## Private Registry Authentication
+
+
+```ts Environment Variable (Recommended)
+@FrontMcp({
+ loader: {
+ tokenEnvVar: 'NPM_TOKEN',
+ },
+ apps: [
+ App.esm('@private/tools@^1.0.0'),
+ ],
+})
+```
+
+```ts Direct Token
+@FrontMcp({
+ loader: {
+ token: 'npm_abc123...',
+ },
+ apps: [
+ App.esm('@private/tools@^1.0.0'),
+ ],
+})
+```
+
+```ts Custom Registry (GitHub Packages)
+@FrontMcp({
+ loader: {
+ registryUrl: 'https://npm.pkg.github.com',
+ tokenEnvVar: 'GITHUB_TOKEN',
+ },
+ apps: [
+ App.esm('@my-org/internal-tools@^1.0.0'),
+ ],
+})
+```
+
+
+
+Never commit tokens directly in source code. Use `tokenEnvVar` to reference environment variables instead.
+
+
+The Bearer token is sent in the `Authorization` header for both registry API calls (version resolution) and bundle fetching.
+
+---
+
+## Comparison: ESM vs Remote vs Local Apps
+
+| Aspect | Local Apps | ESM Packages | Remote Apps |
+|--------|-----------|--------------|-------------|
+| **Declaration** | `@App` class | `App.esm()` | `App.remote()` |
+| **Execution** | In-process | In-process | Out-of-process (HTTP) |
+| **Transport** | None | None | Streamable HTTP / SSE |
+| **Caching** | N/A | Two-tier (memory + disk) | Optional via CachePlugin |
+| **Auth** | N/A | npm registry auth | remoteAuth config |
+| **Hot-Reload** | Requires restart | Version polling | N/A |
+| **Plugins/Adapters** | Full support | Not supported | Not supported |
+| **Best For** | First-party code | Community/npm packages | External MCP servers |
+
+---
+
+## Error Handling
+
+ESM loading can fail at several stages. FrontMCP provides specific error classes for each:
+
+| Error | When | HTTP |
+|-------|------|------|
+| `EsmInvalidSpecifierError` | Package specifier format is invalid | 400 |
+| `EsmVersionResolutionError` | npm registry query fails or no matching version | 500 |
+| `EsmRegistryAuthError` | Private registry authentication fails | 401 |
+| `EsmPackageLoadError` | Bundle fetch or evaluation fails | 500 |
+| `EsmManifestInvalidError` | Package export doesn't match manifest contract | 400 |
+| `EsmCacheError` | Cache read/write operation fails | 500 |
+
+See the [ESM Errors reference](/frontmcp/sdk-reference/errors/esm-errors) for full details.
+
+---
+
+## Best Practices
+
+**Do:**
+
+- Use `namespace` to avoid naming conflicts between packages
+- Use `tokenEnvVar` instead of inline tokens for private registries
+- Set `autoUpdate` with conservative intervals in production (5+ minutes)
+- Use `cacheTTL` to balance freshness vs. startup speed
+- Test packages locally before deploying to production
+
+**Don't:**
+
+- Commit registry tokens in source code
+- Use very short polling intervals in production (avoids registry rate limits)
+- Load untrusted packages without reviewing their manifest and code
+- Rely on ESM packages for plugins or adapters (use local `@App` classes instead)
+
+---
+
+## Next Steps
+
+
+
+ Create and publish npm packages loadable by FrontMCP servers
+
+
+ Use `frontmcp package esm-update` to manage ESM packages
+
+
+ Full guide to local and remote app configuration
+
+
+ Error classes for ESM loading, caching, and authentication
+
+
diff --git a/docs/frontmcp/servers/jobs.mdx b/docs/frontmcp/servers/jobs.mdx
index aa9a35ae8..922b898c3 100644
--- a/docs/frontmcp/servers/jobs.mdx
+++ b/docs/frontmcp/servers/jobs.mdx
@@ -114,6 +114,29 @@ import { App } from '@frontmcp/sdk';
class TextProcessingApp {}
```
+### Loading from npm or Remote Servers
+
+Mix local jobs with those loaded from npm or proxied from remote servers:
+
+```ts
+import { App, Job } from '@frontmcp/sdk';
+
+@App({
+ id: 'text-processing',
+ name: 'Text Processing',
+ jobs: [
+ AnalyzeTextJob, // Local class
+ Job.esm('@acme/jobs@^1.0.0', 'cleanup'), // Single job from npm
+ Job.remote('https://api.example.com/mcp', 'sync-data'), // Single job from remote
+ ],
+})
+class TextProcessingApp {}
+```
+
+
+`Job.esm()` and `Job.remote()` load individual jobs. For loading **entire apps**, use [`App.esm()`](/frontmcp/servers/esm-packages) or [`App.remote()`](/frontmcp/servers/apps#remote-apps).
+
+
To enable the jobs system on your server, configure `jobsConfig`:
```ts
diff --git a/docs/frontmcp/servers/prompts.mdx b/docs/frontmcp/servers/prompts.mdx
index 6ccd9bfc0..97d842c3f 100644
--- a/docs/frontmcp/servers/prompts.mdx
+++ b/docs/frontmcp/servers/prompts.mdx
@@ -114,6 +114,29 @@ class MyApp {}
Prompts can also be generated dynamically by **adapters** or **plugins**.
+### Loading from npm or Remote Servers
+
+Mix local prompts with those loaded from npm or proxied from remote servers:
+
+```ts
+import { App, Prompt } from '@frontmcp/sdk';
+
+@App({
+ id: 'my-app',
+ name: 'My Application',
+ prompts: [
+ SummarizePrompt, // Local class
+ Prompt.esm('@acme/tools@^1.0.0', 'greeting'), // Single prompt from npm
+ Prompt.remote('https://api.example.com/mcp', 'code-review-prompt'), // Single prompt from remote
+ ],
+})
+class MyApp {}
+```
+
+
+`Prompt.esm()` and `Prompt.remote()` load individual prompts. For loading **entire apps**, use [`App.esm()`](/frontmcp/servers/esm-packages) or [`App.remote()`](/frontmcp/servers/apps#remote-apps).
+
+
---
## Return Values
diff --git a/docs/frontmcp/servers/resources.mdx b/docs/frontmcp/servers/resources.mdx
index 758038965..98157934b 100644
--- a/docs/frontmcp/servers/resources.mdx
+++ b/docs/frontmcp/servers/resources.mdx
@@ -145,6 +145,29 @@ class MyApp {}
Resources can also be generated dynamically by **adapters** (e.g., OpenAPI adapter) or **plugins**.
+### Loading from npm or Remote Servers
+
+Mix local resources with those loaded from npm or proxied from remote servers:
+
+```ts
+import { App, Resource } from '@frontmcp/sdk';
+
+@App({
+ id: 'my-app',
+ name: 'My Application',
+ resources: [
+ AppConfig, // Local class
+ Resource.esm('@acme/tools@^1.0.0', 'status'), // Single resource from npm
+ Resource.remote('https://api.example.com/mcp', 'system-health'), // Single resource from remote
+ ],
+})
+class MyApp {}
+```
+
+
+`Resource.esm()` and `Resource.remote()` load individual resources. For loading **entire apps**, use [`App.esm()`](/frontmcp/servers/esm-packages) or [`App.remote()`](/frontmcp/servers/apps#remote-apps).
+
+
---
## Return Values
diff --git a/docs/frontmcp/servers/server.mdx b/docs/frontmcp/servers/server.mdx
index 6b00db7f9..350418a58 100644
--- a/docs/frontmcp/servers/server.mdx
+++ b/docs/frontmcp/servers/server.mdx
@@ -104,6 +104,14 @@ Everything else is optional with sensible defaults.
defaultTtlMs?: 3600000,
},
+ /** Default ESM package loader config (affects all App.esm() apps) */
+ loader?: {
+ url?: string, // Base URL for registry + bundle fetching
+ registryUrl?: string, // Separate registry URL (if different)
+ token?: string, // Bearer token for auth
+ tokenEnvVar?: string, // Env var name containing bearer token (takes precedence over `token`)
+ },
+
logging?: {
level?: LogLevel.Info, // Debug | VERBOSE | Info | Warn | Error | Off
enableConsole?: true, // default true
diff --git a/docs/frontmcp/servers/skills.mdx b/docs/frontmcp/servers/skills.mdx
index 84c918080..72fb3250b 100644
--- a/docs/frontmcp/servers/skills.mdx
+++ b/docs/frontmcp/servers/skills.mdx
@@ -155,6 +155,30 @@ Skills are automatically:
- Indexed for search via `searchSkills`
- Loadable via `loadSkill` with tool availability info
+### Loading from npm or Remote Servers
+
+Mix local skills with those loaded from npm or proxied from remote servers:
+
+```ts
+import { App, Skill } from '@frontmcp/sdk';
+
+@App({
+ id: 'my-app',
+ name: 'My Application',
+ tools: [GitHubTool, DockerTool, K8sTool],
+ skills: [
+ ReviewPRSkill, // Local class
+ Skill.esm('@acme/skills@^1.0.0', 'deploy'), // Single skill from npm
+ Skill.remote('https://api.example.com/mcp', 'security-audit'), // Single skill from remote
+ ],
+})
+class MyApp {}
+```
+
+
+`Skill.esm()` and `Skill.remote()` load individual skills. For loading **entire apps**, use [`App.esm()`](/frontmcp/servers/esm-packages) or [`App.remote()`](/frontmcp/servers/apps#remote-apps).
+
+
---
## Skill Metadata
diff --git a/docs/frontmcp/servers/tools.mdx b/docs/frontmcp/servers/tools.mdx
index aa5a7a353..005be6eff 100644
--- a/docs/frontmcp/servers/tools.mdx
+++ b/docs/frontmcp/servers/tools.mdx
@@ -87,6 +87,30 @@ class MyApp {}
Tools can also be generated dynamically by **adapters** (e.g., OpenAPI adapter) or **plugins**.
+### Loading from npm or Remote Servers
+
+You can mix local tool classes with tools loaded from npm packages or proxied from remote MCP servers:
+
+```ts
+import { App, Tool } from '@frontmcp/sdk';
+
+@App({
+ id: 'my-app',
+ name: 'My Application',
+ tools: [
+ GreetTool, // Local class
+ Tool.esm('@acme/tools@^1.0.0', 'echo'), // Single tool from npm
+ Tool.remote('https://api.example.com/mcp', 'search'), // Single tool from remote server
+ ],
+})
+class MyApp {}
+```
+
+
+`Tool.esm()` loads a single named tool from an npm package at runtime. `Tool.remote()` proxies a single tool from a remote MCP server.
+For loading **entire apps** (all tools, resources, prompts), use [`App.esm()`](/frontmcp/servers/esm-packages) or [`App.remote()`](/frontmcp/servers/apps#remote-apps).
+
+
---
## Input Schemas
diff --git a/libs/cli/src/commands/package/esm-update.ts b/libs/cli/src/commands/package/esm-update.ts
new file mode 100644
index 000000000..24bf762ea
--- /dev/null
+++ b/libs/cli/src/commands/package/esm-update.ts
@@ -0,0 +1,104 @@
+/**
+ * @file esm-update.ts
+ * @description CLI command for checking and applying ESM package updates.
+ *
+ * Reads the registry for ESM-installed apps, checks for updates using the
+ * VersionPoller, downloads and caches new versions, and updates the registry.
+ */
+
+import { Command } from 'commander';
+import { readRegistry, writeRegistry } from './registry';
+import { PM_DIRS } from '../pm/paths';
+
+/**
+ * Register the `esm-update` subcommand.
+ *
+ * Usage:
+ * frontmcp package esm-update [app-name]
+ *
+ * Options:
+ * --check-only Only check for updates, don't apply them
+ * --all Update all ESM-installed apps
+ */
+export function registerEsmUpdateCommand(program: Command): void {
+ program
+ .command('esm-update [app-name]')
+ .description('Check and apply ESM package updates')
+ .option('--check-only', 'Only check for updates without applying them', false)
+ .option('--all', 'Update all ESM-installed apps', false)
+ .action(async (appName: string | undefined, opts: { checkOnly: boolean; all: boolean }) => {
+ const { EsmCacheManager, EsmModuleLoader, parsePackageSpecifier } = await import('@frontmcp/sdk');
+
+ const registry = readRegistry();
+ if (!registry) {
+ console.error('No registry found. Install an app first with `frontmcp package install`.');
+ process.exit(1);
+ }
+
+ // Find ESM apps to update
+ const esmApps = Object.entries(registry.apps).filter(([, app]) => {
+ if (appName && !opts.all) {
+ return app.source?.type === 'esm';
+ }
+ return app.source?.type === 'esm';
+ });
+
+ if (appName && !opts.all) {
+ const filtered = esmApps.filter(([name]) => name === appName);
+ if (filtered.length === 0) {
+ console.error(`App "${appName}" not found or is not an ESM app.`);
+ process.exit(1);
+ }
+ }
+
+ if (esmApps.length === 0) {
+ console.log('No ESM apps found in registry.');
+ return;
+ }
+
+ const cache = new EsmCacheManager({ cacheDir: PM_DIRS.esmCache });
+ const loader = new EsmModuleLoader({ cache });
+
+ console.log(`Checking ${esmApps.length} ESM app(s) for updates...\n`);
+
+ for (const [name, app] of esmApps) {
+ if (appName && !opts.all && name !== appName) continue;
+
+ const sourceRef = app.source?.ref;
+ if (!sourceRef) continue;
+
+ try {
+ const specifier = parsePackageSpecifier(sourceRef);
+ const currentVersion = app.esmVersion ?? app.version;
+
+ // Check for updates
+ const result = await loader.resolveVersion(specifier);
+
+ if (result === currentVersion) {
+ console.log(` ${name}: up to date (${currentVersion})`);
+ continue;
+ }
+
+ console.log(` ${name}: ${currentVersion} → ${result}`);
+
+ if (opts.checkOnly) continue;
+
+ // Apply the update
+ const loadResult = await loader.load(specifier);
+ app.esmVersion = loadResult.resolvedVersion;
+ app.esmCachePath = PM_DIRS.esmCache;
+ app.lastUpdateCheck = new Date().toISOString();
+ app.version = loadResult.resolvedVersion;
+
+ console.log(` Updated to ${loadResult.resolvedVersion} (source: ${loadResult.source})`);
+ } catch (error) {
+ console.error(` ${name}: failed - ${(error as Error).message}`);
+ }
+ }
+
+ if (!opts.checkOnly) {
+ writeRegistry(registry);
+ console.log('\nRegistry updated.');
+ }
+ });
+}
diff --git a/libs/cli/src/commands/package/install.ts b/libs/cli/src/commands/package/install.ts
index 7a6c9ee61..3a93935ca 100644
--- a/libs/cli/src/commands/package/install.ts
+++ b/libs/cli/src/commands/package/install.ts
@@ -44,6 +44,11 @@ export async function runInstall(opts: ParsedArgs): Promise {
case 'git':
packageDir = await fetchFromGit(source.ref, tmpDir);
break;
+ case 'esm':
+ throw new Error(
+ 'ESM sources cannot be installed via "frontmcp install". ' +
+ 'Use loadFrom() in your FrontMcp config to load ESM packages at runtime.',
+ );
}
// 2. Look for manifest
diff --git a/libs/cli/src/commands/package/types.ts b/libs/cli/src/commands/package/types.ts
index 064e8268c..79010390d 100644
--- a/libs/cli/src/commands/package/types.ts
+++ b/libs/cli/src/commands/package/types.ts
@@ -2,7 +2,7 @@
* Install source parsing and types.
*/
-export type InstallSourceType = 'npm' | 'local' | 'git';
+export type InstallSourceType = 'npm' | 'local' | 'git' | 'esm';
export interface InstallSource {
type: InstallSourceType;
@@ -22,6 +22,12 @@ export interface FrontmcpRegistry {
storage: 'sqlite' | 'redis' | 'none';
port: number;
source?: { type: InstallSourceType; ref: string };
+ /** Resolved ESM version (for ESM-loaded apps) */
+ esmVersion?: string;
+ /** Path to cached ESM bundle */
+ esmCachePath?: string;
+ /** ISO timestamp of last update check */
+ lastUpdateCheck?: string;
}
>;
}
@@ -38,6 +44,11 @@ export function parseInstallSource(source: string): InstallSource {
return { type: 'local', ref: source };
}
+ // ESM sources: explicit esm.sh URL or esm: prefix (checked before git to avoid esm:...pkg.git misclassification)
+ if (source.startsWith('https://esm.sh/') || source.startsWith('esm:')) {
+ return { type: 'esm', ref: source.replace(/^esm:/, '') };
+ }
+
if (source.startsWith('github:') || source.startsWith('git+') || source.endsWith('.git')) {
return { type: 'git', ref: source };
}
diff --git a/libs/cli/src/commands/pm/paths.ts b/libs/cli/src/commands/pm/paths.ts
index 10def99d0..57be5f102 100644
--- a/libs/cli/src/commands/pm/paths.ts
+++ b/libs/cli/src/commands/pm/paths.ts
@@ -17,6 +17,7 @@ export const PM_DIRS = {
services: path.join(FRONTMCP_HOME, 'services'),
apps: path.join(FRONTMCP_HOME, 'apps'),
data: path.join(FRONTMCP_HOME, 'data'),
+ esmCache: path.join(FRONTMCP_HOME, 'esm-cache'),
} as const;
export function pidFilePath(name: string): string {
diff --git a/libs/react/tsconfig.lib.json b/libs/react/tsconfig.lib.json
index f3693d012..f5d76a0ea 100644
--- a/libs/react/tsconfig.lib.json
+++ b/libs/react/tsconfig.lib.json
@@ -8,7 +8,8 @@
"declarationMap": true,
"types": ["node"],
"paths": {
- "@frontmcp/sdk": ["libs/sdk/dist/index.d.ts"]
+ "@frontmcp/sdk": ["libs/sdk/dist/index.d.ts"],
+ "@frontmcp/utils": ["libs/utils/dist/index.d.ts"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
diff --git a/libs/sdk/eslint.config.mjs b/libs/sdk/eslint.config.mjs
index b861c898d..56955fdeb 100644
--- a/libs/sdk/eslint.config.mjs
+++ b/libs/sdk/eslint.config.mjs
@@ -18,6 +18,8 @@ export default [
'@langchain/google-genai', // Optional: dynamic import
'@langchain/mistralai', // Optional: dynamic import
'@langchain/groq', // Optional: dynamic import
+ 'openai', // Optional peer dep: dynamic import in agent adapters
+ '@anthropic-ai/sdk', // Optional peer dep: dynamic import in agent adapters
],
},
],
diff --git a/libs/sdk/package.json b/libs/sdk/package.json
index 93e98f5e9..5d39e6449 100644
--- a/libs/sdk/package.json
+++ b/libs/sdk/package.json
@@ -108,7 +108,8 @@
"ioredis": "^5.8.0",
"js-yaml": "^4.1.1",
"jose": "^6.1.3",
- "reflect-metadata": "^0.2.2"
+ "reflect-metadata": "^0.2.2",
+ "semver": "^7.6.0"
},
"devDependencies": {
"typescript": "^5.9.3"
diff --git a/libs/sdk/src/agent/__tests__/agent-npm-remote.spec.ts b/libs/sdk/src/agent/__tests__/agent-npm-remote.spec.ts
new file mode 100644
index 000000000..d3fc461f2
--- /dev/null
+++ b/libs/sdk/src/agent/__tests__/agent-npm-remote.spec.ts
@@ -0,0 +1,112 @@
+import { Agent } from '../../common/decorators/agent.decorator';
+import { AgentKind } from '../../common/records/agent.record';
+import type { AgentEsmTargetRecord, AgentRemoteRecord } from '../../common/records/agent.record';
+
+describe('Agent.esm()', () => {
+ it('creates AgentEsmTargetRecord with kind ESM', () => {
+ const record = (Agent as any).esm('@acme/agents@^1.0.0', 'research') as AgentEsmTargetRecord;
+ expect(record.kind).toBe(AgentKind.ESM);
+ });
+
+ it('parses scoped specifier correctly', () => {
+ const record = (Agent as any).esm('@acme/agents@^1.0.0', 'research') as AgentEsmTargetRecord;
+ expect(record.specifier.scope).toBe('@acme');
+ expect(record.specifier.name).toBe('agents');
+ expect(record.specifier.fullName).toBe('@acme/agents');
+ expect(record.specifier.range).toBe('^1.0.0');
+ });
+
+ it('parses unscoped specifier', () => {
+ const record = (Agent as any).esm('ai-agents@latest', 'writer') as AgentEsmTargetRecord;
+ expect(record.specifier.scope).toBeUndefined();
+ expect(record.specifier.fullName).toBe('ai-agents');
+ });
+
+ it('sets targetName', () => {
+ const record = (Agent as any).esm('@acme/agents@^1.0.0', 'research') as AgentEsmTargetRecord;
+ expect(record.targetName).toBe('research');
+ });
+
+ it('creates unique symbol provide token', () => {
+ const record = (Agent as any).esm('@acme/agents@^1.0.0', 'research') as AgentEsmTargetRecord;
+ expect(typeof record.provide).toBe('symbol');
+ expect(record.provide.toString()).toContain('esm-agent:@acme/agents:research');
+ });
+
+ it('creates different symbols for different targets', () => {
+ const r1 = (Agent as any).esm('@acme/agents@^1.0.0', 'research') as AgentEsmTargetRecord;
+ const r2 = (Agent as any).esm('@acme/agents@^1.0.0', 'writer') as AgentEsmTargetRecord;
+ expect(r1.provide).not.toBe(r2.provide);
+ });
+
+ it('passes options through', () => {
+ const record = (Agent as any).esm('@acme/agents@^1.0.0', 'research', {
+ loader: { url: 'https://custom.cdn' },
+ cacheTTL: 300000,
+ }) as AgentEsmTargetRecord;
+ expect(record.options?.loader).toEqual({ url: 'https://custom.cdn' });
+ expect(record.options?.cacheTTL).toBe(300000);
+ });
+
+ it('generates placeholder metadata with llm', () => {
+ const record = (Agent as any).esm('@acme/agents@^1.0.0', 'research') as AgentEsmTargetRecord;
+ expect(record.metadata.name).toBe('research');
+ expect(record.metadata.description).toContain('research');
+ expect(record.metadata.description).toContain('@acme/agents');
+ expect(record.metadata.llm).toBeDefined();
+ });
+
+ it('allows overriding metadata via options', () => {
+ const record = (Agent as any).esm('@acme/agents@^1.0.0', 'research', {
+ metadata: { description: 'Custom agent desc' },
+ }) as AgentEsmTargetRecord;
+ expect(record.metadata.description).toBe('Custom agent desc');
+ expect(record.metadata.name).toBe('research');
+ });
+
+ it('throws on empty specifier', () => {
+ expect(() => (Agent as any).esm('', 'research')).toThrow('Package specifier cannot be empty');
+ });
+
+ it('throws on invalid specifier', () => {
+ expect(() => (Agent as any).esm('!!!', 'research')).toThrow('Invalid package specifier');
+ });
+});
+
+describe('Agent.remote()', () => {
+ it('creates AgentRemoteRecord with kind REMOTE', () => {
+ const record = (Agent as any).remote('https://api.example.com/mcp', 'assistant') as AgentRemoteRecord;
+ expect(record.kind).toBe(AgentKind.REMOTE);
+ });
+
+ it('sets url and targetName', () => {
+ const record = (Agent as any).remote('https://api.example.com/mcp', 'assistant') as AgentRemoteRecord;
+ expect(record.url).toBe('https://api.example.com/mcp');
+ expect(record.targetName).toBe('assistant');
+ });
+
+ it('creates unique symbol provide token', () => {
+ const record = (Agent as any).remote('https://api.example.com/mcp', 'assistant') as AgentRemoteRecord;
+ expect(typeof record.provide).toBe('symbol');
+ expect(record.provide.toString()).toContain('remote-agent:https://api.example.com/mcp:assistant');
+ });
+
+ it('passes transportOptions and remoteAuth', () => {
+ const record = (Agent as any).remote('https://api.example.com/mcp', 'assistant', {
+ transportOptions: { timeout: 60000 },
+ remoteAuth: { mode: 'static', credentials: { type: 'bearer', value: 'token' } },
+ }) as AgentRemoteRecord;
+ expect(record.transportOptions).toEqual({ timeout: 60000 });
+ expect(record.remoteAuth).toEqual({
+ mode: 'static',
+ credentials: { type: 'bearer', value: 'token' },
+ });
+ });
+
+ it('generates placeholder metadata with llm', () => {
+ const record = (Agent as any).remote('https://api.example.com/mcp', 'assistant') as AgentRemoteRecord;
+ expect(record.metadata.name).toBe('assistant');
+ expect(record.metadata.description).toContain('assistant');
+ expect(record.metadata.llm).toBeDefined();
+ });
+});
diff --git a/libs/sdk/src/agent/agent.instance.ts b/libs/sdk/src/agent/agent.instance.ts
index 7d151ba64..5c39341ae 100644
--- a/libs/sdk/src/agent/agent.instance.ts
+++ b/libs/sdk/src/agent/agent.instance.ts
@@ -517,9 +517,18 @@ export class AgentInstance<
case AgentKind.FACTORY:
return this.record.useFactory(this.providers) as AgentContext;
+
+ case AgentKind.ESM:
+ throw new AgentNotConfiguredError(`ESM agent "${this.name}" cannot be created via AgentInstance.create()`);
+
+ case AgentKind.REMOTE:
+ throw new AgentNotConfiguredError(`Remote agent "${this.name}" cannot be created via AgentInstance.create()`);
+
+ default: {
+ const _exhaustive: never = this.record;
+ throw new Error(`Unknown agent kind: ${(_exhaustive as { kind: string }).kind}`);
+ }
}
- // Note: TypeScript already ensures exhaustiveness through AgentKind enum.
- // The switch covers all AgentKind values, so this point is unreachable.
}
/**
diff --git a/libs/sdk/src/app/__tests__/app.utils.spec.ts b/libs/sdk/src/app/__tests__/app.utils.spec.ts
new file mode 100644
index 000000000..739bcd198
--- /dev/null
+++ b/libs/sdk/src/app/__tests__/app.utils.spec.ts
@@ -0,0 +1,63 @@
+import 'reflect-metadata';
+import { normalizeApp } from '../app.utils';
+import { AppKind, AppType } from '../../common';
+import { MissingProvideError } from '../../errors';
+
+describe('normalizeApp', () => {
+ it('should recognize npm remote app with package specifier (no URI scheme)', () => {
+ const input = { name: '@test/esm-tools', urlType: 'npm', url: '@test/esm-tools@^1.0.0', standalone: false };
+ const result = normalizeApp(input as AppType);
+
+ expect(result.kind).toBe(AppKind.REMOTE_VALUE);
+ expect(result.metadata).toMatchObject({ name: '@test/esm-tools', urlType: 'npm' });
+ });
+
+ it('should recognize esm remote app with package specifier', () => {
+ const input = { name: '@test/esm-mod', urlType: 'esm', url: '@test/esm-mod@latest', standalone: false };
+ const result = normalizeApp(input as AppType);
+
+ expect(result.kind).toBe(AppKind.REMOTE_VALUE);
+ expect(result.metadata).toMatchObject({ name: '@test/esm-mod', urlType: 'esm' });
+ });
+
+ it('should recognize url remote app with valid URI', () => {
+ const input = { name: 'remote-server', urlType: 'url', url: 'https://api.example.com/mcp', standalone: false };
+ const result = normalizeApp(input as AppType);
+
+ expect(result.kind).toBe(AppKind.REMOTE_VALUE);
+ expect(result.metadata).toMatchObject({ name: 'remote-server', urlType: 'url' });
+ });
+
+ it('should recognize worker remote app with valid URI', () => {
+ const input = { name: 'worker-app', urlType: 'worker', url: 'file://./workers/app.js', standalone: false };
+ const result = normalizeApp(input as AppType);
+
+ expect(result.kind).toBe(AppKind.REMOTE_VALUE);
+ expect(result.metadata).toMatchObject({ name: 'worker-app', urlType: 'worker' });
+ });
+
+ it('should throw MissingProvideError for object without provide', () => {
+ const input = { name: 'bad-app', foo: 'bar' };
+ expect(() => normalizeApp(input as AppType)).toThrow(MissingProvideError);
+ });
+
+ it('should use id over name for remote app symbol token', () => {
+ const input = { id: 'custom-id', name: '@test/pkg', urlType: 'npm', url: '@test/pkg@^1.0.0', standalone: false };
+ const result = normalizeApp(input as AppType);
+
+ expect(result.kind).toBe(AppKind.REMOTE_VALUE);
+ expect(result.provide.toString()).toContain('custom-id');
+ });
+
+ it('should reject url type with invalid URI', () => {
+ const input = { name: 'bad-url', urlType: 'url', url: 'not-a-valid-uri', standalone: false };
+ // Falls through isRemoteAppConfig since url has no scheme, hits the generic handler missing 'provide'
+ expect(() => normalizeApp(input as AppType)).toThrow(MissingProvideError);
+ });
+
+ it('should reject unknown urlType even with valid URI', () => {
+ const input = { name: 'unknown', urlType: 'foo', url: 'https://example.com', standalone: false };
+ // Unknown urlType rejected by isRemoteAppConfig, falls through to generic handler missing 'provide'
+ expect(() => normalizeApp(input as AppType)).toThrow(MissingProvideError);
+ });
+});
diff --git a/libs/sdk/src/app/app.registry.ts b/libs/sdk/src/app/app.registry.ts
index 978cf0e7d..81c840221 100644
--- a/libs/sdk/src/app/app.registry.ts
+++ b/libs/sdk/src/app/app.registry.ts
@@ -4,8 +4,9 @@ import { AppType, AppEntry, AppKind, AppRecord, EntryOwnerRef, FrontMcpLogger }
import { appDiscoveryDeps, normalizeApp } from './app.utils';
import ProviderRegistry from '../provider/provider.registry';
import { RegistryAbstract, RegistryBuildMapResult } from '../regsitry';
-import { AppLocalInstance, AppRemoteInstance } from './instances';
+import { AppLocalInstance, AppRemoteInstance, AppEsmInstance } from './instances';
import { RegistryDependencyNotRegisteredError, InvalidRegistryKindError } from '../errors';
+import type { RemoteAppMetadata } from '../common';
export default class AppRegistry extends RegistryAbstract {
private readonly owner: EntryOwnerRef;
@@ -59,7 +60,12 @@ export default class AppRegistry extends RegistryAbstract;
- return (
- typeof obj['urlType'] === 'string' &&
- typeof obj['url'] === 'string' &&
- isValidMcpUri(obj['url']) &&
- typeof obj['name'] === 'string'
- );
+ if (typeof obj['urlType'] !== 'string' || typeof obj['url'] !== 'string' || typeof obj['name'] !== 'string') {
+ return false;
+ }
+ // For npm/esm urlTypes, url is a package specifier (no scheme required)
+ if (obj['urlType'] === 'npm' || obj['urlType'] === 'esm') {
+ return true;
+ }
+ // For url/worker types, require a valid URI scheme
+ if (obj['urlType'] === 'url' || obj['urlType'] === 'worker') {
+ return isValidMcpUri(obj['url']);
+ }
+ // Unknown urlType — reject
+ return false;
}
/**
diff --git a/libs/sdk/src/app/instances/app.esm.instance.ts b/libs/sdk/src/app/instances/app.esm.instance.ts
new file mode 100644
index 000000000..e75929776
--- /dev/null
+++ b/libs/sdk/src/app/instances/app.esm.instance.ts
@@ -0,0 +1,466 @@
+/**
+ * @file app.esm.instance.ts
+ * @description ESM-loaded app instance that dynamically imports npm packages via esm.sh.
+ *
+ * Unlike AppRemoteInstance (which proxies to a remote MCP server),
+ * AppEsmInstance loads the package code locally and executes in-process.
+ */
+
+import {
+ AdapterRegistryInterface,
+ AppEntry,
+ AppRecord,
+ PluginRegistryInterface,
+ PromptRegistryInterface,
+ ProviderRegistryInterface,
+ RemoteAppMetadata,
+ ResourceRegistryInterface,
+ ToolRegistryInterface,
+ EntryOwnerRef,
+ PluginEntry,
+ AdapterEntry,
+ SkillEntry,
+} from '../../common';
+import type { SkillRegistryInterface } from '../../skill/skill.registry';
+import { idFromString } from '@frontmcp/utils';
+import ProviderRegistry from '../../provider/provider.registry';
+import ToolRegistry from '../../tool/tool.registry';
+import ResourceRegistry from '../../resource/resource.registry';
+import PromptRegistry from '../../prompt/prompt.registry';
+import { EsmModuleLoader, EsmCacheManager } from '../../esm-loader';
+import type { EsmRegistryAuth } from '../../esm-loader/esm-auth.types';
+import { parsePackageSpecifier } from '../../esm-loader/package-specifier';
+import { VersionPoller } from '../../esm-loader/version-poller';
+import type { FrontMcpPackageManifest } from '../../esm-loader/esm-manifest';
+import type { EsmLoadResult } from '../../esm-loader/esm-module-loader';
+import type { ParsedPackageSpecifier } from '../../esm-loader/package-specifier';
+import { createEsmToolInstance, createEsmPromptInstance, createEsmResourceInstance } from '../../esm-loader/factories';
+import {
+ normalizeToolFromEsmExport,
+ normalizeResourceFromEsmExport,
+ normalizePromptFromEsmExport,
+ isDecoratedToolClass,
+ isDecoratedResourceClass,
+ isDecoratedPromptClass,
+} from './esm-normalize.utils';
+import { normalizeTool } from '../../tool/tool.utils';
+import { ToolInstance } from '../../tool/tool.instance';
+import { normalizeResource } from '../../resource/resource.utils';
+import { ResourceInstance } from '../../resource/resource.instance';
+import { normalizePrompt } from '../../prompt/prompt.utils';
+import { PromptInstance } from '../../prompt/prompt.instance';
+
+/**
+ * Empty plugin registry for ESM apps.
+ */
+class EmptyPluginRegistry implements PluginRegistryInterface {
+ getPlugins(): PluginEntry[] {
+ return [];
+ }
+ getPluginNames(): string[] {
+ return [];
+ }
+}
+
+/**
+ * Empty adapter registry for ESM apps.
+ */
+class EmptyAdapterRegistry implements AdapterRegistryInterface {
+ getAdapters(): AdapterEntry[] {
+ return [];
+ }
+}
+
+/**
+ * Empty skill registry for ESM apps.
+ */
+class EmptySkillRegistry implements SkillRegistryInterface {
+ readonly owner = { kind: 'app' as const, id: '_esm', ref: EmptySkillRegistry };
+ getSkills(): SkillEntry[] {
+ return [];
+ }
+ findByName(): SkillEntry | undefined {
+ return undefined;
+ }
+ findByQualifiedName(): SkillEntry | undefined {
+ return undefined;
+ }
+ async search(): Promise<[]> {
+ return [];
+ }
+ async loadSkill(): Promise {
+ return undefined;
+ }
+ async listSkills() {
+ return { skills: [], total: 0, hasMore: false };
+ }
+ hasAny(): boolean {
+ return false;
+ }
+ async count(): Promise {
+ return 0;
+ }
+ subscribe(): () => void {
+ return () => {};
+ }
+ getCapabilities() {
+ return {};
+ }
+ async validateAllTools() {
+ return {
+ results: [],
+ isValid: true,
+ totalSkills: 0,
+ failedCount: 0,
+ warningCount: 0,
+ };
+ }
+ async syncToExternal() {
+ return null;
+ }
+ getExternalProvider() {
+ return undefined;
+ }
+ hasExternalProvider() {
+ return false;
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// APP ESM INSTANCE
+// ═══════════════════════════════════════════════════════════════════
+
+/**
+ * ESM app instance that loads npm packages via esm.sh CDN
+ * and executes their code locally in-process.
+ *
+ * Key features:
+ * - Dynamic import of npm packages at runtime
+ * - Local file-based caching of ESM bundles
+ * - Background version polling with semver range checking
+ * - Hot-reload when new versions are detected
+ * - Standard registry integration (hooks, events, etc.)
+ */
+export class AppEsmInstance extends AppEntry {
+ override readonly id: string;
+
+ override get isRemote(): boolean {
+ return true;
+ }
+
+ private readonly scopeProviders: ProviderRegistry;
+ private readonly appOwner: EntryOwnerRef;
+ private readonly loader: EsmModuleLoader;
+ private readonly specifier: ParsedPackageSpecifier;
+ private poller?: VersionPoller;
+ private loadResult?: EsmLoadResult;
+ private updateInProgress = false;
+
+ // Standard registries
+ private readonly _tools: ToolRegistry;
+ private readonly _resources: ResourceRegistry;
+ private readonly _prompts: PromptRegistry;
+ private readonly _plugins: EmptyPluginRegistry;
+ private readonly _adapters: EmptyAdapterRegistry;
+ private readonly _skills: EmptySkillRegistry;
+
+ constructor(record: AppRecord, scopeProviders: ProviderRegistry) {
+ super(record);
+ this.id = this.metadata.id ?? idFromString(this.metadata.name);
+ this.scopeProviders = scopeProviders;
+
+ this.appOwner = {
+ kind: 'app',
+ id: this.id,
+ ref: this.token,
+ };
+
+ // Parse the package specifier from the url field
+ this.specifier = parsePackageSpecifier(this.metadata.url);
+
+ // Merge gateway-level loader with app-level packageConfig.loader
+ const scopeMetadata = scopeProviders.getActiveScope().metadata;
+ const appConfig = this.metadata.packageConfig;
+ const mergedLoader = appConfig?.loader ?? scopeMetadata.loader;
+
+ const registryAuth = this.deriveRegistryAuth(mergedLoader);
+ const esmBaseUrl = mergedLoader?.url;
+
+ // Initialize the ESM module loader with cache
+ const cache = new EsmCacheManager({
+ maxAgeMs: appConfig?.cacheTTL,
+ });
+
+ this.loader = new EsmModuleLoader({
+ cache,
+ registryAuth,
+ logger: scopeProviders.getActiveScope().logger,
+ esmBaseUrl,
+ });
+
+ // Initialize standard registries (empty initially - populated on load)
+ this._tools = new ToolRegistry(this.scopeProviders, [], this.appOwner);
+ this._resources = new ResourceRegistry(this.scopeProviders, [], this.appOwner);
+ this._prompts = new PromptRegistry(this.scopeProviders, [], this.appOwner);
+ this._plugins = new EmptyPluginRegistry();
+ this._adapters = new EmptyAdapterRegistry();
+ this._skills = new EmptySkillRegistry();
+
+ this.ready = this.initialize();
+ }
+
+ protected async initialize(): Promise {
+ const logger = this.scopeProviders.getActiveScope().logger;
+ logger.info(`Initializing ESM app: ${this.id} (${this.specifier.fullName}@${this.specifier.range})`);
+
+ try {
+ // Wait for registries to be ready
+ await Promise.all([this._tools.ready, this._resources.ready, this._prompts.ready]);
+
+ // Load the ESM package
+ this.loadResult = await this.loader.load(this.specifier);
+ logger.info(
+ `Loaded ESM package ${this.specifier.fullName}@${this.loadResult.resolvedVersion} ` +
+ `(source: ${this.loadResult.source})`,
+ );
+
+ // Register primitives from the manifest
+ await this.registerFromManifest(this.loadResult.manifest);
+
+ // Start version poller if auto-update is enabled
+ const autoUpdate = this.metadata.packageConfig?.autoUpdate;
+ if (autoUpdate?.enabled) {
+ const scopeMeta = this.scopeProviders.getActiveScope().metadata;
+ const pollerLoader = this.metadata.packageConfig?.loader ?? scopeMeta.loader;
+
+ this.poller = new VersionPoller({
+ intervalMs: autoUpdate.intervalMs,
+ registryAuth: this.deriveRegistryAuth(pollerLoader),
+ logger,
+ onNewVersion: (pkg, oldVer, newVer) => this.handleVersionUpdate(pkg, oldVer, newVer),
+ });
+ this.poller.addPackage(this.specifier, this.loadResult.resolvedVersion);
+ this.poller.start();
+ }
+ } catch (error) {
+ logger.error(`Failed to initialize ESM app ${this.id}: ${(error as Error).message}`);
+ throw error;
+ }
+ }
+
+ // ═══════════════════════════════════════════════════════════════════
+ // PUBLIC API
+ // ═══════════════════════════════════════════════════════════════════
+
+ override get providers(): ProviderRegistryInterface {
+ return this.scopeProviders;
+ }
+
+ override get adapters(): AdapterRegistryInterface {
+ return this._adapters;
+ }
+
+ override get plugins(): PluginRegistryInterface {
+ return this._plugins;
+ }
+
+ override get tools(): ToolRegistryInterface {
+ return this._tools;
+ }
+
+ override get resources(): ResourceRegistryInterface {
+ return this._resources;
+ }
+
+ override get prompts(): PromptRegistryInterface {
+ return this._prompts;
+ }
+
+ override get skills(): SkillRegistryInterface {
+ return this._skills;
+ }
+
+ /**
+ * Get the currently loaded package version.
+ */
+ getLoadedVersion(): string | undefined {
+ return this.loadResult?.resolvedVersion;
+ }
+
+ /**
+ * Get the package specifier.
+ */
+ getSpecifier(): ParsedPackageSpecifier {
+ return this.specifier;
+ }
+
+ /**
+ * Force reload the package (useful for manual updates).
+ */
+ async reload(): Promise {
+ const logger = this.scopeProviders.getActiveScope().logger;
+ logger.info(`Reloading ESM app ${this.id}`);
+
+ this.loadResult = await this.loader.load(this.specifier);
+ await this.registerFromManifest(this.loadResult.manifest);
+
+ if (this.poller) {
+ this.poller.updateCurrentVersion(this.specifier.fullName, this.loadResult.resolvedVersion);
+ }
+ }
+
+ /**
+ * Stop the version poller and clean up.
+ */
+ async dispose(): Promise {
+ this.poller?.stop();
+ }
+
+ // ═══════════════════════════════════════════════════════════════════
+ // PRIVATE METHODS
+ // ═══════════════════════════════════════════════════════════════════
+
+ /**
+ * Register primitives from a loaded package manifest into standard registries.
+ */
+ private async registerFromManifest(manifest: FrontMcpPackageManifest): Promise {
+ const logger = this.scopeProviders.getActiveScope().logger;
+ const namespace = this.metadata.namespace ?? this.metadata.name;
+
+ let toolCount = 0;
+ let resourceCount = 0;
+ let promptCount = 0;
+
+ // Register tools
+ if (manifest.tools?.length) {
+ for (const rawTool of manifest.tools) {
+ if (isDecoratedToolClass(rawTool)) {
+ // Real @Tool-decorated class → standard normalization (full DI)
+ const record = normalizeTool(rawTool);
+ const prefixedName = namespace ? `${namespace}:${record.metadata.name}` : record.metadata.name;
+ record.metadata.name = prefixedName;
+ record.metadata.id = prefixedName;
+ const instance = new ToolInstance(record, this.scopeProviders, this.appOwner);
+ await instance.ready;
+ this._tools.registerToolInstance(instance);
+ toolCount++;
+ } else {
+ // Plain object → existing path
+ const toolDef = normalizeToolFromEsmExport(rawTool);
+ if (toolDef) {
+ const instance = createEsmToolInstance(toolDef, this.scopeProviders, this.appOwner, namespace);
+ await instance.ready;
+ this._tools.registerToolInstance(instance);
+ toolCount++;
+ }
+ }
+ }
+ }
+
+ // Register resources
+ if (manifest.resources?.length) {
+ for (const rawResource of manifest.resources) {
+ if (isDecoratedResourceClass(rawResource)) {
+ // Real @Resource-decorated class → standard normalization (full DI)
+ const record = normalizeResource(rawResource);
+ const prefixedName = namespace ? `${namespace}:${record.metadata.name}` : record.metadata.name;
+ record.metadata.name = prefixedName;
+ const instance = new ResourceInstance(record, this.scopeProviders, this.appOwner);
+ await instance.ready;
+ this._resources.registerResourceInstance(instance);
+ resourceCount++;
+ } else {
+ // Plain object → existing path
+ const resourceDef = normalizeResourceFromEsmExport(rawResource);
+ if (resourceDef) {
+ const instance = createEsmResourceInstance(resourceDef, this.scopeProviders, this.appOwner, namespace);
+ await instance.ready;
+ this._resources.registerResourceInstance(instance);
+ resourceCount++;
+ }
+ }
+ }
+ }
+
+ // Register prompts
+ if (manifest.prompts?.length) {
+ for (const rawPrompt of manifest.prompts) {
+ if (isDecoratedPromptClass(rawPrompt)) {
+ // Real @Prompt-decorated class → standard normalization (full DI)
+ const record = normalizePrompt(rawPrompt);
+ const prefixedName = namespace ? `${namespace}:${record.metadata.name}` : record.metadata.name;
+ record.metadata.name = prefixedName;
+ const instance = new PromptInstance(record, this.scopeProviders, this.appOwner);
+ await instance.ready;
+ this._prompts.registerPromptInstance(instance);
+ promptCount++;
+ } else {
+ // Plain object → existing path
+ const promptDef = normalizePromptFromEsmExport(rawPrompt);
+ if (promptDef) {
+ const instance = createEsmPromptInstance(promptDef, this.scopeProviders, this.appOwner, namespace);
+ await instance.ready;
+ this._prompts.registerPromptInstance(instance);
+ promptCount++;
+ }
+ }
+ }
+ }
+
+ logger.info(
+ `ESM app ${this.id} registered: ${toolCount} tools, ${resourceCount} resources, ${promptCount} prompts`,
+ );
+ }
+
+ /**
+ * Handle a new version detected by the version poller.
+ */
+ private async handleVersionUpdate(_packageName: string, oldVersion: string, newVersion: string): Promise {
+ const logger = this.scopeProviders.getActiveScope().logger;
+
+ if (this.updateInProgress) {
+ logger.warn(`Update already in progress for ${this.id}, skipping ${newVersion}`);
+ return;
+ }
+ this.updateInProgress = true;
+
+ logger.info(`Updating ESM app ${this.id}: ${oldVersion} → ${newVersion}`);
+
+ try {
+ // Reload the package
+ this.loadResult = await this.loader.load(this.specifier);
+
+ // Replace all registrations with the new manifest's primitives.
+ // replaceAll emits change events, notifying connected MCP clients.
+ this._tools.replaceAll([], this.appOwner);
+ this._resources.replaceAll([], this.appOwner);
+ this._prompts.replaceAll([], this.appOwner);
+
+ await this.registerFromManifest(this.loadResult.manifest);
+
+ logger.info(`ESM app ${this.id} updated to ${newVersion}`);
+ } catch (error) {
+ logger.error(`Failed to update ESM app ${this.id} to ${newVersion}: ${(error as Error).message}`);
+ } finally {
+ this.updateInProgress = false;
+ }
+ }
+
+ /**
+ * Map a public PackageLoader config to internal EsmRegistryAuth.
+ */
+ private deriveRegistryAuth(loader?: {
+ url?: string;
+ registryUrl?: string;
+ token?: string;
+ tokenEnvVar?: string;
+ }): EsmRegistryAuth | undefined {
+ return loader
+ ? {
+ registryUrl: loader.registryUrl ?? loader.url,
+ token: loader.token,
+ tokenEnvVar: loader.tokenEnvVar,
+ }
+ : undefined;
+ }
+}
diff --git a/libs/sdk/src/app/instances/esm-normalize.utils.ts b/libs/sdk/src/app/instances/esm-normalize.utils.ts
new file mode 100644
index 000000000..489876b03
--- /dev/null
+++ b/libs/sdk/src/app/instances/esm-normalize.utils.ts
@@ -0,0 +1,173 @@
+/**
+ * @file esm-normalize.utils.ts
+ * @description Utilities to normalize raw ESM module exports into typed definitions
+ * usable by the ESM instance factories.
+ *
+ * Handles plain object exports with execute/read functions.
+ * Decorated classes (@Tool/@Resource/@Prompt) are detected separately via
+ * `isDecoratedToolClass` etc. and handled by the standard normalization path
+ * in AppEsmInstance (full DI support).
+ */
+
+import { getMetadata, isClass } from '@frontmcp/di';
+import { isValidMcpUri } from '@frontmcp/utils';
+import {
+ FrontMcpToolTokens,
+ FrontMcpResourceTokens,
+ FrontMcpPromptTokens,
+ FrontMcpLocalAppTokens,
+ FrontMcpSkillTokens,
+ FrontMcpJobTokens,
+ FrontMcpAgentTokens,
+ FrontMcpWorkflowTokens,
+} from '../../common/tokens';
+import type { EsmToolDefinition, EsmResourceDefinition, EsmPromptDefinition } from '../../esm-loader/factories';
+
+// ═══════════════════════════════════════════════════════════════════
+// DECORATED CLASS DETECTION
+// ═══════════════════════════════════════════════════════════════════
+
+/**
+ * Check if a raw ESM export is a class decorated with @App.
+ * Detects standard FrontMCP @App-decorated classes loaded from npm packages.
+ */
+export function isDecoratedAppClass(raw: unknown): boolean {
+ return isClass(raw) && getMetadata(FrontMcpLocalAppTokens.type, raw) === true;
+}
+
+/**
+ * Check if a raw ESM export is a class decorated with @Tool.
+ * Uses the actual Symbol tokens from FrontMcpToolTokens (not string keys).
+ */
+export function isDecoratedToolClass(raw: unknown): boolean {
+ return isClass(raw) && getMetadata(FrontMcpToolTokens.type, raw) === true;
+}
+
+/**
+ * Check if a raw ESM export is a class decorated with @Resource.
+ */
+export function isDecoratedResourceClass(raw: unknown): boolean {
+ return isClass(raw) && getMetadata(FrontMcpResourceTokens.type, raw) === true;
+}
+
+/**
+ * Check if a raw ESM export is a class decorated with @Prompt.
+ */
+export function isDecoratedPromptClass(raw: unknown): boolean {
+ return isClass(raw) && getMetadata(FrontMcpPromptTokens.type, raw) === true;
+}
+
+/**
+ * Check if a raw ESM export is a class decorated with @Skill.
+ */
+export function isDecoratedSkillClass(raw: unknown): boolean {
+ return isClass(raw) && getMetadata(FrontMcpSkillTokens.type, raw) === true;
+}
+
+/**
+ * Check if a raw ESM export is a class decorated with @Job.
+ */
+export function isDecoratedJobClass(raw: unknown): boolean {
+ return isClass(raw) && getMetadata(FrontMcpJobTokens.type, raw) === true;
+}
+
+/**
+ * Check if a raw ESM export is a class decorated with @Agent.
+ */
+export function isDecoratedAgentClass(raw: unknown): boolean {
+ return isClass(raw) && getMetadata(FrontMcpAgentTokens.type, raw) === true;
+}
+
+/**
+ * Check if a raw ESM export is a class decorated with @Workflow.
+ */
+export function isDecoratedWorkflowClass(raw: unknown): boolean {
+ return isClass(raw) && getMetadata(FrontMcpWorkflowTokens.type, raw) === true;
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// PLAIN OBJECT NORMALIZATION
+// ═══════════════════════════════════════════════════════════════════
+
+/**
+ * Normalize a raw tool export from an ESM module into an EsmToolDefinition.
+ *
+ * Only handles plain objects: { name, description?, inputSchema?, execute }.
+ * Decorated classes are detected by `isDecoratedToolClass()` and handled
+ * through the standard `normalizeTool()` path in AppEsmInstance.
+ *
+ * @returns Normalized tool definition, or undefined if the export is not a valid plain-object tool
+ */
+export function normalizeToolFromEsmExport(raw: unknown): EsmToolDefinition | undefined {
+ if (!raw || typeof raw !== 'object' || isClass(raw)) return undefined;
+
+ const obj = raw as Record;
+ if (typeof obj['execute'] === 'function' && typeof obj['name'] === 'string') {
+ const description = obj['description'];
+ const inputSchema = obj['inputSchema'];
+ const outputSchema = obj['outputSchema'];
+ return {
+ name: obj['name'] as string,
+ description: typeof description === 'string' ? description : undefined,
+ inputSchema:
+ inputSchema && typeof inputSchema === 'object' ? (inputSchema as Record) : undefined,
+ outputSchema: outputSchema && typeof outputSchema === 'object' ? outputSchema : undefined,
+ execute: obj['execute'] as EsmToolDefinition['execute'],
+ };
+ }
+
+ return undefined;
+}
+
+/**
+ * Normalize a raw resource export from an ESM module into an EsmResourceDefinition.
+ *
+ * Only handles plain objects: { name, description?, uri, mimeType?, read }.
+ * Decorated classes are detected by `isDecoratedResourceClass()` and handled
+ * through the standard `normalizeResource()` path in AppEsmInstance.
+ */
+export function normalizeResourceFromEsmExport(raw: unknown): EsmResourceDefinition | undefined {
+ if (!raw || typeof raw !== 'object' || isClass(raw)) return undefined;
+
+ const obj = raw as Record;
+ if (typeof obj['read'] === 'function' && typeof obj['name'] === 'string' && typeof obj['uri'] === 'string') {
+ const uri = obj['uri'] as string;
+ if (!isValidMcpUri(uri)) return undefined;
+ const description = obj['description'];
+ const mimeType = obj['mimeType'];
+ return {
+ name: obj['name'] as string,
+ description: typeof description === 'string' ? description : undefined,
+ uri,
+ mimeType: typeof mimeType === 'string' ? mimeType : undefined,
+ read: obj['read'] as EsmResourceDefinition['read'],
+ };
+ }
+
+ return undefined;
+}
+
+/**
+ * Normalize a raw prompt export from an ESM module into an EsmPromptDefinition.
+ *
+ * Only handles plain objects: { name, description?, arguments?, execute }.
+ * Decorated classes are detected by `isDecoratedPromptClass()` and handled
+ * through the standard `normalizePrompt()` path in AppEsmInstance.
+ */
+export function normalizePromptFromEsmExport(raw: unknown): EsmPromptDefinition | undefined {
+ if (!raw || typeof raw !== 'object' || isClass(raw)) return undefined;
+
+ const obj = raw as Record;
+ if (typeof obj['execute'] === 'function' && typeof obj['name'] === 'string') {
+ const description = obj['description'];
+ const args = obj['arguments'];
+ return {
+ name: obj['name'] as string,
+ description: typeof description === 'string' ? description : undefined,
+ arguments: Array.isArray(args) ? (args as EsmPromptDefinition['arguments']) : undefined,
+ execute: obj['execute'] as EsmPromptDefinition['execute'],
+ };
+ }
+
+ return undefined;
+}
diff --git a/libs/sdk/src/app/instances/index.ts b/libs/sdk/src/app/instances/index.ts
index 5a45072e7..637d7229e 100644
--- a/libs/sdk/src/app/instances/index.ts
+++ b/libs/sdk/src/app/instances/index.ts
@@ -1,2 +1,3 @@
export * from './app.local.instance';
export * from './app.remote.instance';
+export * from './app.esm.instance';
diff --git a/libs/sdk/src/common/decorators/agent.decorator.ts b/libs/sdk/src/common/decorators/agent.decorator.ts
index 02a982e44..eb5036aec 100644
--- a/libs/sdk/src/common/decorators/agent.decorator.ts
+++ b/libs/sdk/src/common/decorators/agent.decorator.ts
@@ -100,6 +100,58 @@ function frontMcpAgent<
};
}
+// ═══════════════════════════════════════════════════════════════════
+// STATIC METHODS: Agent.esm() and Agent.remote()
+// ═══════════════════════════════════════════════════════════════════
+
+import type { EsmOptions, RemoteOptions } from '../metadata';
+import { AgentKind } from '../records/agent.record';
+import type { AgentEsmTargetRecord, AgentRemoteRecord } from '../records/agent.record';
+import { parsePackageSpecifier } from '../../esm-loader/package-specifier';
+import { validateRemoteUrl } from '../utils/validate-remote-url';
+
+function agentEsm(specifier: string, targetName: string, options?: EsmOptions): AgentEsmTargetRecord {
+ const parsed = parsePackageSpecifier(specifier);
+ return {
+ kind: AgentKind.ESM,
+ provide: Symbol(`esm-agent:${parsed.fullName}:${targetName}`),
+ specifier: parsed,
+ targetName,
+ options,
+ metadata: {
+ name: targetName,
+ description: `Agent "${targetName}" from ${parsed.fullName}`,
+ // Minimal placeholder - full metadata comes from the loaded class
+ llm: { adapter: 'placeholder' } as unknown as AgentMetadata['llm'],
+ ...options?.metadata,
+ },
+ };
+}
+
+function agentRemote(url: string, targetName: string, options?: RemoteOptions): AgentRemoteRecord {
+ validateRemoteUrl(url);
+ return {
+ kind: AgentKind.REMOTE,
+ provide: Symbol(`remote-agent:${url}:${targetName}`),
+ url,
+ targetName,
+ transportOptions: options?.transportOptions,
+ remoteAuth: options?.remoteAuth,
+ metadata: {
+ name: targetName,
+ description: `Remote agent "${targetName}" from ${url}`,
+ // Minimal placeholder - full metadata comes from the remote server
+ llm: { adapter: 'placeholder' } as unknown as AgentMetadata['llm'],
+ ...options?.metadata,
+ },
+ };
+}
+
+Object.assign(FrontMcpAgent, {
+ esm: agentEsm,
+ remote: agentRemote,
+});
+
export { FrontMcpAgent, FrontMcpAgent as Agent, frontMcpAgent, frontMcpAgent as agent };
// ============================================================================
diff --git a/libs/sdk/src/common/decorators/app.decorator.ts b/libs/sdk/src/common/decorators/app.decorator.ts
index df76bb521..6854fbd0c 100644
--- a/libs/sdk/src/common/decorators/app.decorator.ts
+++ b/libs/sdk/src/common/decorators/app.decorator.ts
@@ -1,10 +1,39 @@
import 'reflect-metadata';
import { FrontMcpLocalAppTokens } from '../tokens';
-import { LocalAppMetadata, frontMcpLocalAppMetadataSchema } from '../metadata';
+import {
+ LocalAppMetadata,
+ frontMcpLocalAppMetadataSchema,
+ RemoteAppMetadata,
+ EsmAppOptions,
+ RemoteUrlAppOptions,
+} from '../metadata';
import { InvalidDecoratorMetadataError } from '../../errors/decorator.errors';
+import { parsePackageSpecifier } from '../../esm-loader/package-specifier';
+import { validateRemoteUrl } from '../utils/validate-remote-url';
/**
- * Decorator that marks a class as a McpApp module and provides metadata
+ * Decorator that marks a class as a McpApp module and provides metadata.
+ *
+ * Also provides static methods for declaring external apps:
+ * - `App.esm()` — load an @App class from an npm package
+ * - `App.remote()` — connect to an external MCP server
+ *
+ * @example
+ * ```ts
+ * import { FrontMcp, App } from '@frontmcp/sdk';
+ *
+ * @App({ name: 'Local', tools: [EchoTool] })
+ * class LocalApp {}
+ *
+ * @FrontMcp({
+ * apps: [
+ * LocalApp,
+ * App.esm('@acme/tools@^1.0.0', { namespace: 'acme' }),
+ * App.remote('https://api.example.com/mcp', { namespace: 'api' }),
+ * ],
+ * })
+ * export default class Server {}
+ * ```
*/
function FrontMcpApp(providedMetadata: LocalAppMetadata): ClassDecorator {
return (target: Function) => {
@@ -46,4 +75,101 @@ function FrontMcpApp(providedMetadata: LocalAppMetadata): ClassDecorator {
};
}
-export { FrontMcpApp, FrontMcpApp as App };
+// ═══════════════════════════════════════════════════════════════════
+// STATIC METHODS: App.esm() and App.remote()
+// ═══════════════════════════════════════════════════════════════════
+
+/**
+ * Load an @App-decorated class from an npm package at runtime.
+ *
+ * @param specifier - npm package specifier (e.g., '@acme/tools@^1.0.0')
+ * @param options - Optional per-app overrides
+ */
+function esmApp(specifier: string, options?: EsmAppOptions): RemoteAppMetadata {
+ const parsed = parsePackageSpecifier(specifier);
+
+ const packageConfig: RemoteAppMetadata['packageConfig'] = {};
+ let hasPackageConfig = false;
+
+ if (options?.loader) {
+ packageConfig.loader = options.loader;
+ hasPackageConfig = true;
+ }
+ if (options?.autoUpdate) {
+ packageConfig.autoUpdate = options.autoUpdate;
+ hasPackageConfig = true;
+ }
+ if (options?.cacheTTL !== undefined) {
+ packageConfig.cacheTTL = options.cacheTTL;
+ hasPackageConfig = true;
+ }
+ if (options?.importMap) {
+ packageConfig.importMap = options.importMap;
+ hasPackageConfig = true;
+ }
+
+ return {
+ name: options?.name ?? parsed.fullName,
+ urlType: 'esm',
+ url: specifier,
+ namespace: options?.namespace,
+ description: options?.description,
+ standalone: options?.standalone ?? false,
+ filter: options?.filter,
+ ...(hasPackageConfig ? { packageConfig } : {}),
+ };
+}
+
+/**
+ * Connect to an external MCP server via HTTP.
+ *
+ * @param url - MCP server endpoint URL (e.g., 'https://api.example.com/mcp')
+ * @param options - Optional per-app overrides
+ */
+function remoteApp(url: string, options?: RemoteUrlAppOptions): RemoteAppMetadata {
+ validateRemoteUrl(url);
+ let derivedName: string;
+ try {
+ const parsed = new URL(url);
+ derivedName = parsed.hostname.split('.')[0];
+ } catch {
+ derivedName = url;
+ }
+
+ return {
+ name: options?.name ?? derivedName,
+ urlType: 'url',
+ url,
+ namespace: options?.namespace,
+ description: options?.description,
+ standalone: options?.standalone ?? false,
+ transportOptions: options?.transportOptions,
+ remoteAuth: options?.remoteAuth,
+ refreshInterval: options?.refreshInterval,
+ cacheTTL: options?.cacheTTL,
+ filter: options?.filter,
+ };
+}
+
+// Attach static methods to the decorator function
+Object.assign(FrontMcpApp, {
+ esm: esmApp,
+ remote: remoteApp,
+});
+
+// ═══════════════════════════════════════════════════════════════════
+// TYPE-SAFE EXPORT
+// ═══════════════════════════════════════════════════════════════════
+
+/**
+ * The `App` type — callable as a decorator AND has `.esm()` / `.remote()` static methods.
+ */
+type AppDecorator = {
+ (metadata: LocalAppMetadata): ClassDecorator;
+ esm(specifier: string, options?: EsmAppOptions): RemoteAppMetadata;
+ remote(url: string, options?: RemoteUrlAppOptions): RemoteAppMetadata;
+};
+
+const App = FrontMcpApp as unknown as AppDecorator;
+
+export { FrontMcpApp, App };
diff --git a/libs/sdk/src/common/decorators/job.decorator.ts b/libs/sdk/src/common/decorators/job.decorator.ts
index 7ad9fa01d..df0139427 100644
--- a/libs/sdk/src/common/decorators/job.decorator.ts
+++ b/libs/sdk/src/common/decorators/job.decorator.ts
@@ -54,4 +54,64 @@ function frontMcpJob<
};
}
-export { FrontMcpJob, FrontMcpJob as Job, frontMcpJob, frontMcpJob as job };
+// ═══════════════════════════════════════════════════════════════════
+// STATIC METHODS: Job.esm() and Job.remote()
+// ═══════════════════════════════════════════════════════════════════
+
+import type { EsmOptions, RemoteOptions } from '../metadata';
+import { JobKind } from '../records/job.record';
+import type { JobEsmTargetRecord, JobRemoteRecord } from '../records/job.record';
+import { parsePackageSpecifier } from '../../esm-loader/package-specifier';
+import { validateRemoteUrl } from '../utils/validate-remote-url';
+
+function jobEsm(specifier: string, targetName: string, options?: EsmOptions): JobEsmTargetRecord {
+ const parsed = parsePackageSpecifier(specifier);
+ return {
+ kind: JobKind.ESM,
+ provide: Symbol(`esm-job:${parsed.fullName}:${targetName}`),
+ specifier: parsed,
+ targetName,
+ options,
+ metadata: {
+ name: targetName,
+ description: `Job "${targetName}" from ${parsed.fullName}`,
+ inputSchema: {},
+ outputSchema: {},
+ ...options?.metadata,
+ },
+ };
+}
+
+function jobRemote(url: string, targetName: string, options?: RemoteOptions): JobRemoteRecord {
+ validateRemoteUrl(url);
+ return {
+ kind: JobKind.REMOTE,
+ provide: Symbol(`remote-job:${url}:${targetName}`),
+ url,
+ targetName,
+ transportOptions: options?.transportOptions,
+ remoteAuth: options?.remoteAuth,
+ metadata: {
+ name: targetName,
+ description: `Remote job "${targetName}" from ${url}`,
+ inputSchema: {},
+ outputSchema: {},
+ ...options?.metadata,
+ },
+ };
+}
+
+Object.assign(FrontMcpJob, {
+ esm: jobEsm,
+ remote: jobRemote,
+});
+
+type JobDecorator = {
+ (metadata: JobMetadata): ClassDecorator;
+ esm: typeof jobEsm;
+ remote: typeof jobRemote;
+};
+
+const Job = FrontMcpJob as unknown as JobDecorator;
+
+export { FrontMcpJob, Job, frontMcpJob, frontMcpJob as job };
diff --git a/libs/sdk/src/common/decorators/prompt.decorator.ts b/libs/sdk/src/common/decorators/prompt.decorator.ts
index 0bc61bae9..46d1b4caa 100644
--- a/libs/sdk/src/common/decorators/prompt.decorator.ts
+++ b/libs/sdk/src/common/decorators/prompt.decorator.ts
@@ -53,4 +53,62 @@ function frontMcpPrompt(
};
}
-export { FrontMcpPrompt, FrontMcpPrompt as Prompt, frontMcpPrompt, frontMcpPrompt as prompt };
+// ═══════════════════════════════════════════════════════════════════
+// STATIC METHODS: Prompt.esm() and Prompt.remote()
+// ═══════════════════════════════════════════════════════════════════
+
+import type { EsmOptions, RemoteOptions } from '../metadata';
+import { PromptKind } from '../records/prompt.record';
+import type { PromptEsmTargetRecord, PromptRemoteRecord } from '../records/prompt.record';
+import { parsePackageSpecifier } from '../../esm-loader/package-specifier';
+import { validateRemoteUrl } from '../utils/validate-remote-url';
+
+function promptEsm(specifier: string, targetName: string, options?: EsmOptions): PromptEsmTargetRecord {
+ const parsed = parsePackageSpecifier(specifier);
+ return {
+ kind: PromptKind.ESM,
+ provide: Symbol(`esm-prompt:${parsed.fullName}:${targetName}`),
+ specifier: parsed,
+ targetName,
+ options,
+ metadata: {
+ name: targetName,
+ description: `Prompt "${targetName}" from ${parsed.fullName}`,
+ arguments: [],
+ ...options?.metadata,
+ },
+ };
+}
+
+function promptRemote(url: string, targetName: string, options?: RemoteOptions): PromptRemoteRecord {
+ validateRemoteUrl(url);
+ return {
+ kind: PromptKind.REMOTE,
+ provide: Symbol(`remote-prompt:${url}:${targetName}`),
+ url,
+ targetName,
+ transportOptions: options?.transportOptions,
+ remoteAuth: options?.remoteAuth,
+ metadata: {
+ name: targetName,
+ description: `Remote prompt "${targetName}" from ${url}`,
+ arguments: [],
+ ...options?.metadata,
+ },
+ };
+}
+
+Object.assign(FrontMcpPrompt, {
+ esm: promptEsm,
+ remote: promptRemote,
+});
+
+type PromptDecorator = {
+ (metadata: PromptMetadata): ClassDecorator;
+ esm: typeof promptEsm;
+ remote: typeof promptRemote;
+};
+
+const Prompt = FrontMcpPrompt as unknown as PromptDecorator;
+
+export { FrontMcpPrompt, Prompt, frontMcpPrompt, frontMcpPrompt as prompt };
diff --git a/libs/sdk/src/common/decorators/resource.decorator.ts b/libs/sdk/src/common/decorators/resource.decorator.ts
index e7b52af9a..f52ce9fcb 100644
--- a/libs/sdk/src/common/decorators/resource.decorator.ts
+++ b/libs/sdk/src/common/decorators/resource.decorator.ts
@@ -118,9 +118,75 @@ function frontMcpResourceTemplate(
};
}
+// ═══════════════════════════════════════════════════════════════════
+// STATIC METHODS: Resource.esm() and Resource.remote()
+// ═══════════════════════════════════════════════════════════════════
+
+import type { EsmOptions, RemoteOptions } from '../metadata';
+import { ResourceKind } from '../records/resource.record';
+import type { ResourceEsmTargetRecord, ResourceRemoteRecord } from '../records/resource.record';
+import { parsePackageSpecifier } from '../../esm-loader/package-specifier';
+import { validateRemoteUrl } from '../utils/validate-remote-url';
+
+function resourceEsm(
+ specifier: string,
+ targetName: string,
+ options?: EsmOptions,
+): ResourceEsmTargetRecord {
+ const parsed = parsePackageSpecifier(specifier);
+ return {
+ kind: ResourceKind.ESM,
+ provide: Symbol(`esm-resource:${parsed.fullName}:${targetName}`),
+ specifier: parsed,
+ targetName,
+ options,
+ metadata: {
+ name: targetName,
+ description: `Resource "${targetName}" from ${parsed.fullName}`,
+ uri: `esm://${targetName}`,
+ ...options?.metadata,
+ },
+ };
+}
+
+function resourceRemote(
+ url: string,
+ targetName: string,
+ options?: RemoteOptions,
+): ResourceRemoteRecord {
+ validateRemoteUrl(url);
+ return {
+ kind: ResourceKind.REMOTE,
+ provide: Symbol(`remote-resource:${url}:${targetName}`),
+ url,
+ targetName,
+ transportOptions: options?.transportOptions,
+ remoteAuth: options?.remoteAuth,
+ metadata: {
+ name: targetName,
+ description: `Remote resource "${targetName}" from ${url}`,
+ uri: `remote://${targetName}`,
+ ...options?.metadata,
+ },
+ };
+}
+
+Object.assign(FrontMcpResource, {
+ esm: resourceEsm,
+ remote: resourceRemote,
+});
+
+type ResourceDecorator = {
+ (metadata: ResourceMetadata): ClassDecorator;
+ esm: typeof resourceEsm;
+ remote: typeof resourceRemote;
+};
+
+const Resource = FrontMcpResource as unknown as ResourceDecorator;
+
export {
FrontMcpResource,
- FrontMcpResource as Resource,
+ Resource,
FrontMcpResourceTemplate,
FrontMcpResourceTemplate as ResourceTemplate,
frontMcpResource,
diff --git a/libs/sdk/src/common/decorators/skill.decorator.ts b/libs/sdk/src/common/decorators/skill.decorator.ts
index 16966ae9c..5240c9b95 100644
--- a/libs/sdk/src/common/decorators/skill.decorator.ts
+++ b/libs/sdk/src/common/decorators/skill.decorator.ts
@@ -137,8 +137,65 @@ function frontMcpSkill(providedMetadata: SkillMetadata): SkillValueRecord {
};
}
+// ═══════════════════════════════════════════════════════════════════
+// STATIC METHODS: Skill.esm() and Skill.remote()
+// ═══════════════════════════════════════════════════════════════════
+
+import type { EsmOptions, RemoteOptions } from '../metadata';
+import type { SkillEsmTargetRecord, SkillRemoteRecord } from '../records/skill.record';
+import { parsePackageSpecifier } from '../../esm-loader/package-specifier';
+import { validateRemoteUrl } from '../utils/validate-remote-url';
+
+function skillEsm(specifier: string, targetName: string, options?: EsmOptions): SkillEsmTargetRecord {
+ const parsed = parsePackageSpecifier(specifier);
+ return {
+ kind: SkillKind.ESM,
+ provide: Symbol(`esm-skill:${parsed.fullName}:${targetName}`),
+ specifier: parsed,
+ targetName,
+ options,
+ metadata: {
+ name: targetName,
+ description: `Skill "${targetName}" from ${parsed.fullName}`,
+ instructions: options?.metadata?.instructions ?? '',
+ ...options?.metadata,
+ } as SkillMetadata,
+ };
+}
+
+function skillRemote(url: string, targetName: string, options?: RemoteOptions): SkillRemoteRecord {
+ validateRemoteUrl(url);
+ return {
+ kind: SkillKind.REMOTE,
+ provide: Symbol(`remote-skill:${url}:${targetName}`),
+ url,
+ targetName,
+ transportOptions: options?.transportOptions,
+ remoteAuth: options?.remoteAuth,
+ metadata: {
+ name: targetName,
+ description: `Remote skill "${targetName}" from ${url}`,
+ instructions: options?.metadata?.instructions ?? '',
+ ...options?.metadata,
+ } as SkillMetadata,
+ };
+}
+
+Object.assign(FrontMcpSkill, {
+ esm: skillEsm,
+ remote: skillRemote,
+});
+
+type SkillDecorator = {
+ (metadata: SkillMetadata): ClassDecorator;
+ esm: typeof skillEsm;
+ remote: typeof skillRemote;
+};
+
+const Skill = FrontMcpSkill as unknown as SkillDecorator;
+
// Export with aliases
-export { FrontMcpSkill, FrontMcpSkill as Skill, frontMcpSkill, frontMcpSkill as skill };
+export { FrontMcpSkill, Skill, frontMcpSkill, frontMcpSkill as skill };
/**
* Check if a class has the @Skill decorator.
diff --git a/libs/sdk/src/common/decorators/tool.decorator.ts b/libs/sdk/src/common/decorators/tool.decorator.ts
index ca1e0c733..265ef4463 100644
--- a/libs/sdk/src/common/decorators/tool.decorator.ts
+++ b/libs/sdk/src/common/decorators/tool.decorator.ts
@@ -11,8 +11,13 @@ import {
ToolOutputType,
} from '../metadata';
import type { ToolUIConfig } from '../metadata/tool-ui.metadata';
+import type { EsmOptions, RemoteOptions } from '../metadata';
import z from 'zod';
import { ToolContext } from '../interfaces';
+import { ToolKind } from '../records/tool.record';
+import type { ToolEsmTargetRecord, ToolRemoteRecord } from '../records/tool.record';
+import { parsePackageSpecifier } from '../../esm-loader/package-specifier';
+import { validateRemoteUrl } from '../utils/validate-remote-url';
/**
* Decorator that marks a class as a McpTool module and provides metadata
@@ -61,6 +66,50 @@ function frontMcpTool<
};
}
+// ═══════════════════════════════════════════════════════════════════
+// STATIC METHODS: Tool.esm() and Tool.remote()
+// ═══════════════════════════════════════════════════════════════════
+
+function toolEsm(specifier: string, targetName: string, options?: EsmOptions): ToolEsmTargetRecord {
+ const parsed = parsePackageSpecifier(specifier);
+ return {
+ kind: ToolKind.ESM,
+ provide: Symbol(`esm-tool:${parsed.fullName}:${targetName}`),
+ specifier: parsed,
+ targetName,
+ options,
+ metadata: {
+ name: targetName,
+ description: `Tool "${targetName}" from ${parsed.fullName}`,
+ inputSchema: {},
+ ...options?.metadata,
+ },
+ };
+}
+
+function toolRemote(url: string, targetName: string, options?: RemoteOptions): ToolRemoteRecord {
+ validateRemoteUrl(url);
+ return {
+ kind: ToolKind.REMOTE,
+ provide: Symbol(`remote-tool:${url}:${targetName}`),
+ url,
+ targetName,
+ transportOptions: options?.transportOptions,
+ remoteAuth: options?.remoteAuth,
+ metadata: {
+ name: targetName,
+ description: `Remote tool "${targetName}" from ${url}`,
+ inputSchema: {},
+ ...options?.metadata,
+ },
+ };
+}
+
+Object.assign(FrontMcpTool, {
+ esm: toolEsm,
+ remote: toolRemote,
+});
+
export { FrontMcpTool, FrontMcpTool as Tool, frontMcpTool, frontMcpTool as tool };
/**
diff --git a/libs/sdk/src/common/interfaces/job.interface.ts b/libs/sdk/src/common/interfaces/job.interface.ts
index 69217bcdc..af9155fa0 100644
--- a/libs/sdk/src/common/interfaces/job.interface.ts
+++ b/libs/sdk/src/common/interfaces/job.interface.ts
@@ -5,7 +5,7 @@ import { FlowControl } from './flow.interface';
import { ToolInputOf, ToolOutputOf } from '../decorators';
import { ExecutionContextBase, ExecutionContextBaseArgs } from './execution-context.interface';
-export type JobType = Type | FuncType;
+export type JobType = Type | FuncType | string;
export type JobCtorArgs = ExecutionContextBaseArgs & {
metadata: JobMetadata;
diff --git a/libs/sdk/src/common/interfaces/prompt.interface.ts b/libs/sdk/src/common/interfaces/prompt.interface.ts
index 4b7d0058f..e7c0b009f 100644
--- a/libs/sdk/src/common/interfaces/prompt.interface.ts
+++ b/libs/sdk/src/common/interfaces/prompt.interface.ts
@@ -21,7 +21,7 @@ export interface PromptInterface {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type FunctionalPromptType = (() => any) & { [key: symbol]: unknown };
-export type PromptType = Type | FuncType | FunctionalPromptType;
+export type PromptType = Type | FuncType | FunctionalPromptType | string;
type HistoryEntry = {
at: number;
diff --git a/libs/sdk/src/common/interfaces/resource.interface.ts b/libs/sdk/src/common/interfaces/resource.interface.ts
index 9c1203632..57add6f2d 100644
--- a/libs/sdk/src/common/interfaces/resource.interface.ts
+++ b/libs/sdk/src/common/interfaces/resource.interface.ts
@@ -31,7 +31,8 @@ export type FunctionResourceType = (...args: any[]) => any;
*/
export type ResourceType = Record, Out = unknown> =
| Type>
- | FunctionResourceType;
+ | FunctionResourceType
+ | string;
type HistoryEntry = {
at: number;
diff --git a/libs/sdk/src/common/interfaces/skill.interface.ts b/libs/sdk/src/common/interfaces/skill.interface.ts
index a6c79f964..4f0a45163 100644
--- a/libs/sdk/src/common/interfaces/skill.interface.ts
+++ b/libs/sdk/src/common/interfaces/skill.interface.ts
@@ -8,7 +8,7 @@ import { SkillRecord } from '../records';
/**
* Type for skill definitions that can be passed to FrontMcp apps/plugins.
*/
-export type SkillType = Type | SkillRecord;
+export type SkillType = Type | SkillRecord | string;
/**
* Full content returned when loading a skill.
diff --git a/libs/sdk/src/common/interfaces/tool.interface.ts b/libs/sdk/src/common/interfaces/tool.interface.ts
index 253f2526e..bb0208a5d 100644
--- a/libs/sdk/src/common/interfaces/tool.interface.ts
+++ b/libs/sdk/src/common/interfaces/tool.interface.ts
@@ -7,7 +7,7 @@ import type { AIPlatformType, ClientInfo, McpLoggingLevel } from '../../notifica
import { ElicitResult, ElicitOptions, performElicit } from '../../elicitation';
import { ZodType } from 'zod';
-export type ToolType = Type | FuncType;
+export type ToolType = Type | FuncType | string;
type HistoryEntry = {
at: number;
diff --git a/libs/sdk/src/common/interfaces/workflow.interface.ts b/libs/sdk/src/common/interfaces/workflow.interface.ts
index ce2397fd4..1225c68c1 100644
--- a/libs/sdk/src/common/interfaces/workflow.interface.ts
+++ b/libs/sdk/src/common/interfaces/workflow.interface.ts
@@ -2,7 +2,7 @@ import { FuncType, Type } from '@frontmcp/di';
import { WorkflowMetadata, WorkflowStepResult } from '../metadata/workflow.metadata';
import { ExecutionContextBase, ExecutionContextBaseArgs } from './execution-context.interface';
-export type WorkflowType = Type | FuncType;
+export type WorkflowType = Type | FuncType | string;
export interface WorkflowExecutionResult {
workflowName: string;
diff --git a/libs/sdk/src/common/metadata/agent.metadata.ts b/libs/sdk/src/common/metadata/agent.metadata.ts
index c1cec223e..c47c8907c 100644
--- a/libs/sdk/src/common/metadata/agent.metadata.ts
+++ b/libs/sdk/src/common/metadata/agent.metadata.ts
@@ -22,7 +22,7 @@ import { ToolInputType, ToolOutputType } from './tool.metadata';
* Agent type definition (class or factory function).
* Used in app/plugin metadata for defining agents.
*/
-export type AgentType = Type | FuncType;
+export type AgentType = Type | FuncType | string;
declare global {
/**
diff --git a/libs/sdk/src/common/metadata/app-filter.metadata.ts b/libs/sdk/src/common/metadata/app-filter.metadata.ts
new file mode 100644
index 000000000..a6356eed4
--- /dev/null
+++ b/libs/sdk/src/common/metadata/app-filter.metadata.ts
@@ -0,0 +1,88 @@
+/**
+ * @file app-filter.metadata.ts
+ * @description Include/exclude filtering for tools, resources, prompts, and other primitives
+ * loaded from remote or ESM-based apps.
+ */
+
+import { z } from 'zod';
+
+/**
+ * Glob-style name patterns per primitive type.
+ * Each key maps to an array of name patterns (supports `*` wildcards).
+ *
+ * @example
+ * ```ts
+ * { tools: ['echo', 'add'], resources: ['config:*'] }
+ * ```
+ */
+export interface PrimitiveFilterMap {
+ tools?: string[];
+ resources?: string[];
+ prompts?: string[];
+ agents?: string[];
+ skills?: string[];
+ jobs?: string[];
+ workflows?: string[];
+}
+
+/**
+ * Keys that can appear in a PrimitiveFilterMap.
+ */
+export const PRIMITIVE_FILTER_KEYS = [
+ 'tools',
+ 'resources',
+ 'prompts',
+ 'agents',
+ 'skills',
+ 'jobs',
+ 'workflows',
+] as const;
+
+export type PrimitiveFilterKey = (typeof PRIMITIVE_FILTER_KEYS)[number];
+
+/**
+ * Configuration for include/exclude filtering of primitives from external apps.
+ *
+ * @example
+ * ```ts
+ * // Include everything, exclude specific tools
+ * { default: 'include', exclude: { tools: ['dangerous-*'] } }
+ *
+ * // Exclude everything, include only specific tools
+ * { default: 'exclude', include: { tools: ['echo', 'add'] } }
+ * ```
+ */
+export interface AppFilterConfig {
+ /**
+ * Default behavior for primitives not explicitly listed.
+ * - `'include'` (default): everything is included unless in `exclude`
+ * - `'exclude'`: everything is excluded unless in `include`
+ */
+ default?: 'include' | 'exclude';
+
+ /** Include specific primitives by type and name glob pattern */
+ include?: PrimitiveFilterMap;
+
+ /** Exclude specific primitives by type and name glob pattern */
+ exclude?: PrimitiveFilterMap;
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// ZOD SCHEMAS
+// ═══════════════════════════════════════════════════════════════════
+
+const primitiveFilterMapSchema = z.object({
+ tools: z.array(z.string()).optional(),
+ resources: z.array(z.string()).optional(),
+ prompts: z.array(z.string()).optional(),
+ agents: z.array(z.string()).optional(),
+ skills: z.array(z.string()).optional(),
+ jobs: z.array(z.string()).optional(),
+ workflows: z.array(z.string()).optional(),
+});
+
+export const appFilterConfigSchema = z.object({
+ default: z.enum(['include', 'exclude']).optional(),
+ include: primitiveFilterMapSchema.optional(),
+ exclude: primitiveFilterMapSchema.optional(),
+});
diff --git a/libs/sdk/src/common/metadata/app.metadata.ts b/libs/sdk/src/common/metadata/app.metadata.ts
index fdd62a246..bfac73800 100644
--- a/libs/sdk/src/common/metadata/app.metadata.ts
+++ b/libs/sdk/src/common/metadata/app.metadata.ts
@@ -1,6 +1,9 @@
import { z } from 'zod';
import { isValidMcpUri } from '@frontmcp/utils';
import { RawZodShape, authOptionsSchema, AuthOptionsInput } from '../types';
+import type { AppFilterConfig } from './app-filter.metadata';
+import { appFilterConfigSchema } from './app-filter.metadata';
+import type { EsmOptions, RemoteOptions } from './remote-primitive.metadata';
import type {
AgentType,
ProviderType,
@@ -251,6 +254,23 @@ export type RemoteAuthConfig =
mode: 'oauth';
};
+/**
+ * Unified loader configuration for npm/ESM package resolution and bundle fetching.
+ * When `url` is set but `registryUrl` is not, both registry and bundles use `url`.
+ * When `registryUrl` is also set, registry uses `registryUrl`, bundles use `url`.
+ */
+export interface PackageLoader {
+ /** Base URL for the loader server (registry API + bundle fetching).
+ * Defaults: registry → https://registry.npmjs.org, bundles → https://esm.sh */
+ url?: string;
+ /** Separate registry URL for version resolution (if different from bundle URL) */
+ registryUrl?: string;
+ /** Bearer token for authentication */
+ token?: string;
+ /** Env var name containing the bearer token */
+ tokenEnvVar?: string;
+}
+
/**
* Declarative metadata describing what a remote encapsulated mcp app.
*/
@@ -328,6 +348,41 @@ export interface RemoteAppMetadata {
*/
cacheTTL?: number;
+ /**
+ * ESM/NPM-specific configuration (only used when urlType is 'npm' or 'esm').
+ * Configures loader endpoints, auto-update, caching, and import map overrides.
+ */
+ packageConfig?: {
+ /**
+ * Unified loader configuration for registry API + bundle fetching.
+ * Overrides the gateway-level `loader` when set.
+ */
+ loader?: PackageLoader;
+ /** Auto-update configuration for semver-based polling */
+ autoUpdate?: {
+ /** Enable background version polling */
+ enabled: boolean;
+ /** Polling interval in milliseconds (default: 300000 = 5 min) */
+ intervalMs?: number;
+ };
+ /** Local cache TTL in milliseconds (default: 86400000 = 24 hours) */
+ cacheTTL?: number;
+ /** Import map overrides for ESM resolution */
+ importMap?: Record;
+ };
+
+ /**
+ * Include/exclude filter for selectively importing primitives from this app.
+ * Supports per-type filtering (tools, resources, prompts, etc.) with glob patterns.
+ *
+ * @example
+ * ```ts
+ * { default: 'include', exclude: { tools: ['dangerous-*'] } }
+ * { default: 'exclude', include: { tools: ['echo', 'add'] } }
+ * ```
+ */
+ filter?: AppFilterConfig;
+
/**
* If true, the app will NOT be included and will act as a separated scope.
* If false, the app will be included in MultiApp frontmcp server.
@@ -337,6 +392,25 @@ export interface RemoteAppMetadata {
standalone: 'includeInParent' | boolean;
}
+export const packageLoaderSchema = z.object({
+ url: z.string().url().optional(),
+ registryUrl: z.string().url().optional(),
+ token: z.string().min(1).optional(),
+ tokenEnvVar: z.string().min(1).optional(),
+});
+
+const esmAutoUpdateOptionsSchema = z.object({
+ enabled: z.boolean(),
+ intervalMs: z.number().positive().optional(),
+});
+
+const packageConfigSchema = z.object({
+ loader: packageLoaderSchema.optional(),
+ autoUpdate: esmAutoUpdateOptionsSchema.optional(),
+ cacheTTL: z.number().positive().optional(),
+ importMap: z.record(z.string(), z.string()).optional(),
+});
+
const remoteTransportOptionsSchema = z.object({
timeout: z.number().optional(),
retryAttempts: z.number().optional(),
@@ -366,24 +440,80 @@ const remoteAuthConfigSchema = z.discriminatedUnion('mode', [
}),
]);
-export const frontMcpRemoteAppMetadataSchema = z.looseObject({
- id: z.string().optional(),
- name: z.string().min(1),
- description: z.string().optional(),
- urlType: z.enum(['worker', 'url', 'npm', 'esm']),
- url: z.string().refine(isValidMcpUri, {
- message: 'URL must have a valid scheme (e.g., https://, file://, custom://)',
- }),
- namespace: z.string().optional(),
- transportOptions: remoteTransportOptionsSchema.optional(),
- remoteAuth: remoteAuthConfigSchema.optional(),
- auth: authOptionsSchema.optional(),
- refreshInterval: z.number().optional(),
- cacheTTL: z.number().optional(),
- standalone: z
- .union([z.literal('includeInParent'), z.boolean()])
- .optional()
- .default(false),
-} satisfies RawZodShape);
+export const frontMcpRemoteAppMetadataSchema = z
+ .looseObject({
+ id: z.string().optional(),
+ name: z.string().min(1),
+ description: z.string().optional(),
+ urlType: z.enum(['worker', 'url', 'npm', 'esm']),
+ url: z.string().min(1),
+ namespace: z.string().optional(),
+ transportOptions: remoteTransportOptionsSchema.optional(),
+ remoteAuth: remoteAuthConfigSchema.optional(),
+ auth: authOptionsSchema.optional(),
+ refreshInterval: z.number().optional(),
+ cacheTTL: z.number().optional(),
+ packageConfig: packageConfigSchema.optional(),
+ filter: appFilterConfigSchema.optional(),
+ standalone: z
+ .union([z.literal('includeInParent'), z.boolean()])
+ .optional()
+ .default(false),
+ } satisfies RawZodShape)
+ .refine(
+ (data) => {
+ // For npm/esm urlTypes, url is a package specifier (no scheme required)
+ if (data.urlType === 'npm' || data.urlType === 'esm') return true;
+ // For url/worker types, require a valid URI scheme
+ return isValidMcpUri(data.url);
+ },
+ { message: 'URL must have a valid scheme for url/worker types (e.g., https://, file://)' },
+ );
export type AppMetadata = LocalAppMetadata | RemoteAppMetadata;
+
+// ═══════════════════════════════════════════════════════════════════
+// App.esm() / App.remote() OPTION TYPES
+// ═══════════════════════════════════════════════════════════════════
+
+/**
+ * Options for `App.esm()` — loads an @App-decorated class from an npm package.
+ * Extends {@link EsmOptions} with app-specific fields.
+ */
+export interface EsmAppOptions extends EsmOptions {
+ /** Override the auto-derived app name */
+ name?: string;
+ /** Namespace prefix for tools, resources, and prompts */
+ namespace?: string;
+ /** Human-readable description */
+ description?: string;
+ /** Standalone mode */
+ standalone?: boolean | 'includeInParent';
+ /** Auto-update configuration for semver-based polling */
+ autoUpdate?: { enabled: boolean; intervalMs?: number };
+ /** Import map overrides for ESM resolution */
+ importMap?: Record;
+ /** Include/exclude filter for selectively importing primitives */
+ filter?: AppFilterConfig;
+}
+
+/**
+ * Options for `App.remote()` — connects to an external MCP server via HTTP.
+ * Extends {@link RemoteOptions} with app-specific fields.
+ */
+export interface RemoteUrlAppOptions extends RemoteOptions {
+ /** Override the auto-derived app name */
+ name?: string;
+ /** Namespace prefix for tools, resources, and prompts */
+ namespace?: string;
+ /** Human-readable description */
+ description?: string;
+ /** Standalone mode */
+ standalone?: boolean | 'includeInParent';
+ /** Interval (ms) to refresh capabilities from the remote server */
+ refreshInterval?: number;
+ /** TTL (ms) for cached capabilities */
+ cacheTTL?: number;
+ /** Include/exclude filter for selectively importing primitives */
+ filter?: AppFilterConfig;
+}
diff --git a/libs/sdk/src/common/metadata/front-mcp.metadata.ts b/libs/sdk/src/common/metadata/front-mcp.metadata.ts
index 3b73aa2c9..23c0a959e 100644
--- a/libs/sdk/src/common/metadata/front-mcp.metadata.ts
+++ b/libs/sdk/src/common/metadata/front-mcp.metadata.ts
@@ -27,6 +27,8 @@ import {
SqliteOptionsInput,
sqliteOptionsSchema,
} from '../types';
+import type { PackageLoader } from './app.metadata';
+import { packageLoaderSchema } from './app.metadata';
import {
annotatedFrontMcpAppSchema,
annotatedFrontMcpPluginsSchema,
@@ -194,6 +196,12 @@ export interface FrontMcpBaseMetadata {
*/
sqlite?: SqliteOptionsInput;
+ /**
+ * Default package loader config for all npm/esm apps.
+ * Individual apps can override this via `packageConfig.loader`.
+ */
+ loader?: PackageLoader;
+
/**
* UI rendering configuration.
* Controls CDN overrides for widget import resolution.
@@ -274,6 +282,7 @@ export const frontMcpBaseSchema = z.object({
.optional(),
})
.optional(),
+ loader: packageLoaderSchema.optional(),
} satisfies RawZodShape);
export interface FrontMcpMultiAppMetadata extends FrontMcpBaseMetadata {
@@ -406,6 +415,7 @@ const frontMcpLiteSchema = z.object({
auth: authOptionsSchema.optional(),
logging: loggingOptionsSchema.optional(),
skillsConfig: skillsConfigOptionsSchema.optional(),
+ loader: packageLoaderSchema.optional(),
// Pass through without deep validation — not used in CLI mode
http: z.any().optional(),
redis: z.any().optional(),
diff --git a/libs/sdk/src/common/metadata/index.ts b/libs/sdk/src/common/metadata/index.ts
index b699711ba..8ec1a7d1e 100644
--- a/libs/sdk/src/common/metadata/index.ts
+++ b/libs/sdk/src/common/metadata/index.ts
@@ -20,3 +20,5 @@ export * from './agent.metadata';
export * from './skill.metadata';
export * from './job.metadata';
export * from './workflow.metadata';
+export * from './app-filter.metadata';
+export * from './remote-primitive.metadata';
diff --git a/libs/sdk/src/common/metadata/remote-primitive.metadata.ts b/libs/sdk/src/common/metadata/remote-primitive.metadata.ts
new file mode 100644
index 000000000..3ca139c38
--- /dev/null
+++ b/libs/sdk/src/common/metadata/remote-primitive.metadata.ts
@@ -0,0 +1,37 @@
+/**
+ * @file remote-primitive.metadata.ts
+ * @description Base option types for `.esm()` and `.remote()` static methods
+ * on primitive decorators (Tool, Resource, Prompt, Agent, Skill, Job) and App.
+ */
+
+import type { PackageLoader, RemoteTransportOptions, RemoteAuthConfig } from './app.metadata';
+
+/**
+ * Base ESM loading options shared by all `.esm()` methods (primitives and apps).
+ *
+ * @typeParam M - Metadata type for the primitive (e.g., `ToolMetadata`, `ResourceMetadata`).
+ * When provided, allows overriding the loaded primitive's metadata via `metadata`.
+ */
+export interface EsmOptions> {
+ /** Per-primitive loader override (registry URL, auth token) */
+ loader?: PackageLoader;
+ /** Cache TTL in milliseconds */
+ cacheTTL?: number;
+ /** Override or extend the loaded primitive's metadata (e.g., name, description) */
+ metadata?: Partial;
+}
+
+/**
+ * Base remote options shared by all `.remote()` methods (primitives and apps).
+ *
+ * @typeParam M - Metadata type for the primitive (e.g., `ToolMetadata`, `ResourceMetadata`).
+ * When provided, allows overriding the proxied primitive's metadata via `metadata`.
+ */
+export interface RemoteOptions> {
+ /** Transport-specific options (timeout, retries, headers) */
+ transportOptions?: RemoteTransportOptions;
+ /** Authentication config for the remote server */
+ remoteAuth?: RemoteAuthConfig;
+ /** Override or extend the proxied primitive's metadata (e.g., name, description) */
+ metadata?: Partial;
+}
diff --git a/libs/sdk/src/common/records/agent.record.ts b/libs/sdk/src/common/records/agent.record.ts
index c58b3a7e0..a093000f1 100644
--- a/libs/sdk/src/common/records/agent.record.ts
+++ b/libs/sdk/src/common/records/agent.record.ts
@@ -1,6 +1,9 @@
import { Token, Type } from '@frontmcp/di';
import { ProviderType } from '../interfaces';
import { AgentMetadata } from '../metadata';
+import type { ParsedPackageSpecifier } from '../../esm-loader/package-specifier';
+import type { RemoteTransportOptions, RemoteAuthConfig } from '../metadata';
+import type { EsmOptions } from '../metadata';
/**
* Discriminator enum for agent record types.
@@ -14,6 +17,10 @@ export enum AgentKind {
VALUE = 'VALUE',
/** Agent created via factory function */
FACTORY = 'FACTORY',
+ /** Agent loaded from an npm package via esm.sh */
+ ESM = 'ESM',
+ /** Agent proxied from a remote MCP server */
+ REMOTE = 'REMOTE',
}
/**
@@ -90,7 +97,51 @@ export interface AgentFactoryRecord {
providers?: ProviderType[];
}
+/**
+ * Record for ESM-loaded agents from npm packages.
+ */
+export interface AgentEsmRecord {
+ kind: AgentKind.ESM;
+ provide: string;
+ specifier: ParsedPackageSpecifier;
+ metadata: AgentMetadata;
+ providers?: ProviderType[];
+}
+
+/** Single named agent loaded from an npm package at runtime */
+export interface AgentEsmTargetRecord {
+ kind: AgentKind.ESM;
+ provide: symbol;
+ specifier: ParsedPackageSpecifier;
+ /** Which agent to load from the package */
+ targetName: string;
+ options?: EsmOptions;
+ metadata: AgentMetadata;
+ providers?: ProviderType[];
+}
+
+/** Single named agent proxied from a remote MCP server */
+export interface AgentRemoteRecord {
+ kind: AgentKind.REMOTE;
+ provide: symbol;
+ /** Remote MCP server URL */
+ url: string;
+ /** Which agent to proxy */
+ targetName: string;
+ transportOptions?: RemoteTransportOptions;
+ remoteAuth?: RemoteAuthConfig;
+ metadata: AgentMetadata;
+ providers?: ProviderType[];
+}
+
/**
* Union type of all possible agent record types.
*/
-export type AgentRecord = AgentClassTokenRecord | AgentFunctionTokenRecord | AgentValueRecord | AgentFactoryRecord;
+export type AgentRecord =
+ | AgentClassTokenRecord
+ | AgentFunctionTokenRecord
+ | AgentValueRecord
+ | AgentFactoryRecord
+ | AgentEsmRecord
+ | AgentEsmTargetRecord
+ | AgentRemoteRecord;
diff --git a/libs/sdk/src/common/records/job.record.ts b/libs/sdk/src/common/records/job.record.ts
index 9a26acaf4..1dcc306ea 100644
--- a/libs/sdk/src/common/records/job.record.ts
+++ b/libs/sdk/src/common/records/job.record.ts
@@ -1,11 +1,16 @@
import { Type } from '@frontmcp/di';
import { JobContext } from '../interfaces';
import { JobMetadata } from '../metadata';
+import type { ParsedPackageSpecifier } from '../../esm-loader/package-specifier';
+import type { RemoteTransportOptions, RemoteAuthConfig } from '../metadata';
+import type { EsmOptions } from '../metadata';
export enum JobKind {
CLASS_TOKEN = 'CLASS_TOKEN',
FUNCTION = 'FUNCTION',
DYNAMIC = 'DYNAMIC',
+ ESM = 'ESM',
+ REMOTE = 'REMOTE',
}
export type JobClassTokenRecord = {
@@ -31,4 +36,41 @@ export type JobDynamicRecord = {
registeredAt: number;
};
-export type JobRecord = JobClassTokenRecord | JobFunctionTokenRecord | JobDynamicRecord;
+export type JobEsmRecord = {
+ kind: JobKind.ESM;
+ provide: string;
+ specifier: ParsedPackageSpecifier;
+ metadata: JobMetadata;
+};
+
+/** Single named job loaded from an npm package at runtime */
+export type JobEsmTargetRecord = {
+ kind: JobKind.ESM;
+ provide: symbol;
+ specifier: ParsedPackageSpecifier;
+ /** Which job to load from the package */
+ targetName: string;
+ options?: EsmOptions;
+ metadata: JobMetadata;
+};
+
+/** Single named job proxied from a remote MCP server */
+export type JobRemoteRecord = {
+ kind: JobKind.REMOTE;
+ provide: symbol;
+ /** Remote MCP server URL */
+ url: string;
+ /** Which job to proxy */
+ targetName: string;
+ transportOptions?: RemoteTransportOptions;
+ remoteAuth?: RemoteAuthConfig;
+ metadata: JobMetadata;
+};
+
+export type JobRecord =
+ | JobClassTokenRecord
+ | JobFunctionTokenRecord
+ | JobDynamicRecord
+ | JobEsmRecord
+ | JobEsmTargetRecord
+ | JobRemoteRecord;
diff --git a/libs/sdk/src/common/records/prompt.record.ts b/libs/sdk/src/common/records/prompt.record.ts
index 054378dc8..e9181bcfc 100644
--- a/libs/sdk/src/common/records/prompt.record.ts
+++ b/libs/sdk/src/common/records/prompt.record.ts
@@ -1,10 +1,15 @@
import { Type } from '@frontmcp/di';
import { PromptMetadata } from '../metadata';
import { PromptEntry } from '../entries';
+import type { ParsedPackageSpecifier } from '../../esm-loader/package-specifier';
+import type { RemoteTransportOptions, RemoteAuthConfig } from '../metadata';
+import type { EsmOptions } from '../metadata';
export enum PromptKind {
CLASS_TOKEN = 'CLASS_TOKEN',
FUNCTION = 'FUNCTION',
+ ESM = 'ESM',
+ REMOTE = 'REMOTE',
}
export type PromptClassTokenRecord = {
@@ -21,4 +26,40 @@ export type PromptFunctionTokenRecord = {
metadata: PromptMetadata;
};
-export type PromptRecord = PromptClassTokenRecord | PromptFunctionTokenRecord;
+export type PromptEsmRecord = {
+ kind: PromptKind.ESM;
+ provide: string;
+ specifier: ParsedPackageSpecifier;
+ metadata: PromptMetadata;
+};
+
+/** Single named prompt loaded from an npm package at runtime */
+export type PromptEsmTargetRecord = {
+ kind: PromptKind.ESM;
+ provide: symbol;
+ specifier: ParsedPackageSpecifier;
+ /** Which prompt to load from the package */
+ targetName: string;
+ options?: EsmOptions;
+ metadata: PromptMetadata;
+};
+
+/** Single named prompt proxied from a remote MCP server */
+export type PromptRemoteRecord = {
+ kind: PromptKind.REMOTE;
+ provide: symbol;
+ /** Remote MCP server URL */
+ url: string;
+ /** Which prompt to proxy */
+ targetName: string;
+ transportOptions?: RemoteTransportOptions;
+ remoteAuth?: RemoteAuthConfig;
+ metadata: PromptMetadata;
+};
+
+export type PromptRecord =
+ | PromptClassTokenRecord
+ | PromptFunctionTokenRecord
+ | PromptEsmRecord
+ | PromptEsmTargetRecord
+ | PromptRemoteRecord;
diff --git a/libs/sdk/src/common/records/resource.record.ts b/libs/sdk/src/common/records/resource.record.ts
index 82ce1de02..ec0d9bec9 100644
--- a/libs/sdk/src/common/records/resource.record.ts
+++ b/libs/sdk/src/common/records/resource.record.ts
@@ -1,6 +1,9 @@
import { Type } from '@frontmcp/di';
import { ResourceMetadata, ResourceTemplateMetadata } from '../metadata';
import { ResourceEntry } from '../entries';
+import type { ParsedPackageSpecifier } from '../../esm-loader/package-specifier';
+import type { RemoteTransportOptions, RemoteAuthConfig } from '../metadata';
+import type { EsmOptions } from '../metadata';
// ============================================================================
// Static Resource Records
@@ -9,6 +12,8 @@ import { ResourceEntry } from '../entries';
export enum ResourceKind {
CLASS_TOKEN = 'CLASS_TOKEN',
FUNCTION = 'FUNCTION',
+ ESM = 'ESM',
+ REMOTE = 'REMOTE',
}
export type ResourceClassTokenRecord = {
@@ -25,7 +30,43 @@ export type ResourceFunctionRecord = {
metadata: ResourceMetadata;
};
-export type ResourceRecord = ResourceClassTokenRecord | ResourceFunctionRecord;
+export type ResourceEsmRecord = {
+ kind: ResourceKind.ESM;
+ provide: string;
+ specifier: ParsedPackageSpecifier;
+ metadata: ResourceMetadata;
+};
+
+/** Single named resource loaded from an npm package at runtime */
+export type ResourceEsmTargetRecord = {
+ kind: ResourceKind.ESM;
+ provide: symbol;
+ specifier: ParsedPackageSpecifier;
+ /** Which resource to load from the package */
+ targetName: string;
+ options?: EsmOptions;
+ metadata: ResourceMetadata;
+};
+
+/** Single named resource proxied from a remote MCP server */
+export type ResourceRemoteRecord = {
+ kind: ResourceKind.REMOTE;
+ provide: symbol;
+ /** Remote MCP server URL */
+ url: string;
+ /** Which resource to proxy */
+ targetName: string;
+ transportOptions?: RemoteTransportOptions;
+ remoteAuth?: RemoteAuthConfig;
+ metadata: ResourceMetadata;
+};
+
+export type ResourceRecord =
+ | ResourceClassTokenRecord
+ | ResourceFunctionRecord
+ | ResourceEsmRecord
+ | ResourceEsmTargetRecord
+ | ResourceRemoteRecord;
// ============================================================================
// Resource Template Records
diff --git a/libs/sdk/src/common/records/skill.record.ts b/libs/sdk/src/common/records/skill.record.ts
index 342c6243e..044e5bae8 100644
--- a/libs/sdk/src/common/records/skill.record.ts
+++ b/libs/sdk/src/common/records/skill.record.ts
@@ -1,6 +1,9 @@
import { Type } from '@frontmcp/di';
import { SkillContext } from '../interfaces';
import { SkillMetadata } from '../metadata';
+import type { ParsedPackageSpecifier } from '../../esm-loader/package-specifier';
+import type { RemoteTransportOptions, RemoteAuthConfig } from '../metadata';
+import type { EsmOptions } from '../metadata';
/**
* Kinds of skill records supported by the framework.
@@ -20,6 +23,16 @@ export enum SkillKind {
* File-based skill loaded from a .skill.md or similar file.
*/
FILE = 'FILE',
+
+ /**
+ * ESM-loaded skill from an npm package via esm.sh.
+ */
+ ESM = 'ESM',
+
+ /**
+ * Skill proxied from a remote MCP server.
+ */
+ REMOTE = 'REMOTE',
}
/**
@@ -55,7 +68,47 @@ export type SkillFileRecord = {
filePath: string;
};
+/**
+ * Record for ESM-loaded skills from npm packages.
+ */
+export type SkillEsmRecord = {
+ kind: SkillKind.ESM;
+ provide: string;
+ specifier: ParsedPackageSpecifier;
+ metadata: SkillMetadata;
+};
+
+/** Single named skill loaded from an npm package at runtime */
+export type SkillEsmTargetRecord = {
+ kind: SkillKind.ESM;
+ provide: symbol;
+ specifier: ParsedPackageSpecifier;
+ /** Which skill to load from the package */
+ targetName: string;
+ options?: EsmOptions;
+ metadata: SkillMetadata;
+};
+
+/** Single named skill proxied from a remote MCP server */
+export type SkillRemoteRecord = {
+ kind: SkillKind.REMOTE;
+ provide: symbol;
+ /** Remote MCP server URL */
+ url: string;
+ /** Which skill to proxy */
+ targetName: string;
+ transportOptions?: RemoteTransportOptions;
+ remoteAuth?: RemoteAuthConfig;
+ metadata: SkillMetadata;
+};
+
/**
* Union of all skill record types.
*/
-export type SkillRecord = SkillClassTokenRecord | SkillValueRecord | SkillFileRecord;
+export type SkillRecord =
+ | SkillClassTokenRecord
+ | SkillValueRecord
+ | SkillFileRecord
+ | SkillEsmRecord
+ | SkillEsmTargetRecord
+ | SkillRemoteRecord;
diff --git a/libs/sdk/src/common/records/tool.record.ts b/libs/sdk/src/common/records/tool.record.ts
index d721f250e..a652fb093 100644
--- a/libs/sdk/src/common/records/tool.record.ts
+++ b/libs/sdk/src/common/records/tool.record.ts
@@ -1,10 +1,15 @@
import { Type } from '@frontmcp/di';
import { ToolContext } from '../interfaces';
import { ToolMetadata } from '../metadata';
+import type { ParsedPackageSpecifier } from '../../esm-loader/package-specifier';
+import type { RemoteTransportOptions, RemoteAuthConfig } from '../metadata';
+import type { EsmOptions } from '../metadata';
export enum ToolKind {
CLASS_TOKEN = 'CLASS_TOKEN',
FUNCTION = 'FUNCTION',
+ ESM = 'ESM',
+ REMOTE = 'REMOTE',
}
export type ToolClassTokenRecord = {
@@ -21,4 +26,40 @@ export type ToolFunctionTokenRecord = {
metadata: ToolMetadata;
};
-export type ToolRecord = ToolClassTokenRecord | ToolFunctionTokenRecord;
+export type ToolEsmRecord = {
+ kind: ToolKind.ESM;
+ provide: string;
+ specifier: ParsedPackageSpecifier;
+ metadata: ToolMetadata;
+};
+
+/** Single named tool loaded from an npm package at runtime */
+export type ToolEsmTargetRecord = {
+ kind: ToolKind.ESM;
+ provide: symbol;
+ specifier: ParsedPackageSpecifier;
+ /** Which tool to load from the package */
+ targetName: string;
+ options?: EsmOptions;
+ metadata: ToolMetadata;
+};
+
+/** Single named tool proxied from a remote MCP server */
+export type ToolRemoteRecord = {
+ kind: ToolKind.REMOTE;
+ provide: symbol;
+ /** Remote MCP server URL */
+ url: string;
+ /** Which tool to proxy */
+ targetName: string;
+ transportOptions?: RemoteTransportOptions;
+ remoteAuth?: RemoteAuthConfig;
+ metadata: ToolMetadata;
+};
+
+export type ToolRecord =
+ | ToolClassTokenRecord
+ | ToolFunctionTokenRecord
+ | ToolEsmRecord
+ | ToolEsmTargetRecord
+ | ToolRemoteRecord;
diff --git a/libs/sdk/src/common/records/workflow.record.ts b/libs/sdk/src/common/records/workflow.record.ts
index 1f2929ae8..dfd5c0bda 100644
--- a/libs/sdk/src/common/records/workflow.record.ts
+++ b/libs/sdk/src/common/records/workflow.record.ts
@@ -1,10 +1,15 @@
import { Type } from '@frontmcp/di';
import { WorkflowMetadata } from '../metadata';
+import type { ParsedPackageSpecifier } from '../../esm-loader/package-specifier';
+import type { RemoteTransportOptions, RemoteAuthConfig } from '../metadata';
+import type { EsmOptions } from '../metadata';
export enum WorkflowKind {
CLASS_TOKEN = 'CLASS_TOKEN',
VALUE = 'VALUE',
DYNAMIC = 'DYNAMIC',
+ ESM = 'ESM',
+ REMOTE = 'REMOTE',
}
export type WorkflowClassTokenRecord = {
@@ -27,4 +32,41 @@ export type WorkflowDynamicRecord = {
registeredAt: number;
};
-export type WorkflowRecord = WorkflowClassTokenRecord | WorkflowValueRecord | WorkflowDynamicRecord;
+export type WorkflowEsmRecord = {
+ kind: WorkflowKind.ESM;
+ provide: string;
+ specifier: ParsedPackageSpecifier;
+ metadata: WorkflowMetadata;
+};
+
+/** Single named workflow loaded from an npm package at runtime */
+export type WorkflowEsmTargetRecord = {
+ kind: WorkflowKind.ESM;
+ provide: symbol;
+ specifier: ParsedPackageSpecifier;
+ /** Which workflow to load from the package */
+ targetName: string;
+ options?: EsmOptions;
+ metadata: WorkflowMetadata;
+};
+
+/** Single named workflow proxied from a remote MCP server */
+export type WorkflowRemoteRecord = {
+ kind: WorkflowKind.REMOTE;
+ provide: symbol;
+ /** Remote MCP server URL */
+ url: string;
+ /** Which workflow to proxy */
+ targetName: string;
+ transportOptions?: RemoteTransportOptions;
+ remoteAuth?: RemoteAuthConfig;
+ metadata: WorkflowMetadata;
+};
+
+export type WorkflowRecord =
+ | WorkflowClassTokenRecord
+ | WorkflowValueRecord
+ | WorkflowDynamicRecord
+ | WorkflowEsmRecord
+ | WorkflowEsmTargetRecord
+ | WorkflowRemoteRecord;
diff --git a/libs/sdk/src/common/schemas/annotated-class.schema.ts b/libs/sdk/src/common/schemas/annotated-class.schema.ts
index 77d13e354..27311c8f3 100644
--- a/libs/sdk/src/common/schemas/annotated-class.schema.ts
+++ b/libs/sdk/src/common/schemas/annotated-class.schema.ts
@@ -24,6 +24,7 @@ import {
frontMcpProviderMetadataSchema,
frontMcpRemoteAppMetadataSchema,
} from '../metadata';
+import { isPackageSpecifier } from '../../esm-loader/package-specifier';
export const annotatedFrontMcpAppSchema = z.custom(
(v): v is Type => {
@@ -137,14 +138,18 @@ export const annotatedFrontMcpAdaptersSchema = z.custom(
{ message: 'adapters items must be annotated with @Adapter() | @FrontMcpAdapter().' },
);
-export const annotatedFrontMcpToolsSchema = z.custom(
- (v): v is Type => {
+export const annotatedFrontMcpToolsSchema = z.custom(
+ (v): v is Type | string => {
+ // ESM package specifier string (e.g., '@acme/tools@^1.0.0')
+ if (typeof v === 'string') {
+ return isPackageSpecifier(v);
+ }
return (
typeof v === 'function' &&
(Reflect.hasMetadata(FrontMcpToolTokens.type, v) || v[FrontMcpToolTokens.type] !== undefined)
);
},
- { message: 'tools items must be annotated with @Tool() | @FrontMcpTool().' },
+ { message: 'tools items must be annotated with @Tool() | @FrontMcpTool() or be a package specifier string.' },
);
export const annotatedFrontMcpResourcesSchema = z.custom(
@@ -187,6 +192,10 @@ export const annotatedFrontMcpLoggerSchema = z.custom(
export const annotatedFrontMcpAgentsSchema = z.custom(
(v): v is AgentType => {
+ // ESM package specifier string (e.g., '@acme/agents@^1.0.0')
+ if (typeof v === 'string') {
+ return isPackageSpecifier(v);
+ }
// Check for class-based @Agent decorator
if (typeof v === 'function') {
if (Reflect.hasMetadata(FrontMcpAgentTokens.type, v)) {
@@ -213,7 +222,10 @@ export const annotatedFrontMcpAgentsSchema = z.custom(
}
return false;
},
- { message: 'agents items must be annotated with @Agent() | @FrontMcpAgent() or use agent() builder.' },
+ {
+ message:
+ 'agents items must be annotated with @Agent() | @FrontMcpAgent(), use agent() builder, or be a package specifier string.',
+ },
);
export const annotatedFrontMcpJobsSchema = z.custom(
diff --git a/libs/sdk/src/common/tokens/app.tokens.ts b/libs/sdk/src/common/tokens/app.tokens.ts
index eaf74e163..331a84f7a 100644
--- a/libs/sdk/src/common/tokens/app.tokens.ts
+++ b/libs/sdk/src/common/tokens/app.tokens.ts
@@ -35,5 +35,7 @@ export const FrontMcpRemoteAppTokens: RawMetadataShape = {
remoteAuth: tokenFactory.meta('remoteAuth'),
refreshInterval: tokenFactory.meta('refreshInterval'),
cacheTTL: tokenFactory.meta('cacheTTL'),
+ packageConfig: tokenFactory.meta('packageConfig'),
+ filter: tokenFactory.meta('filter'),
standalone: tokenFactory.meta('standalone'),
} as const;
diff --git a/libs/sdk/src/common/tokens/front-mcp.tokens.ts b/libs/sdk/src/common/tokens/front-mcp.tokens.ts
index afb0eb5cf..62f55470a 100644
--- a/libs/sdk/src/common/tokens/front-mcp.tokens.ts
+++ b/libs/sdk/src/common/tokens/front-mcp.tokens.ts
@@ -39,4 +39,6 @@ export const FrontMcpTokens: RawMetadataShape = {
ui: tokenFactory.meta('ui'),
// jobs and workflows configuration
jobs: tokenFactory.meta('jobs'),
+ // default package loader for ESM apps
+ loader: tokenFactory.meta('loader'),
};
diff --git a/libs/sdk/src/common/utils/__tests__/primitive-filter.spec.ts b/libs/sdk/src/common/utils/__tests__/primitive-filter.spec.ts
new file mode 100644
index 000000000..56c16a329
--- /dev/null
+++ b/libs/sdk/src/common/utils/__tests__/primitive-filter.spec.ts
@@ -0,0 +1,139 @@
+import { applyPrimitiveFilter } from '../primitive-filter';
+import type { AppFilterConfig } from '../../metadata/app-filter.metadata';
+
+describe('applyPrimitiveFilter()', () => {
+ const items = [
+ { name: 'echo' },
+ { name: 'add' },
+ { name: 'dangerous-delete' },
+ { name: 'dangerous-format' },
+ { name: 'search' },
+ ];
+
+ describe('default: include (default mode)', () => {
+ it('returns all items when no config', () => {
+ expect(applyPrimitiveFilter(items, 'tools')).toEqual(items);
+ });
+
+ it('returns all items when config is undefined', () => {
+ expect(applyPrimitiveFilter(items, 'tools', undefined)).toEqual(items);
+ });
+
+ it('returns all items when config has no patterns for this type', () => {
+ const config: AppFilterConfig = { exclude: { resources: ['*'] } };
+ expect(applyPrimitiveFilter(items, 'tools', config)).toEqual(items);
+ });
+
+ it('excludes items matching exclude patterns', () => {
+ const config: AppFilterConfig = { exclude: { tools: ['echo'] } };
+ const result = applyPrimitiveFilter(items, 'tools', config);
+ expect(result.map((i) => i.name)).toEqual(['add', 'dangerous-delete', 'dangerous-format', 'search']);
+ });
+
+ it('supports glob patterns (dangerous-*)', () => {
+ const config: AppFilterConfig = { exclude: { tools: ['dangerous-*'] } };
+ const result = applyPrimitiveFilter(items, 'tools', config);
+ expect(result.map((i) => i.name)).toEqual(['echo', 'add', 'search']);
+ });
+
+ it('supports exact name match', () => {
+ const config: AppFilterConfig = { exclude: { tools: ['add'] } };
+ const result = applyPrimitiveFilter(items, 'tools', config);
+ expect(result).not.toContainEqual({ name: 'add' });
+ expect(result).toHaveLength(4);
+ });
+
+ it('exclude takes precedence over include when both match', () => {
+ const config: AppFilterConfig = {
+ include: { tools: ['echo'] },
+ exclude: { tools: ['echo'] },
+ };
+ const result = applyPrimitiveFilter(items, 'tools', config);
+ expect(result.map((i) => i.name)).not.toContain('echo');
+ });
+ });
+
+ describe('default: exclude', () => {
+ it('returns empty when no include patterns for this type', () => {
+ const config: AppFilterConfig = { default: 'exclude' };
+ expect(applyPrimitiveFilter(items, 'tools', config)).toEqual([]);
+ });
+
+ it('includes only items matching include patterns', () => {
+ const config: AppFilterConfig = {
+ default: 'exclude',
+ include: { tools: ['echo', 'add'] },
+ };
+ const result = applyPrimitiveFilter(items, 'tools', config);
+ expect(result.map((i) => i.name)).toEqual(['echo', 'add']);
+ });
+
+ it('supports glob patterns in include', () => {
+ const config: AppFilterConfig = {
+ default: 'exclude',
+ include: { tools: ['dangerous-*'] },
+ };
+ const result = applyPrimitiveFilter(items, 'tools', config);
+ expect(result.map((i) => i.name)).toEqual(['dangerous-delete', 'dangerous-format']);
+ });
+
+ it('include takes precedence over exclude when both match', () => {
+ const config: AppFilterConfig = {
+ default: 'exclude',
+ include: { tools: ['echo'] },
+ exclude: { tools: ['echo'] },
+ };
+ const result = applyPrimitiveFilter(items, 'tools', config);
+ expect(result.map((i) => i.name)).toContain('echo');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('empty items array returns empty', () => {
+ const config: AppFilterConfig = { exclude: { tools: ['echo'] } };
+ expect(applyPrimitiveFilter([], 'tools', config)).toEqual([]);
+ });
+
+ it('undefined config returns all items', () => {
+ expect(applyPrimitiveFilter(items, 'tools', undefined)).toEqual(items);
+ });
+
+ it('wildcard * matches everything in exclude', () => {
+ const config: AppFilterConfig = { exclude: { tools: ['*'] } };
+ expect(applyPrimitiveFilter(items, 'tools', config)).toEqual([]);
+ });
+
+ it('wildcard * matches everything in include', () => {
+ const config: AppFilterConfig = {
+ default: 'exclude',
+ include: { tools: ['*'] },
+ };
+ expect(applyPrimitiveFilter(items, 'tools', config)).toEqual(items);
+ });
+
+ it('patterns with special regex chars are handled properly', () => {
+ const specialItems = [{ name: 'tool.v2' }, { name: 'tool-v2' }, { name: 'toollv2' }];
+ const config: AppFilterConfig = {
+ default: 'exclude',
+ include: { tools: ['tool.v2'] },
+ };
+ const result = applyPrimitiveFilter(specialItems, 'tools', config);
+ // Only exact match, dot is escaped
+ expect(result.map((i) => i.name)).toEqual(['tool.v2']);
+ });
+
+ it('works with different primitive types', () => {
+ const config: AppFilterConfig = { exclude: { resources: ['echo'] } };
+ const result = applyPrimitiveFilter(items, 'resources', config);
+ expect(result).toHaveLength(4);
+ });
+
+ it('returns empty for exclude-default with no include patterns for the type but other types have patterns', () => {
+ const config: AppFilterConfig = {
+ default: 'exclude',
+ include: { resources: ['config'] },
+ };
+ expect(applyPrimitiveFilter(items, 'tools', config)).toEqual([]);
+ });
+ });
+});
diff --git a/libs/sdk/src/common/utils/index.ts b/libs/sdk/src/common/utils/index.ts
index 58deaf9f5..dcb3311ac 100644
--- a/libs/sdk/src/common/utils/index.ts
+++ b/libs/sdk/src/common/utils/index.ts
@@ -1,3 +1,5 @@
export * from './decide-request-intent.utils';
export * from './path.utils';
export * from './global-config.utils';
+export * from './primitive-filter';
+export * from './validate-remote-url';
diff --git a/libs/sdk/src/common/utils/primitive-filter.ts b/libs/sdk/src/common/utils/primitive-filter.ts
new file mode 100644
index 000000000..864034fbb
--- /dev/null
+++ b/libs/sdk/src/common/utils/primitive-filter.ts
@@ -0,0 +1,108 @@
+/**
+ * @file primitive-filter.ts
+ * @description Utility for applying include/exclude filters to named primitives
+ * (tools, resources, prompts, etc.) loaded from external apps.
+ */
+
+import type { AppFilterConfig, PrimitiveFilterKey } from '../metadata/app-filter.metadata';
+
+/**
+ * Match a name against a glob-like pattern.
+ * Supports `*` as wildcard for any sequence of characters.
+ *
+ * @example
+ * ```ts
+ * matchPattern('echo', 'echo') // true
+ * matchPattern('echo', 'ech*') // true
+ * matchPattern('dangerous-tool', 'dangerous-*') // true
+ * matchPattern('echo', 'add') // false
+ * ```
+ */
+function matchPattern(name: string, pattern: string): boolean {
+ if (pattern === '*') return true;
+ if (!pattern.includes('*')) return name === pattern;
+
+ // Linear-time segment matching (avoids regex-based ReDoS)
+ const segments = pattern.split('*');
+ let pos = 0;
+
+ for (let i = 0; i < segments.length; i++) {
+ const seg = segments[i];
+ if (i === 0) {
+ // First segment must match at the start
+ if (!name.startsWith(seg)) return false;
+ pos = seg.length;
+ } else if (i === segments.length - 1) {
+ // Last segment must match at the end
+ if (!name.endsWith(seg)) return false;
+ // Ensure no overlap with earlier matched portion
+ if (name.length - seg.length < pos) return false;
+ } else {
+ // Middle segments: find next occurrence after current position
+ const idx = name.indexOf(seg, pos);
+ if (idx === -1) return false;
+ pos = idx + seg.length;
+ }
+ }
+
+ return true;
+}
+
+/**
+ * Check if a name matches any pattern in an array.
+ */
+function matchesAny(name: string, patterns: string[]): boolean {
+ return patterns.some((p) => matchPattern(name, p));
+}
+
+/**
+ * Apply include/exclude filtering to an array of named items.
+ *
+ * Filtering logic:
+ * - `default: 'include'` (default): item is included unless it matches an `exclude` pattern
+ * - `default: 'exclude'`: item is excluded unless it matches an `include` pattern
+ * - If both `include` and `exclude` match, `exclude` takes precedence when default is 'include',
+ * and `include` takes precedence when default is 'exclude'
+ *
+ * @param items - Array of items with a `name` property
+ * @param primitiveType - The primitive type key (e.g., 'tools', 'resources')
+ * @param config - Optional filter configuration
+ * @returns Filtered array of items
+ */
+export function applyPrimitiveFilter(
+ items: T[],
+ primitiveType: PrimitiveFilterKey,
+ config?: AppFilterConfig,
+): T[] {
+ if (!config) return items;
+
+ const defaultMode = config.default ?? 'include';
+ const includePatterns = config.include?.[primitiveType];
+ const excludePatterns = config.exclude?.[primitiveType];
+
+ // No patterns for this type — use default mode
+ if (!includePatterns?.length && !excludePatterns?.length) {
+ // If default is 'exclude' and no include patterns exist for this type,
+ // check if there are ANY include patterns at all. If the entire include
+ // map is empty, default behavior applies (exclude all).
+ if (defaultMode === 'exclude') {
+ return [];
+ }
+ return items;
+ }
+
+ return items.filter((item) => {
+ const matchesInclude = includePatterns?.length ? matchesAny(item.name, includePatterns) : false;
+ const matchesExclude = excludePatterns?.length ? matchesAny(item.name, excludePatterns) : false;
+
+ if (defaultMode === 'include') {
+ // Include by default, exclude wins over include
+ if (matchesExclude) return false;
+ return true;
+ } else {
+ // Exclude by default, include wins over exclude
+ if (matchesInclude) return true;
+ return false;
+ }
+ });
+}
diff --git a/libs/sdk/src/common/utils/validate-remote-url.ts b/libs/sdk/src/common/utils/validate-remote-url.ts
new file mode 100644
index 000000000..fd396b046
--- /dev/null
+++ b/libs/sdk/src/common/utils/validate-remote-url.ts
@@ -0,0 +1,19 @@
+/**
+ * @file validate-remote-url.ts
+ * @description Shared URL validation for .remote() factory methods.
+ */
+
+import { isValidMcpUri } from '@frontmcp/utils';
+
+/**
+ * Validate that a remote URL has a valid URI scheme per RFC 3986.
+ * Used by all .remote() factory methods (Tool, Resource, Prompt, Agent, Skill, Job).
+ *
+ * @param url - The URL to validate
+ * @throws Error if the URL does not have a valid scheme
+ */
+export function validateRemoteUrl(url: string): void {
+ if (!isValidMcpUri(url)) {
+ throw new Error('URI must have a valid scheme (e.g., file://, https://, custom://)');
+ }
+}
diff --git a/libs/sdk/src/errors/__tests__/esm.errors.spec.ts b/libs/sdk/src/errors/__tests__/esm.errors.spec.ts
new file mode 100644
index 000000000..6fc6c9e09
--- /dev/null
+++ b/libs/sdk/src/errors/__tests__/esm.errors.spec.ts
@@ -0,0 +1,102 @@
+import {
+ EsmPackageLoadError,
+ EsmVersionResolutionError,
+ EsmManifestInvalidError,
+ EsmCacheError,
+ EsmRegistryAuthError,
+ EsmInvalidSpecifierError,
+} from '../esm.errors';
+import { InternalMcpError, PublicMcpError, MCP_ERROR_CODES } from '../mcp.error';
+
+describe('ESM Error Classes', () => {
+ describe('EsmPackageLoadError', () => {
+ it('should be an InternalMcpError', () => {
+ const error = new EsmPackageLoadError('pkg', '1.0.0');
+ expect(error).toBeInstanceOf(InternalMcpError);
+ expect(error.packageName).toBe('pkg');
+ expect(error.version).toBe('1.0.0');
+ });
+
+ it('should include original error message', () => {
+ const original = new Error('fetch failed');
+ const error = new EsmPackageLoadError('pkg', '1.0.0', original);
+ expect(error.message).toContain('fetch failed');
+ expect(error.originalError).toBe(original);
+ });
+
+ it('should handle missing version', () => {
+ const error = new EsmPackageLoadError('pkg');
+ expect(error.message).toContain('"pkg"');
+ expect(error.version).toBeUndefined();
+ });
+ });
+
+ describe('EsmVersionResolutionError', () => {
+ it('should be an InternalMcpError', () => {
+ const error = new EsmVersionResolutionError('pkg', '^1.0.0');
+ expect(error).toBeInstanceOf(InternalMcpError);
+ expect(error.packageName).toBe('pkg');
+ expect(error.range).toBe('^1.0.0');
+ });
+
+ it('should include original error', () => {
+ const original = new Error('timeout');
+ const error = new EsmVersionResolutionError('pkg', '^1.0.0', original);
+ expect(error.originalError).toBe(original);
+ expect(error.message).toContain('timeout');
+ });
+ });
+
+ describe('EsmManifestInvalidError', () => {
+ it('should be a PublicMcpError', () => {
+ const error = new EsmManifestInvalidError('pkg');
+ expect(error).toBeInstanceOf(PublicMcpError);
+ expect(error.packageName).toBe('pkg');
+ expect(error.mcpErrorCode).toBe(MCP_ERROR_CODES.INVALID_PARAMS);
+ });
+
+ it('should include details', () => {
+ const error = new EsmManifestInvalidError('pkg', 'missing name field');
+ expect(error.details).toBe('missing name field');
+ expect(error.message).toContain('missing name field');
+ });
+ });
+
+ describe('EsmCacheError', () => {
+ it('should be an InternalMcpError', () => {
+ const error = new EsmCacheError('read');
+ expect(error).toBeInstanceOf(InternalMcpError);
+ expect(error.operation).toBe('read');
+ });
+
+ it('should include package name and original error', () => {
+ const original = new Error('ENOENT');
+ const error = new EsmCacheError('read', 'pkg', original);
+ expect(error.packageName).toBe('pkg');
+ expect(error.originalError).toBe(original);
+ });
+ });
+
+ describe('EsmRegistryAuthError', () => {
+ it('should be a PublicMcpError with UNAUTHORIZED code', () => {
+ const error = new EsmRegistryAuthError();
+ expect(error).toBeInstanceOf(PublicMcpError);
+ expect(error.mcpErrorCode).toBe(MCP_ERROR_CODES.UNAUTHORIZED);
+ });
+
+ it('should include registry URL', () => {
+ const error = new EsmRegistryAuthError('https://npm.pkg.github.com', 'invalid token');
+ expect(error.registryUrl).toBe('https://npm.pkg.github.com');
+ expect(error.details).toBe('invalid token');
+ });
+ });
+
+ describe('EsmInvalidSpecifierError', () => {
+ it('should be a PublicMcpError', () => {
+ const error = new EsmInvalidSpecifierError('invalid!!!');
+ expect(error).toBeInstanceOf(PublicMcpError);
+ expect(error.specifier).toBe('invalid!!!');
+ expect(error.mcpErrorCode).toBe(MCP_ERROR_CODES.INVALID_PARAMS);
+ });
+ });
+});
diff --git a/libs/sdk/src/errors/esm.errors.ts b/libs/sdk/src/errors/esm.errors.ts
new file mode 100644
index 000000000..6568c42ef
--- /dev/null
+++ b/libs/sdk/src/errors/esm.errors.ts
@@ -0,0 +1,134 @@
+/**
+ * @file esm.errors.ts
+ * @description Error classes for ESM package loading, version resolution, and caching.
+ */
+
+import { PublicMcpError, InternalMcpError, MCP_ERROR_CODES } from './mcp.error';
+
+// ═══════════════════════════════════════════════════════════════════
+// LOADING ERRORS
+// ═══════════════════════════════════════════════════════════════════
+
+/**
+ * Error thrown when loading an ESM package fails.
+ */
+export class EsmPackageLoadError extends InternalMcpError {
+ readonly packageName: string;
+ readonly version?: string;
+ readonly originalError?: Error;
+
+ constructor(packageName: string, version?: string, originalError?: Error) {
+ super(
+ `Failed to load ESM package "${packageName}"${version ? `@${version}` : ''}: ${originalError?.message || 'Unknown error'}`,
+ 'ESM_PACKAGE_LOAD_ERROR',
+ );
+ this.packageName = packageName;
+ this.version = version;
+ this.originalError = originalError;
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// VERSION ERRORS
+// ═══════════════════════════════════════════════════════════════════
+
+/**
+ * Error thrown when version resolution against the npm registry fails.
+ */
+export class EsmVersionResolutionError extends InternalMcpError {
+ readonly packageName: string;
+ readonly range: string;
+ readonly originalError?: Error;
+
+ constructor(packageName: string, range: string, originalError?: Error) {
+ super(
+ `Failed to resolve version for "${packageName}@${range}": ${originalError?.message || 'Unknown error'}`,
+ 'ESM_VERSION_RESOLUTION_ERROR',
+ );
+ this.packageName = packageName;
+ this.range = range;
+ this.originalError = originalError;
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// MANIFEST ERRORS
+// ═══════════════════════════════════════════════════════════════════
+
+/**
+ * Error thrown when an ESM package's manifest is invalid or missing.
+ */
+export class EsmManifestInvalidError extends PublicMcpError {
+ readonly packageName: string;
+ readonly details?: string;
+ readonly mcpErrorCode = MCP_ERROR_CODES.INVALID_PARAMS;
+
+ constructor(packageName: string, details?: string) {
+ super(
+ `Invalid manifest in ESM package "${packageName}"${details ? `: ${details}` : ''}`,
+ 'ESM_MANIFEST_INVALID',
+ 400,
+ );
+ this.packageName = packageName;
+ this.details = details;
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// CACHE ERRORS
+// ═══════════════════════════════════════════════════════════════════
+
+/**
+ * Error thrown when ESM cache operations fail.
+ */
+export class EsmCacheError extends InternalMcpError {
+ readonly operation: string;
+ readonly packageName?: string;
+ readonly originalError?: Error;
+
+ constructor(operation: string, packageName?: string, originalError?: Error) {
+ super(
+ `ESM cache ${operation} failed${packageName ? ` for "${packageName}"` : ''}: ${originalError?.message || 'Unknown error'}`,
+ 'ESM_CACHE_ERROR',
+ );
+ this.operation = operation;
+ this.packageName = packageName;
+ this.originalError = originalError;
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// AUTH ERRORS
+// ═══════════════════════════════════════════════════════════════════
+
+/**
+ * Error thrown when authentication to a private npm registry fails.
+ */
+export class EsmRegistryAuthError extends PublicMcpError {
+ readonly registryUrl?: string;
+ readonly details?: string;
+ readonly mcpErrorCode = MCP_ERROR_CODES.UNAUTHORIZED;
+
+ constructor(registryUrl?: string, details?: string) {
+ super('Authentication failed for npm registry', 'ESM_REGISTRY_AUTH_ERROR', 401);
+ this.registryUrl = registryUrl;
+ this.details = details;
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════════
+// SPECIFIER ERRORS
+// ═══════════════════════════════════════════════════════════════════
+
+/**
+ * Error thrown when a package specifier string is invalid.
+ */
+export class EsmInvalidSpecifierError extends PublicMcpError {
+ readonly specifier: string;
+ readonly mcpErrorCode = MCP_ERROR_CODES.INVALID_PARAMS;
+
+ constructor(specifier: string) {
+ super(`Invalid ESM package specifier: "${specifier}"`, 'ESM_INVALID_SPECIFIER', 400);
+ this.specifier = specifier;
+ }
+}
diff --git a/libs/sdk/src/errors/index.ts b/libs/sdk/src/errors/index.ts
index 570ffa43d..01bc8a6df 100644
--- a/libs/sdk/src/errors/index.ts
+++ b/libs/sdk/src/errors/index.ts
@@ -196,6 +196,16 @@ export {
WorkflowJobTimeoutError,
} from './workflow.errors';
+// Export ESM loader errors
+export {
+ EsmPackageLoadError,
+ EsmVersionResolutionError,
+ EsmManifestInvalidError,
+ EsmCacheError,
+ EsmRegistryAuthError,
+ EsmInvalidSpecifierError,
+} from './esm.errors';
+
// Export SDK errors
export {
FlowExitedWithoutOutputError,
diff --git a/libs/sdk/src/esm-loader/__tests__/esm-auth.types.spec.ts b/libs/sdk/src/esm-loader/__tests__/esm-auth.types.spec.ts
new file mode 100644
index 000000000..be045afe4
--- /dev/null
+++ b/libs/sdk/src/esm-loader/__tests__/esm-auth.types.spec.ts
@@ -0,0 +1,86 @@
+import { resolveRegistryToken, getRegistryUrl, DEFAULT_NPM_REGISTRY, esmRegistryAuthSchema } from '../esm-auth.types';
+
+describe('resolveRegistryToken', () => {
+ const originalEnv = process.env;
+
+ beforeEach(() => {
+ process.env = { ...originalEnv };
+ });
+
+ afterEach(() => {
+ process.env = originalEnv;
+ });
+
+ it('should return undefined when no auth provided', () => {
+ expect(resolveRegistryToken()).toBeUndefined();
+ expect(resolveRegistryToken(undefined)).toBeUndefined();
+ });
+
+ it('should return direct token', () => {
+ expect(resolveRegistryToken({ token: 'my-token-123' })).toBe('my-token-123');
+ });
+
+ it('should resolve token from environment variable', () => {
+ process.env.MY_NPM_TOKEN = 'env-token-456';
+ expect(resolveRegistryToken({ tokenEnvVar: 'MY_NPM_TOKEN' })).toBe('env-token-456');
+ });
+
+ it('should throw when env var is not set', () => {
+ expect(() => resolveRegistryToken({ tokenEnvVar: 'NONEXISTENT_VAR' })).toThrow(
+ 'Environment variable "NONEXISTENT_VAR" is not set',
+ );
+ });
+
+ it('should return undefined when auth has no token or tokenEnvVar', () => {
+ expect(resolveRegistryToken({})).toBeUndefined();
+ expect(resolveRegistryToken({ registryUrl: 'https://example.com' })).toBeUndefined();
+ });
+});
+
+describe('getRegistryUrl', () => {
+ it('should return default registry when no auth provided', () => {
+ expect(getRegistryUrl()).toBe(DEFAULT_NPM_REGISTRY);
+ expect(getRegistryUrl(undefined)).toBe(DEFAULT_NPM_REGISTRY);
+ });
+
+ it('should return default registry when no registryUrl in auth', () => {
+ expect(getRegistryUrl({ token: 'abc' })).toBe(DEFAULT_NPM_REGISTRY);
+ });
+
+ it('should return custom registry URL', () => {
+ expect(getRegistryUrl({ registryUrl: 'https://npm.pkg.github.com' })).toBe('https://npm.pkg.github.com');
+ });
+});
+
+describe('esmRegistryAuthSchema', () => {
+ it('should validate valid auth with token', () => {
+ const result = esmRegistryAuthSchema.safeParse({ token: 'abc123' });
+ expect(result.success).toBe(true);
+ });
+
+ it('should validate valid auth with tokenEnvVar', () => {
+ const result = esmRegistryAuthSchema.safeParse({ tokenEnvVar: 'NPM_TOKEN' });
+ expect(result.success).toBe(true);
+ });
+
+ it('should validate auth with registryUrl', () => {
+ const result = esmRegistryAuthSchema.safeParse({
+ registryUrl: 'https://npm.pkg.github.com',
+ token: 'abc',
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('should reject auth with both token and tokenEnvVar', () => {
+ const result = esmRegistryAuthSchema.safeParse({
+ token: 'abc',
+ tokenEnvVar: 'NPM_TOKEN',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should validate empty auth', () => {
+ const result = esmRegistryAuthSchema.safeParse({});
+ expect(result.success).toBe(true);
+ });
+});
diff --git a/libs/sdk/src/esm-loader/__tests__/esm-cache.spec.ts b/libs/sdk/src/esm-loader/__tests__/esm-cache.spec.ts
new file mode 100644
index 000000000..56c8d9b63
--- /dev/null
+++ b/libs/sdk/src/esm-loader/__tests__/esm-cache.spec.ts
@@ -0,0 +1,366 @@
+import * as os from 'node:os';
+import * as path from 'node:path';
+// NOTE: The native Node imports below (fs, child_process, url, util) are required
+// by the importWrappedModule helper which spawns a subprocess to validate real ESM
+// module evaluation. This is an intentional exception to the @frontmcp/utils rule
+// because subprocess-based ESM validation cannot use the mocked utils FS layer.
+import * as fs from 'node:fs/promises';
+import { execFile } from 'node:child_process';
+import { pathToFileURL } from 'node:url';
+import { promisify } from 'node:util';
+import { EsmCacheManager } from '../esm-cache';
+
+/**
+ * In-memory file store backing @frontmcp/utils mocks.
+ */
+const store = new Map();
+
+// Track directories implicitly from stored file paths
+function getDirectoryEntries(dirPath: string): string[] {
+ const entries = new Set();
+ const prefix = dirPath.endsWith(path.sep) ? dirPath : `${dirPath}${path.sep}`;
+ for (const key of store.keys()) {
+ if (key.startsWith(prefix)) {
+ const rest = key.slice(prefix.length);
+ const firstSegment = rest.split(path.sep)[0];
+ if (firstSegment) entries.add(firstSegment);
+ }
+ }
+ return [...entries];
+}
+
+function hasEntriesUnder(dirPath: string): boolean {
+ const prefix = dirPath.endsWith(path.sep) ? dirPath : `${dirPath}${path.sep}`;
+ for (const key of store.keys()) {
+ if (key.startsWith(prefix)) return true;
+ }
+ return false;
+}
+
+// Mock @frontmcp/utils
+jest.mock('@frontmcp/utils', () => ({
+ readFile: jest.fn(async (p: string) => {
+ const val = store.get(p);
+ if (val === undefined) throw new Error(`ENOENT: ${p}`);
+ return val;
+ }),
+ writeFile: jest.fn(async (p: string, content: string) => {
+ store.set(p, content);
+ }),
+ fileExists: jest.fn(async (p: string) => store.has(p) || hasEntriesUnder(p)),
+ readJSON: jest.fn(async (p: string): Promise => {
+ const val = store.get(p);
+ if (!val) return undefined;
+ return JSON.parse(val) as T;
+ }),
+ writeJSON: jest.fn(async (p: string, data: unknown) => {
+ store.set(p, JSON.stringify(data));
+ }),
+ ensureDir: jest.fn(async () => undefined),
+ rm: jest.fn(async (p: string) => {
+ const prefix = p.endsWith(path.sep) ? p : `${p}${path.sep}`;
+ for (const key of [...store.keys()]) {
+ if (key === p || key.startsWith(prefix)) store.delete(key);
+ }
+ }),
+ readdir: jest.fn(async (dirPath: string) => getDirectoryEntries(dirPath)),
+ sha256Hex: jest.fn((input: string) => {
+ let hash = 0;
+ for (let i = 0; i < input.length; i++) {
+ hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0;
+ }
+ return Math.abs(hash).toString(16).padStart(16, '0');
+ }),
+ isValidMcpUri: jest.fn((uri: string) => /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(uri)),
+}));
+
+const execFileAsync = promisify(execFile);
+
+async function importWrappedModule(source: string, extension = '.mjs'): Promise {
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'esm-cache-import-'));
+ const modulePath = path.join(tempDir, `bundle${extension}`);
+ const moduleUrl = `${pathToFileURL(modulePath).href}?t=${Date.now()}`;
+
+ try {
+ await fs.writeFile(modulePath, source, 'utf8');
+
+ const { stdout } = await execFileAsync(process.execPath, [
+ '--input-type=module',
+ '-e',
+ `const mod = await import(${JSON.stringify(moduleUrl)}); console.log(JSON.stringify(mod));`,
+ ]);
+
+ return JSON.parse(stdout) as unknown;
+ } finally {
+ await fs.rm(tempDir, { recursive: true, force: true });
+ }
+}
+
+describe('EsmCacheManager', () => {
+ const cacheDir = path.join(os.tmpdir(), 'test-esm-cache');
+ let cache: EsmCacheManager;
+
+ beforeEach(() => {
+ store.clear();
+ cache = new EsmCacheManager({ cacheDir, maxAgeMs: 60_000 });
+ });
+
+ describe('put()', () => {
+ it('writes bundle.mjs for ESM bundles and meta.json', async () => {
+ const entry = await cache.put('@acme/tools', '1.0.0', 'export default {}', 'https://esm.sh/@acme/tools@1.0.0');
+
+ expect(entry.packageName).toBe('@acme/tools');
+ expect(entry.resolvedVersion).toBe('1.0.0');
+ expect(entry.packageUrl).toBe('https://esm.sh/@acme/tools@1.0.0');
+ expect(entry.bundlePath).toContain('bundle.mjs');
+ expect(entry.cachedAt).toBeGreaterThan(0);
+
+ // Verify bundle was written
+ expect(store.get(entry.bundlePath)).toBe('export default {}');
+ });
+
+ it('writes bundle.cjs bridge for CJS bundles', async () => {
+ const entry = await cache.put(
+ '@acme/tools',
+ '1.0.0',
+ `module.exports = { default: { name: '@acme/tools', version: '1.0.0', tools: [] } };`,
+ 'https://esm.sh/@acme/tools@1.0.0',
+ );
+
+ expect(entry.bundlePath).toContain('bundle.cjs');
+ expect(store.get(entry.bundlePath)).toContain('module.exports =');
+ expect(store.get(entry.bundlePath)).not.toContain('export default');
+ });
+
+ it('stores etag when provided', async () => {
+ const entry = await cache.put('@acme/tools', '1.0.0', 'code', 'https://esm.sh/x', '"abc123"');
+ expect(entry.etag).toBe('"abc123"');
+ });
+
+ it('omits etag when not provided', async () => {
+ const entry = await cache.put('@acme/tools', '1.0.0', 'code', 'https://esm.sh/x');
+ expect(entry.etag).toBeUndefined();
+ });
+
+ it('rejects packageUrl without valid URI scheme', async () => {
+ await expect(cache.put('@acme/tools', '1.0.0', 'code', 'no-scheme')).rejects.toThrow(
+ 'URI must have a valid scheme',
+ );
+ });
+ });
+
+ describe('get()', () => {
+ it('returns entry when cached and fresh', async () => {
+ await cache.put('@acme/tools', '1.0.0', 'code', 'https://esm.sh/x');
+ const entry = await cache.get('@acme/tools', '1.0.0');
+
+ expect(entry).toBeDefined();
+ if (!entry) throw new Error('expected cache entry');
+ expect(entry.packageName).toBe('@acme/tools');
+ expect(entry.resolvedVersion).toBe('1.0.0');
+ });
+
+ it('returns entry from disk when memory cache is cold', async () => {
+ await cache.put('@acme/tools', '1.0.0', 'code', 'https://esm.sh/x');
+
+ // Create a fresh manager pointing at the same cacheDir — empty memoryStore
+ const freshCache = new EsmCacheManager({ cacheDir, maxAgeMs: 60_000 });
+ const entry = await freshCache.get('@acme/tools', '1.0.0');
+
+ expect(entry).toBeDefined();
+ if (!entry) throw new Error('expected cache entry from disk');
+ expect(entry.packageName).toBe('@acme/tools');
+ expect(entry.resolvedVersion).toBe('1.0.0');
+ });
+
+ it('returns undefined when not cached', async () => {
+ const entry = await cache.get('@acme/tools', '1.0.0');
+ expect(entry).toBeUndefined();
+ });
+
+ it('returns undefined when expired', async () => {
+ const shortTtlCache = new EsmCacheManager({ cacheDir, maxAgeMs: 1 });
+ await shortTtlCache.put('@acme/tools', '1.0.0', 'code', 'https://esm.sh/x');
+
+ await new Promise((r) => setTimeout(r, 5));
+
+ const entry = await shortTtlCache.get('@acme/tools', '1.0.0');
+ expect(entry).toBeUndefined();
+ });
+
+ it('returns from in-memory cache even when bundle file is deleted from disk', async () => {
+ await cache.put('@acme/tools', '1.0.0', 'code', 'https://esm.sh/x');
+ const entry = await cache.get('@acme/tools', '1.0.0');
+
+ if (entry) {
+ store.delete(entry.bundlePath);
+ }
+
+ // In-memory cache still has the entry (with bundleContent)
+ const result = await cache.get('@acme/tools', '1.0.0');
+ expect(result).toBeDefined();
+ if (!result) throw new Error('expected cache entry');
+ expect(result.bundleContent).toBe('code');
+ });
+
+ it('returns undefined when meta.json is empty/null', async () => {
+ const { sha256Hex } = jest.requireMock('@frontmcp/utils') as { sha256Hex: (s: string) => string };
+ const hash = sha256Hex('@acme/tools@1.0.0');
+ const metaPath = path.join(cacheDir, hash, 'meta.json');
+ store.set(metaPath, 'null');
+
+ const result = await cache.get('@acme/tools', '1.0.0');
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe('invalidate()', () => {
+ it('removes all versions of a package', async () => {
+ await cache.put('@acme/tools', '1.0.0', 'v1', 'https://esm.sh/a');
+ await cache.put('@acme/tools', '2.0.0', 'v2', 'https://esm.sh/b');
+ await cache.put('@other/pkg', '1.0.0', 'other', 'https://esm.sh/c');
+
+ await cache.invalidate('@acme/tools');
+
+ expect(await cache.get('@acme/tools', '1.0.0')).toBeUndefined();
+ expect(await cache.get('@acme/tools', '2.0.0')).toBeUndefined();
+ expect(await cache.get('@other/pkg', '1.0.0')).toBeDefined();
+ });
+
+ it('does nothing when cache dir does not exist', async () => {
+ const emptyCache = new EsmCacheManager({ cacheDir: '/nonexistent/path' });
+ await expect(emptyCache.invalidate('@acme/tools')).resolves.toBeUndefined();
+ });
+
+ it('handles readdir error gracefully', async () => {
+ const { readdir } = jest.requireMock('@frontmcp/utils') as { readdir: jest.Mock };
+ readdir.mockRejectedValueOnce(new Error('permission denied'));
+
+ // Put something so cacheDir "exists"
+ await cache.put('@acme/tools', '1.0.0', 'v1', 'https://esm.sh/a');
+ await expect(cache.invalidate('@acme/tools')).resolves.toBeUndefined();
+ });
+ });
+
+ describe('cleanup()', () => {
+ it('removes expired entries and returns count', async () => {
+ const shortTtlCache = new EsmCacheManager({ cacheDir, maxAgeMs: 1 });
+ await shortTtlCache.put('@acme/tools', '1.0.0', 'v1', 'https://esm.sh/a');
+ await shortTtlCache.put('@acme/tools', '2.0.0', 'v2', 'https://esm.sh/b');
+
+ await new Promise((r) => setTimeout(r, 5));
+
+ const removed = await shortTtlCache.cleanup();
+ expect(removed).toBe(2);
+ });
+
+ it('does not remove fresh entries', async () => {
+ await cache.put('@acme/tools', '1.0.0', 'v1', 'https://esm.sh/a');
+ const removed = await cache.cleanup();
+ expect(removed).toBe(0);
+ });
+
+ it('accepts custom maxAgeMs override', async () => {
+ await cache.put('@acme/tools', '1.0.0', 'v1', 'https://esm.sh/a');
+
+ await new Promise((r) => setTimeout(r, 5));
+
+ const removed = await cache.cleanup(1);
+ expect(removed).toBe(1);
+ });
+
+ it('returns 0 when cache dir does not exist', async () => {
+ const emptyCache = new EsmCacheManager({ cacheDir: '/nonexistent/path' });
+ const removed = await emptyCache.cleanup();
+ expect(removed).toBe(0);
+ });
+
+ it('handles readdir error gracefully', async () => {
+ const { readdir } = jest.requireMock('@frontmcp/utils') as { readdir: jest.Mock };
+ readdir.mockRejectedValueOnce(new Error('permission denied'));
+
+ await cache.put('@acme/tools', '1.0.0', 'v1', 'https://esm.sh/a');
+ const removed = await cache.cleanup();
+ expect(removed).toBe(0);
+ });
+ });
+
+ describe('readBundle()', () => {
+ it('reads content from in-memory entry', async () => {
+ const entry = await cache.put('@acme/tools', '1.0.0', 'export default 42;', 'https://esm.sh/x');
+ const content = await cache.readBundle(entry);
+ expect(content).toBe('export default 42;');
+ });
+
+ it('reads content from disk when bundleContent is absent', async () => {
+ const entry = await cache.put('@acme/tools', '1.0.0', 'export default 42;', 'https://esm.sh/x');
+
+ // Simulate a cold entry with no in-memory content
+ const coldEntry = { ...entry, bundleContent: undefined };
+ const content = await cache.readBundle(coldEntry);
+
+ // Disk content is the wrapped/cached version written by put()
+ expect(content).toBeDefined();
+ expect(typeof content).toBe('string');
+ });
+
+ it('returns the same bridged source for in-memory CJS content as the disk cache', async () => {
+ const entry = await cache.put(
+ '@acme/tools',
+ '1.0.0',
+ `
+module.exports = {
+ default: {
+ name: '@acme/tools',
+ version: '1.0.0',
+ tools: [],
+ },
+};
+`,
+ 'https://esm.sh/@acme/tools@1.0.0',
+ );
+
+ const content = await cache.readBundle(entry);
+
+ expect(content).toBe(store.get(entry.bundlePath));
+ expect(entry.bundlePath).toContain('bundle.cjs');
+ expect(content).toContain('((module, exports) => {');
+ expect(content).not.toContain('const module = { exports: {} };');
+ expect(content).not.toContain('export default');
+ });
+
+ it('imports bridged CJS bundles without redeclaring module and flattens the manifest default', async () => {
+ const entry = await cache.put(
+ '@acme/tools',
+ '1.0.0',
+ `
+module.exports = {
+ default: {
+ name: '@acme/tools',
+ version: '1.0.0',
+ tools: [],
+ },
+};
+`,
+ 'https://esm.sh/@acme/tools@1.0.0',
+ );
+
+ const content = await cache.readBundle(entry);
+ const extension = path.extname(entry.bundlePath) || '.mjs';
+ const imported = (await importWrappedModule(content, extension)) as {
+ default: { name: string; version: string; tools: unknown[] };
+ };
+
+ expect(imported.default.name).toBe('@acme/tools');
+ expect(imported.default.version).toBe('1.0.0');
+ expect(imported.default.tools).toEqual([]);
+ });
+ });
+
+ describe('defaults', () => {
+ it('uses default cache dir and max age', () => {
+ const defaultCache = new EsmCacheManager();
+ expect(defaultCache).toBeInstanceOf(EsmCacheManager);
+ });
+ });
+});
diff --git a/libs/sdk/src/esm-loader/__tests__/esm-class-registration.spec.ts b/libs/sdk/src/esm-loader/__tests__/esm-class-registration.spec.ts
new file mode 100644
index 000000000..a6183a6ba
--- /dev/null
+++ b/libs/sdk/src/esm-loader/__tests__/esm-class-registration.spec.ts
@@ -0,0 +1,220 @@
+/**
+ * Integration tests for class-based ESM tool registration.
+ *
+ * Verifies that real @Tool-decorated classes (using Symbol tokens from FrontMcpToolTokens)
+ * are detected by isDecoratedToolClass() and can be normalized via normalizeTool()
+ * into ToolClassTokenRecords — the same path used by local tools.
+ */
+import 'reflect-metadata';
+import {
+ FrontMcpToolTokens,
+ FrontMcpResourceTokens,
+ FrontMcpPromptTokens,
+ FrontMcpSkillTokens,
+ FrontMcpJobTokens,
+ ToolKind,
+} from '../../common';
+import {
+ isDecoratedToolClass,
+ isDecoratedResourceClass,
+ isDecoratedPromptClass,
+ isDecoratedSkillClass,
+ isDecoratedJobClass,
+ normalizeToolFromEsmExport,
+ normalizeResourceFromEsmExport,
+ normalizePromptFromEsmExport,
+} from '../../app/instances/esm-normalize.utils';
+import { normalizeTool, collectToolMetadata } from '../../tool/tool.utils';
+import { extendedToolMetadata } from '../../common/tokens';
+
+/**
+ * Helper: simulate what @Tool decorator does — store metadata using real Symbol tokens.
+ */
+function simulateToolDecorator(
+ cls: { new (...args: unknown[]): unknown },
+ meta: { name: string; description?: string; inputSchema?: Record },
+) {
+ Reflect.defineMetadata(FrontMcpToolTokens.type, true, cls);
+ Reflect.defineMetadata(FrontMcpToolTokens.name, meta.name, cls);
+ if (meta.description) {
+ Reflect.defineMetadata(FrontMcpToolTokens.description, meta.description, cls);
+ }
+ if (meta.inputSchema) {
+ Reflect.defineMetadata(FrontMcpToolTokens.inputSchema, meta.inputSchema, cls);
+ }
+ Reflect.defineMetadata(extendedToolMetadata, {}, cls);
+}
+
+describe('ESM Class-Based Registration', () => {
+ describe('Detection: decorated classes vs plain objects', () => {
+ it('detects @Tool-decorated class with Symbol tokens', () => {
+ class MyTool {
+ async execute() {
+ return { content: [] };
+ }
+ }
+ simulateToolDecorator(MyTool, { name: 'my-tool', description: 'A tool' });
+
+ expect(isDecoratedToolClass(MyTool)).toBe(true);
+ // Plain-object normalizer should NOT handle it
+ expect(normalizeToolFromEsmExport(MyTool)).toBeUndefined();
+ });
+
+ it('does not detect plain objects as decorated classes', () => {
+ const plainTool = { name: 'echo', execute: jest.fn() };
+ expect(isDecoratedToolClass(plainTool)).toBe(false);
+ // Plain-object normalizer should handle it
+ expect(normalizeToolFromEsmExport(plainTool)).toBeDefined();
+ });
+
+ it('does not detect undecorated classes as decorated', () => {
+ class UndecoratedTool {
+ async execute() {
+ return { content: [] };
+ }
+ }
+ expect(isDecoratedToolClass(UndecoratedTool)).toBe(false);
+ });
+ });
+
+ describe('normalizeTool() with decorated classes', () => {
+ it('produces CLASS_TOKEN record from @Tool-decorated class', () => {
+ class EchoTool {
+ async execute(input: { message: string }) {
+ return { content: [{ type: 'text' as const, text: input.message }] };
+ }
+ }
+ simulateToolDecorator(EchoTool, {
+ name: 'echo',
+ description: 'Echoes input',
+ inputSchema: { message: { type: 'string' } },
+ });
+
+ const record = normalizeTool(EchoTool);
+
+ expect(record.kind).toBe(ToolKind.CLASS_TOKEN);
+ expect(record.metadata.name).toBe('echo');
+ expect(record.metadata.description).toBe('Echoes input');
+ expect(record.provide).toBe(EchoTool);
+ });
+
+ it('extracts metadata via collectToolMetadata using Symbol tokens', () => {
+ class AddTool {
+ async execute() {
+ return { content: [] };
+ }
+ }
+ simulateToolDecorator(AddTool, {
+ name: 'add',
+ description: 'Adds numbers',
+ inputSchema: { a: { type: 'number' }, b: { type: 'number' } },
+ });
+
+ const metadata = collectToolMetadata(AddTool);
+ expect(metadata.name).toBe('add');
+ expect(metadata.description).toBe('Adds numbers');
+ expect(metadata.inputSchema).toEqual({ a: { type: 'number' }, b: { type: 'number' } });
+ });
+ });
+
+ describe('Namespace prefixing for class tools', () => {
+ it('record metadata can be prefixed with namespace', () => {
+ class ForgetTool {
+ async execute() {
+ return { content: [] };
+ }
+ }
+ simulateToolDecorator(ForgetTool, { name: 'forget', description: 'Forgets' });
+
+ const record = normalizeTool(ForgetTool);
+ const namespace = 'acme';
+ const prefixedName = `${namespace}:${record.metadata.name}`;
+ record.metadata.name = prefixedName;
+ record.metadata.id = prefixedName;
+
+ expect(record.metadata.name).toBe('acme:forget');
+ expect(record.metadata.id).toBe('acme:forget');
+ });
+ });
+
+ describe('Mixed manifest: plain objects + decorated classes', () => {
+ it('detection correctly separates both types', () => {
+ class DecoratedTool {
+ async execute() {
+ return { content: [] };
+ }
+ }
+ simulateToolDecorator(DecoratedTool, { name: 'decorated' });
+
+ const plainTool = { name: 'plain', execute: jest.fn() };
+
+ const items: unknown[] = [DecoratedTool, plainTool];
+
+ const decorated = items.filter(isDecoratedToolClass);
+ const plain = items.filter((i) => !isDecoratedToolClass(i));
+
+ expect(decorated).toHaveLength(1);
+ expect(plain).toHaveLength(1);
+
+ // Decorated goes through normalizeTool
+ const record = normalizeTool(decorated[0]);
+ expect(record.kind).toBe(ToolKind.CLASS_TOKEN);
+ expect(record.metadata.name).toBe('decorated');
+
+ // Plain goes through normalizeToolFromEsmExport
+ const def = normalizeToolFromEsmExport(plain[0]);
+ expect(def).toBeDefined();
+ expect(def?.name).toBe('plain');
+ });
+ });
+
+ describe('Resource and Prompt detection', () => {
+ it('detects @Resource-decorated class', () => {
+ class StatusResource {}
+ Reflect.defineMetadata(FrontMcpResourceTokens.type, true, StatusResource);
+ expect(isDecoratedResourceClass(StatusResource)).toBe(true);
+ expect(normalizeResourceFromEsmExport(StatusResource)).toBeUndefined();
+ });
+
+ it('detects @Prompt-decorated class', () => {
+ class GreetPrompt {}
+ Reflect.defineMetadata(FrontMcpPromptTokens.type, true, GreetPrompt);
+ expect(isDecoratedPromptClass(GreetPrompt)).toBe(true);
+ expect(normalizePromptFromEsmExport(GreetPrompt)).toBeUndefined();
+ });
+ });
+
+ describe('Skill and Job detection', () => {
+ it('detects @Skill-decorated class', () => {
+ class ReviewSkill {}
+ Reflect.defineMetadata(FrontMcpSkillTokens.type, true, ReviewSkill);
+ expect(isDecoratedSkillClass(ReviewSkill)).toBe(true);
+ });
+
+ it('does not detect undecorated class as @Skill', () => {
+ class PlainClass {}
+ expect(isDecoratedSkillClass(PlainClass)).toBe(false);
+ });
+
+ it('does not detect plain objects as @Skill', () => {
+ const obj = { name: 'review', instructions: 'do stuff' };
+ expect(isDecoratedSkillClass(obj)).toBe(false);
+ });
+
+ it('detects @Job-decorated class', () => {
+ class ProcessJob {}
+ Reflect.defineMetadata(FrontMcpJobTokens.type, true, ProcessJob);
+ expect(isDecoratedJobClass(ProcessJob)).toBe(true);
+ });
+
+ it('does not detect undecorated class as @Job', () => {
+ class PlainClass {}
+ expect(isDecoratedJobClass(PlainClass)).toBe(false);
+ });
+
+ it('does not detect plain objects as @Job', () => {
+ const obj = { name: 'process', execute: jest.fn() };
+ expect(isDecoratedJobClass(obj)).toBe(false);
+ });
+ });
+});
diff --git a/libs/sdk/src/esm-loader/__tests__/esm-context-factories.spec.ts b/libs/sdk/src/esm-loader/__tests__/esm-context-factories.spec.ts
new file mode 100644
index 000000000..f57ece945
--- /dev/null
+++ b/libs/sdk/src/esm-loader/__tests__/esm-context-factories.spec.ts
@@ -0,0 +1,90 @@
+import {
+ createEsmToolContextClass,
+ createEsmResourceContextClass,
+ createEsmPromptContextClass,
+} from '../factories/esm-context-factories';
+
+describe('esm-context-factories', () => {
+ describe('createEsmToolContextClass()', () => {
+ it('returns a class with the correct name', () => {
+ const executeFn = jest.fn();
+ const CtxClass = createEsmToolContextClass(executeFn, 'my-tool');
+ expect(CtxClass.name).toBe('EsmTool_my-tool');
+ });
+
+ it('execute delegates to the provided function', async () => {
+ const expectedResult = { content: [{ type: 'text', text: 'hello' }] };
+ const executeFn = jest.fn().mockResolvedValue(expectedResult);
+
+ const CtxClass = createEsmToolContextClass(executeFn, 'echo');
+
+ // Access the prototype's execute method directly
+ const proto = CtxClass.prototype;
+ const result = await proto.execute({ message: 'hi' });
+
+ expect(executeFn).toHaveBeenCalledWith({ message: 'hi' });
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('propagates errors from execute function', async () => {
+ const executeFn = jest.fn().mockRejectedValue(new Error('tool failed'));
+ const CtxClass = createEsmToolContextClass(executeFn, 'failing-tool');
+
+ await expect(CtxClass.prototype.execute({})).rejects.toThrow('tool failed');
+ });
+ });
+
+ describe('createEsmResourceContextClass()', () => {
+ it('returns a class with the correct name', () => {
+ const readFn = jest.fn();
+ const CtxClass = createEsmResourceContextClass(readFn, 'my-resource');
+ expect(CtxClass.name).toBe('EsmResource_my-resource');
+ });
+
+ it('execute delegates to the provided read function', async () => {
+ const expectedResult = { contents: [{ uri: 'file://test', text: 'data' }] };
+ const readFn = jest.fn().mockResolvedValue(expectedResult);
+
+ const CtxClass = createEsmResourceContextClass(readFn, 'file-reader');
+ const result = await CtxClass.prototype.execute('file://test', { key: 'val' });
+
+ expect(readFn).toHaveBeenCalledWith('file://test', { key: 'val' });
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('propagates errors from read function', async () => {
+ const readFn = jest.fn().mockRejectedValue(new Error('read failed'));
+ const CtxClass = createEsmResourceContextClass(readFn, 'failing-resource');
+
+ await expect(CtxClass.prototype.execute('uri', {})).rejects.toThrow('read failed');
+ });
+ });
+
+ describe('createEsmPromptContextClass()', () => {
+ it('returns a class with the correct name', () => {
+ const executeFn = jest.fn();
+ const CtxClass = createEsmPromptContextClass(executeFn, 'my-prompt');
+ expect(CtxClass.name).toBe('EsmPrompt_my-prompt');
+ });
+
+ it('execute delegates to the provided function', async () => {
+ const expectedResult = {
+ messages: [{ role: 'user', content: { type: 'text', text: 'hello' } }],
+ };
+ const executeFn = jest.fn().mockResolvedValue(expectedResult);
+
+ const CtxClass = createEsmPromptContextClass(executeFn, 'greeter');
+ const result = await CtxClass.prototype.execute({ name: 'world' });
+
+ expect(executeFn).toHaveBeenCalledWith({ name: 'world' });
+ expect(result).toEqual(expectedResult);
+ });
+
+ it('propagates errors from execute function', async () => {
+ const executeFn = jest.fn().mockRejectedValue(new Error('prompt failed'));
+ const CtxClass = createEsmPromptContextClass(executeFn, 'failing-prompt');
+
+ await expect(CtxClass.prototype.execute({})).rejects.toThrow('prompt failed');
+ });
+ });
+});
diff --git a/libs/sdk/src/esm-loader/__tests__/esm-instance-factories.spec.ts b/libs/sdk/src/esm-loader/__tests__/esm-instance-factories.spec.ts
new file mode 100644
index 000000000..456898344
--- /dev/null
+++ b/libs/sdk/src/esm-loader/__tests__/esm-instance-factories.spec.ts
@@ -0,0 +1,71 @@
+import { z } from 'zod';
+import { createEsmToolInstance } from '../factories/esm-instance-factories';
+import { createMockOwner, createMockProviderRegistry } from '../../__test-utils__/mocks';
+import type { ToolCallExtra } from '../../common/entries/tool.entry';
+
+describe('esm-instance-factories', () => {
+ describe('createEsmToolInstance()', () => {
+ it('preserves JSON Schema tool arguments when parsing input', async () => {
+ const execute = jest.fn().mockResolvedValue({
+ content: [{ type: 'text', text: 'ok' }],
+ });
+
+ const instance = createEsmToolInstance(
+ {
+ name: 'echo',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ message: { type: 'string' },
+ },
+ required: ['message'],
+ },
+ execute,
+ },
+ createMockProviderRegistry(),
+ createMockOwner(),
+ 'esm',
+ );
+
+ await instance.ready;
+
+ const parsed = instance.parseInput({
+ name: 'esm:echo',
+ arguments: { message: 'hello' },
+ });
+
+ expect(parsed).toEqual({ message: 'hello' });
+
+ const ctx = instance.create(parsed, { authInfo: {} } as ToolCallExtra);
+ await ctx.execute(ctx.input);
+
+ expect(execute).toHaveBeenCalledWith({ message: 'hello' });
+ });
+
+ it('keeps strict parsing for Zod-shape ESM tools', async () => {
+ const instance = createEsmToolInstance(
+ {
+ name: 'strict-echo',
+ inputSchema: {
+ message: z.string(),
+ } as Record,
+ execute: jest.fn().mockResolvedValue({
+ content: [{ type: 'text', text: 'ok' }],
+ }),
+ },
+ createMockProviderRegistry(),
+ createMockOwner(),
+ 'esm',
+ );
+
+ await instance.ready;
+
+ const parsed = instance.parseInput({
+ name: 'esm:strict-echo',
+ arguments: { message: 'hello', extra: 'ignored' },
+ });
+
+ expect(parsed).toEqual({ message: 'hello' });
+ });
+ });
+});
diff --git a/libs/sdk/src/esm-loader/__tests__/esm-loader.e2e.spec.ts b/libs/sdk/src/esm-loader/__tests__/esm-loader.e2e.spec.ts
new file mode 100644
index 000000000..64b240a3e
--- /dev/null
+++ b/libs/sdk/src/esm-loader/__tests__/esm-loader.e2e.spec.ts
@@ -0,0 +1,573 @@
+/**
+ * @file esm-loader.e2e.spec.ts
+ * @description End-to-end tests for the ESM loader pipeline using a local HTTP server.
+ *
+ * Tests the full flow: VersionResolver → fetch registry → EsmModuleLoader → fetch bundle → cache → import → execute.
+ * No real network calls - everything hits a local HTTP server on 127.0.0.1.
+ *
+ * Note: Bundles use CJS fixtures for Jest compatibility. Most tests load them via
+ * require(), and a targeted regression test covers the cache → native import() bridge.
+ */
+
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { mkdtemp, rm, writeFile } from '@frontmcp/utils';
+import { LocalEsmServer } from './helpers/local-esm-server';
+import { SIMPLE_TOOLS_V1, SIMPLE_TOOLS_V2, MULTI_PRIMITIVE, NAMED_EXPORTS } from './helpers/esm-fixtures';
+import { EsmCacheManager } from '../esm-cache';
+import { EsmModuleLoader } from '../esm-module-loader';
+import { VersionResolver } from '../version-resolver';
+import { VersionPoller } from '../version-poller';
+import { parsePackageSpecifier } from '../package-specifier';
+import { normalizeEsmExport } from '../esm-manifest';
+
+/**
+ * Load a CJS bundle from disk (Jest-compatible alternative to dynamic import).
+ * Clears require cache to avoid stale modules.
+ */
+function loadBundleFromDisk(filePath: string): unknown {
+ delete require.cache[require.resolve(filePath)];
+ return require(filePath);
+}
+
+describe('ESM Loader E2E', () => {
+ let server: LocalEsmServer;
+ let registryUrl: string;
+ let esmBaseUrl: string;
+ let tmpCacheDir: string;
+
+ beforeAll(async () => {
+ server = new LocalEsmServer();
+
+ server.addPackage({
+ name: '@test/simple-tools',
+ versions: {
+ '1.0.0': { bundle: SIMPLE_TOOLS_V1 },
+ '2.0.0': { bundle: SIMPLE_TOOLS_V2 },
+ },
+ 'dist-tags': { latest: '1.0.0', next: '2.0.0' },
+ });
+
+ server.addPackage({
+ name: '@test/multi-primitive',
+ versions: {
+ '1.0.0': { bundle: MULTI_PRIMITIVE },
+ },
+ 'dist-tags': { latest: '1.0.0' },
+ });
+
+ server.addPackage({
+ name: '@test/named-exports',
+ versions: {
+ '1.0.0': { bundle: NAMED_EXPORTS },
+ },
+ 'dist-tags': { latest: '1.0.0' },
+ });
+
+ const info = await server.start();
+ registryUrl = info.registryUrl;
+ esmBaseUrl = info.esmBaseUrl;
+ });
+
+ afterAll(async () => {
+ await server.stop();
+ });
+
+ beforeEach(async () => {
+ tmpCacheDir = await mkdtemp(path.join(os.tmpdir(), 'esm-e2e-'));
+ server.clearRequestLog();
+ });
+
+ afterEach(async () => {
+ await rm(tmpCacheDir, { recursive: true, force: true }).catch(() => undefined);
+ });
+
+ describe('VersionResolver with local registry', () => {
+ it('resolves latest tag from local server', async () => {
+ const resolver = new VersionResolver({
+ registryAuth: { registryUrl },
+ });
+
+ const specifier = parsePackageSpecifier('@test/simple-tools@latest');
+ const result = await resolver.resolve(specifier);
+
+ expect(result.resolvedVersion).toBe('1.0.0');
+ expect(result.availableVersions).toContain('1.0.0');
+ expect(result.availableVersions).toContain('2.0.0');
+ });
+
+ it('resolves next tag from local server', async () => {
+ const resolver = new VersionResolver({
+ registryAuth: { registryUrl },
+ });
+
+ const specifier = parsePackageSpecifier('@test/simple-tools@next');
+ const result = await resolver.resolve(specifier);
+
+ expect(result.resolvedVersion).toBe('2.0.0');
+ });
+
+ it('resolves semver range from local server', async () => {
+ const resolver = new VersionResolver({
+ registryAuth: { registryUrl },
+ });
+
+ const specifier = parsePackageSpecifier('@test/simple-tools@^1.0.0');
+ const result = await resolver.resolve(specifier);
+
+ expect(result.resolvedVersion).toBe('1.0.0');
+ });
+
+ it('returns 404 for unknown package', async () => {
+ const resolver = new VersionResolver({
+ registryAuth: { registryUrl },
+ });
+
+ const specifier = parsePackageSpecifier('@test/nonexistent@latest');
+ await expect(resolver.resolve(specifier)).rejects.toThrow('not found');
+ });
+ });
+
+ describe('Auth token handling', () => {
+ afterEach(() => {
+ server.setAuthToken(undefined);
+ });
+
+ it('succeeds when correct token provided', async () => {
+ server.setAuthToken('test-secret-token');
+
+ const resolver = new VersionResolver({
+ registryAuth: {
+ registryUrl,
+ token: 'test-secret-token',
+ },
+ });
+
+ const specifier = parsePackageSpecifier('@test/simple-tools@latest');
+ const result = await resolver.resolve(specifier);
+ expect(result.resolvedVersion).toBe('1.0.0');
+ });
+
+ it('fails with 401 when no token provided but server requires it', async () => {
+ server.setAuthToken('test-secret-token');
+
+ const resolver = new VersionResolver({
+ registryAuth: { registryUrl },
+ });
+
+ const specifier = parsePackageSpecifier('@test/simple-tools@latest');
+ await expect(resolver.resolve(specifier)).rejects.toThrow('401');
+ });
+
+ it('fails with 401 when wrong token provided', async () => {
+ server.setAuthToken('correct-token');
+
+ const resolver = new VersionResolver({
+ registryAuth: {
+ registryUrl,
+ token: 'wrong-token',
+ },
+ });
+
+ const specifier = parsePackageSpecifier('@test/simple-tools@latest');
+ await expect(resolver.resolve(specifier)).rejects.toThrow('401');
+ });
+
+ it('auth header is sent in requests', async () => {
+ server.setAuthToken('verify-header');
+
+ const resolver = new VersionResolver({
+ registryAuth: {
+ registryUrl,
+ token: 'verify-header',
+ },
+ });
+
+ const specifier = parsePackageSpecifier('@test/simple-tools@latest');
+ await resolver.resolve(specifier);
+
+ const log = server.getRequestLog();
+ expect(log.length).toBeGreaterThan(0);
+ expect(log[0].headers.authorization).toBe('Bearer verify-header');
+ });
+ });
+
+ describe('ESM bundle fetching', () => {
+ it('fetches bundle from custom ESM base URL', async () => {
+ const response = await fetch(`${esmBaseUrl}/@test/simple-tools@1.0.0?bundle`);
+ expect(response.ok).toBe(true);
+
+ const content = await response.text();
+ expect(content).toContain('@test/simple-tools');
+ expect(content).toContain('echo');
+ });
+
+ it('returns 404 for unknown package version', async () => {
+ const response = await fetch(`${esmBaseUrl}/@test/simple-tools@9.9.9?bundle`);
+ expect(response.status).toBe(404);
+ });
+
+ it('serves correct ETag header', async () => {
+ const response = await fetch(`${esmBaseUrl}/@test/simple-tools@1.0.0?bundle`);
+ const etag = response.headers.get('etag');
+ expect(etag).toBe('"@test/simple-tools@1.0.0"');
+ });
+
+ it('returns 404 for unknown package', async () => {
+ const response = await fetch(`${esmBaseUrl}/@test/nonexistent@1.0.0?bundle`);
+ expect(response.status).toBe(404);
+ });
+ });
+
+ describe('Bundle import and manifest normalization', () => {
+ it('loads a CJS fixture through the disk cache and native import path', async () => {
+ const loader = new EsmModuleLoader({
+ cache: new EsmCacheManager({ cacheDir: tmpCacheDir, maxAgeMs: 60_000 }),
+ registryAuth: { registryUrl },
+ esmBaseUrl,
+ });
+
+ const specifier = parsePackageSpecifier('@test/simple-tools@1.0.0');
+ const result = await loader.load(specifier);
+
+ expect(result.source).toBe('network');
+ expect(result.manifest.name).toBe('@test/simple-tools');
+ expect(result.manifest.version).toBe('1.0.0');
+ expect(result.manifest.tools).toHaveLength(1);
+ expect((result.manifest.tools![0] as { name: string }).name).toBe('echo');
+ });
+
+ it('reloads a cached CJS fixture from disk through native import', async () => {
+ const specifier = parsePackageSpecifier('@test/simple-tools@1.0.0');
+
+ const firstLoader = new EsmModuleLoader({
+ cache: new EsmCacheManager({ cacheDir: tmpCacheDir, maxAgeMs: 60_000 }),
+ registryAuth: { registryUrl },
+ esmBaseUrl,
+ });
+ await firstLoader.load(specifier);
+
+ const cachedLoader = new EsmModuleLoader({
+ cache: new EsmCacheManager({ cacheDir: tmpCacheDir, maxAgeMs: 60_000 }),
+ registryAuth: { registryUrl },
+ esmBaseUrl,
+ });
+
+ const cachedResult = await cachedLoader.load(specifier);
+
+ expect(cachedResult.source).toBe('cache');
+ expect(cachedResult.manifest.name).toBe('@test/simple-tools');
+ expect(cachedResult.manifest.version).toBe('1.0.0');
+ expect(cachedResult.manifest.tools).toHaveLength(1);
+ expect((cachedResult.manifest.tools![0] as { name: string }).name).toBe('echo');
+ });
+
+ it('writes bundle to disk and imports correctly', async () => {
+ const response = await fetch(`${esmBaseUrl}/@test/simple-tools@1.0.0?bundle`);
+ const bundleContent = await response.text();
+
+ const bundlePath = path.join(tmpCacheDir, 'simple-tools-v1.js');
+ await writeFile(bundlePath, bundleContent);
+
+ const mod = loadBundleFromDisk(bundlePath);
+ const manifest = normalizeEsmExport(mod);
+
+ expect(manifest.name).toBe('@test/simple-tools');
+ expect(manifest.version).toBe('1.0.0');
+ expect(manifest.tools).toHaveLength(1);
+ expect((manifest.tools![0] as { name: string }).name).toBe('echo');
+ });
+
+ it('imports multi-primitive package correctly', async () => {
+ const response = await fetch(`${esmBaseUrl}/@test/multi-primitive@1.0.0?bundle`);
+ const bundleContent = await response.text();
+
+ const bundlePath = path.join(tmpCacheDir, 'multi-primitive.js');
+ await writeFile(bundlePath, bundleContent);
+
+ const mod = loadBundleFromDisk(bundlePath);
+ const manifest = normalizeEsmExport(mod);
+
+ expect(manifest.name).toBe('@test/multi-primitive');
+ expect(manifest.tools).toHaveLength(1);
+ expect(manifest.prompts).toHaveLength(1);
+ expect(manifest.resources).toHaveLength(1);
+ });
+
+ it('imports named exports package correctly', async () => {
+ const response = await fetch(`${esmBaseUrl}/@test/named-exports@1.0.0?bundle`);
+ const bundleContent = await response.text();
+
+ const bundlePath = path.join(tmpCacheDir, 'named-exports.js');
+ await writeFile(bundlePath, bundleContent);
+
+ const mod = loadBundleFromDisk(bundlePath);
+ const manifest = normalizeEsmExport(mod);
+
+ expect(manifest.name).toBe('@test/named-exports');
+ expect(manifest.tools).toHaveLength(1);
+ });
+ });
+
+ describe('Tool execution', () => {
+ it('executes a loaded tool end-to-end', async () => {
+ const response = await fetch(`${esmBaseUrl}/@test/simple-tools@1.0.0?bundle`);
+ const bundleContent = await response.text();
+
+ const bundlePath = path.join(tmpCacheDir, 'exec-test.js');
+ await writeFile(bundlePath, bundleContent);
+
+ const mod = loadBundleFromDisk(bundlePath);
+ const manifest = normalizeEsmExport(mod);
+
+ const echoTool = manifest.tools![0] as {
+ execute: (input: Record) => Promise<{ content: Array<{ text: string }> }>;
+ };
+ const result = await echoTool.execute({ message: 'hello' });
+
+ expect(result.content).toHaveLength(1);
+ expect(result.content[0].text).toBe(JSON.stringify({ message: 'hello' }));
+ });
+
+ it('executes multi-primitive tools and resources', async () => {
+ const response = await fetch(`${esmBaseUrl}/@test/multi-primitive@1.0.0?bundle`);
+ const bundleContent = await response.text();
+
+ const bundlePath = path.join(tmpCacheDir, 'multi-exec.js');
+ await writeFile(bundlePath, bundleContent);
+
+ const mod = loadBundleFromDisk(bundlePath);
+ const manifest = normalizeEsmExport(mod);
+
+ // Execute tool
+ const greetTool = manifest.tools![0] as {
+ execute: (input: Record) => Promise<{ content: Array<{ text: string }> }>;
+ };
+ const toolResult = await greetTool.execute({ name: 'Alice' });
+ expect(toolResult.content[0].text).toBe('Hello, Alice!');
+
+ // Execute resource
+ const statusResource = manifest.resources![0] as {
+ read: () => Promise<{ contents: Array<{ text: string }> }>;
+ };
+ const resResult = await statusResource.read();
+ expect(JSON.parse(resResult.contents[0].text)).toEqual({ status: 'ok' });
+
+ // Execute prompt
+ const greetPrompt = manifest.prompts![0] as {
+ execute: (args: Record) => Promise<{ messages: Array<{ content: { text: string } }> }>;
+ };
+ const promptResult = await greetPrompt.execute({ name: 'Bob' });
+ expect(promptResult.messages[0].content.text).toBe('Greet Bob');
+ });
+ });
+
+ describe('Version update detection', () => {
+ it('detects new version via VersionPoller', async () => {
+ // Temporarily set latest to 2.0.0
+ server.addPackage({
+ name: '@test/simple-tools',
+ versions: {
+ '1.0.0': { bundle: SIMPLE_TOOLS_V1 },
+ '2.0.0': { bundle: SIMPLE_TOOLS_V2 },
+ },
+ 'dist-tags': { latest: '2.0.0', next: '2.0.0' },
+ });
+
+ const onNewVersion = jest.fn().mockResolvedValue(undefined);
+
+ const poller = new VersionPoller({
+ onNewVersion,
+ registryAuth: { registryUrl },
+ });
+
+ const specifier = parsePackageSpecifier('@test/simple-tools@latest');
+ poller.addPackage(specifier, '1.0.0');
+
+ try {
+ const results = await poller.checkNow();
+
+ expect(results).toHaveLength(1);
+ expect(results[0].hasUpdate).toBe(true);
+ expect(results[0].latestVersion).toBe('2.0.0');
+ expect(results[0].currentVersion).toBe('1.0.0');
+ expect(onNewVersion).not.toHaveBeenCalled();
+ } finally {
+ poller.stop();
+
+ // Restore
+ server.addPackage({
+ name: '@test/simple-tools',
+ versions: {
+ '1.0.0': { bundle: SIMPLE_TOOLS_V1 },
+ '2.0.0': { bundle: SIMPLE_TOOLS_V2 },
+ },
+ 'dist-tags': { latest: '1.0.0', next: '2.0.0' },
+ });
+ }
+ });
+
+ it('reports no update when version matches', async () => {
+ const onNewVersion = jest.fn().mockResolvedValue(undefined);
+
+ const poller = new VersionPoller({
+ onNewVersion,
+ registryAuth: { registryUrl },
+ });
+
+ const specifier = parsePackageSpecifier('@test/simple-tools@latest');
+ poller.addPackage(specifier, '1.0.0');
+
+ try {
+ const results = await poller.checkNow();
+
+ expect(results).toHaveLength(1);
+ expect(results[0].hasUpdate).toBe(false);
+ expect(onNewVersion).not.toHaveBeenCalled();
+ } finally {
+ poller.stop();
+ }
+ });
+ });
+
+ describe('Hot-reload cycle', () => {
+ it('loads v1, then reloads with v2 containing new tools', async () => {
+ // Step 1: Load v1
+ const v1Response = await fetch(`${esmBaseUrl}/@test/simple-tools@1.0.0?bundle`);
+ const v1Content = await v1Response.text();
+
+ const v1Path = path.join(tmpCacheDir, 'v1.js');
+ await writeFile(v1Path, v1Content);
+
+ const v1Mod = loadBundleFromDisk(v1Path);
+ const v1Manifest = normalizeEsmExport(v1Mod);
+
+ expect(v1Manifest.tools).toHaveLength(1);
+ expect((v1Manifest.tools![0] as { name: string }).name).toBe('echo');
+
+ // Step 2: Load v2 (simulating hot-reload after version detection)
+ const v2Response = await fetch(`${esmBaseUrl}/@test/simple-tools@2.0.0?bundle`);
+ const v2Content = await v2Response.text();
+
+ const v2Path = path.join(tmpCacheDir, 'v2.js');
+ await writeFile(v2Path, v2Content);
+
+ const v2Mod = loadBundleFromDisk(v2Path);
+ const v2Manifest = normalizeEsmExport(v2Mod);
+
+ expect(v2Manifest.tools).toHaveLength(2);
+ expect((v2Manifest.tools![1] as { name: string }).name).toBe('reverse');
+ });
+ });
+
+ describe('Multiple packages', () => {
+ it('loads two packages independently from same server', async () => {
+ const resolver = new VersionResolver({
+ registryAuth: { registryUrl },
+ });
+
+ const spec1 = parsePackageSpecifier('@test/simple-tools@latest');
+ const spec2 = parsePackageSpecifier('@test/multi-primitive@latest');
+
+ const [res1, res2] = await Promise.all([resolver.resolve(spec1), resolver.resolve(spec2)]);
+
+ expect(res1.resolvedVersion).toBe('1.0.0');
+ expect(res2.resolvedVersion).toBe('1.0.0');
+
+ // Fetch both bundles
+ const [bundle1, bundle2] = await Promise.all([
+ fetch(`${esmBaseUrl}/@test/simple-tools@${res1.resolvedVersion}?bundle`).then((r) => r.text()),
+ fetch(`${esmBaseUrl}/@test/multi-primitive@${res2.resolvedVersion}?bundle`).then((r) => r.text()),
+ ]);
+
+ expect(bundle1).toContain('simple-tools');
+ expect(bundle2).toContain('multi-primitive');
+ });
+ });
+
+ describe('Cache behavior', () => {
+ it('second fetch hits server again (verifiable via request log)', async () => {
+ server.clearRequestLog();
+
+ // First fetch
+ await fetch(`${esmBaseUrl}/@test/simple-tools@1.0.0?bundle`);
+ const countAfterFirst = server.getRequestLog().length;
+
+ // Second fetch
+ await fetch(`${esmBaseUrl}/@test/simple-tools@1.0.0?bundle`);
+ const countAfterSecond = server.getRequestLog().length;
+
+ // Both should hit the server (HTTP-level caching is not implemented in our test server)
+ expect(countAfterSecond).toBe(countAfterFirst + 1);
+ });
+ });
+
+ describe('Request logging', () => {
+ it('records all requests made to the server', async () => {
+ server.clearRequestLog();
+
+ const resolver = new VersionResolver({
+ registryAuth: { registryUrl },
+ });
+
+ const specifier = parsePackageSpecifier('@test/simple-tools@latest');
+ await resolver.resolve(specifier);
+
+ const log = server.getRequestLog();
+ expect(log.length).toBeGreaterThan(0);
+ expect(log[0].method).toBe('GET');
+ expect(log[0].url).toContain('simple-tools');
+ });
+ });
+
+ describe('Custom base URL for on-premise', () => {
+ it('custom esmBaseUrl pointing to local server works', async () => {
+ const bundleUrl = `${esmBaseUrl}/@test/simple-tools@1.0.0?bundle`;
+ const response = await fetch(bundleUrl);
+
+ expect(response.ok).toBe(true);
+ const content = await response.text();
+ expect(content).toContain('echo');
+ expect(bundleUrl).toContain('127.0.0.1');
+ });
+ });
+
+ describe('Server dynamic package updates', () => {
+ it('serves new version after updatePackage()', async () => {
+ server.addPackage({
+ name: '@test/dynamic-pkg',
+ versions: {
+ '1.0.0': {
+ bundle: 'module.exports = { default: { name: "@test/dynamic-pkg", version: "1.0.0", tools: [] } };',
+ },
+ },
+ 'dist-tags': { latest: '1.0.0' },
+ });
+
+ // Verify v1
+ const v1Resp = await fetch(`${esmBaseUrl}/@test/dynamic-pkg@1.0.0?bundle`);
+ expect(v1Resp.ok).toBe(true);
+
+ // Add v2
+ server.updatePackage(
+ '@test/dynamic-pkg',
+ '2.0.0',
+ 'module.exports = { default: { name: "@test/dynamic-pkg", version: "2.0.0", tools: [{ name: "new-tool" }] } };',
+ );
+
+ // Verify v2
+ const v2Resp = await fetch(`${esmBaseUrl}/@test/dynamic-pkg@2.0.0?bundle`);
+ expect(v2Resp.ok).toBe(true);
+ const v2Content = await v2Resp.text();
+ expect(v2Content).toContain('new-tool');
+
+ // Verify registry reflects updated latest
+ const resolver = new VersionResolver({
+ registryAuth: { registryUrl },
+ });
+ const specifier = parsePackageSpecifier('@test/dynamic-pkg@latest');
+ const result = await resolver.resolve(specifier);
+ expect(result.resolvedVersion).toBe('2.0.0');
+ });
+ });
+});
diff --git a/libs/sdk/src/esm-loader/__tests__/esm-manifest.spec.ts b/libs/sdk/src/esm-loader/__tests__/esm-manifest.spec.ts
new file mode 100644
index 000000000..70674f869
--- /dev/null
+++ b/libs/sdk/src/esm-loader/__tests__/esm-manifest.spec.ts
@@ -0,0 +1,327 @@
+import 'reflect-metadata';
+import { normalizeEsmExport, frontMcpPackageManifestSchema } from '../esm-manifest';
+import {
+ FrontMcpToolTokens,
+ FrontMcpResourceTokens,
+ FrontMcpPromptTokens,
+ FrontMcpSkillTokens,
+ FrontMcpJobTokens,
+ FrontMcpAgentTokens,
+ FrontMcpWorkflowTokens,
+} from '../../common';
+import { extendedToolMetadata } from '../../common/tokens';
+
+/** Helper: simulate @Tool decorator metadata on a class */
+function simulateTool(cls: { new (...args: unknown[]): unknown }, name: string) {
+ Reflect.defineMetadata(FrontMcpToolTokens.type, true, cls);
+ Reflect.defineMetadata(FrontMcpToolTokens.name, name, cls);
+ Reflect.defineMetadata(extendedToolMetadata, {}, cls);
+}
+
+/** Helper: simulate @Resource decorator metadata on a class */
+function simulateResource(cls: { new (...args: unknown[]): unknown }, name?: string) {
+ Reflect.defineMetadata(FrontMcpResourceTokens.type, true, cls);
+ if (name) Reflect.defineMetadata(FrontMcpResourceTokens.name, name, cls);
+}
+
+/** Helper: simulate @Prompt decorator metadata on a class */
+function simulatePrompt(cls: { new (...args: unknown[]): unknown }, name?: string) {
+ Reflect.defineMetadata(FrontMcpPromptTokens.type, true, cls);
+ if (name) Reflect.defineMetadata(FrontMcpPromptTokens.name, name, cls);
+}
+
+/** Helper: simulate @Skill decorator metadata on a class */
+function simulateSkill(cls: { new (...args: unknown[]): unknown }, name?: string) {
+ Reflect.defineMetadata(FrontMcpSkillTokens.type, true, cls);
+ if (name) Reflect.defineMetadata(FrontMcpSkillTokens.name, name, cls);
+}
+
+/** Helper: simulate @Job decorator metadata on a class */
+function simulateJob(cls: { new (...args: unknown[]): unknown }, name?: string) {
+ Reflect.defineMetadata(FrontMcpJobTokens.type, true, cls);
+ if (name) Reflect.defineMetadata(FrontMcpJobTokens.name, name, cls);
+}
+
+/** Helper: simulate @Agent decorator metadata on a class */
+function simulateAgent(cls: { new (...args: unknown[]): unknown }, name?: string) {
+ Reflect.defineMetadata(FrontMcpAgentTokens.type, true, cls);
+ if (name) Reflect.defineMetadata(FrontMcpAgentTokens.name, name, cls);
+}
+
+/** Helper: simulate @Workflow decorator metadata on a class */
+function simulateWorkflow(cls: { new (...args: unknown[]): unknown }, name?: string) {
+ Reflect.defineMetadata(FrontMcpWorkflowTokens.type, true, cls);
+ if (name) Reflect.defineMetadata(FrontMcpWorkflowTokens.name, name, cls);
+}
+
+describe('normalizeEsmExport', () => {
+ describe('plain manifest object via default export', () => {
+ it('should normalize a valid manifest from default export', () => {
+ const moduleExport = {
+ default: {
+ name: '@acme/tools',
+ version: '1.0.0',
+ description: 'Test tools',
+ tools: [{ name: 'my-tool', execute: jest.fn() }],
+ },
+ };
+
+ const result = normalizeEsmExport(moduleExport);
+ expect(result.name).toBe('@acme/tools');
+ expect(result.version).toBe('1.0.0');
+ expect(result.tools).toHaveLength(1);
+ });
+
+ it('should handle manifest without optional fields', () => {
+ const moduleExport = {
+ default: {
+ name: 'minimal',
+ version: '0.1.0',
+ },
+ };
+
+ const result = normalizeEsmExport(moduleExport);
+ expect(result.name).toBe('minimal');
+ expect(result.version).toBe('0.1.0');
+ expect(result.tools).toBeUndefined();
+ });
+ });
+
+ describe('named exports', () => {
+ it('should collect named exports into a manifest', () => {
+ const moduleExport = {
+ name: 'named-pkg',
+ version: '2.0.0',
+ tools: [{ name: 'tool-a' }],
+ prompts: [{ name: 'prompt-a' }],
+ };
+
+ const result = normalizeEsmExport(moduleExport);
+ expect(result.name).toBe('named-pkg');
+ expect(result.tools).toHaveLength(1);
+ expect(result.prompts).toHaveLength(1);
+ });
+
+ it('should handle module with only primitive arrays (no name/version)', () => {
+ const moduleExport = {
+ tools: [{ name: 'tool-a' }],
+ };
+
+ const result = normalizeEsmExport(moduleExport);
+ expect(result.name).toBe('unknown');
+ expect(result.version).toBe('0.0.0');
+ expect(result.tools).toHaveLength(1);
+ });
+ });
+
+ describe('error cases', () => {
+ it('should throw for null export', () => {
+ expect(() => normalizeEsmExport(null)).toThrow('must be an object');
+ });
+
+ it('should throw for undefined export', () => {
+ expect(() => normalizeEsmExport(undefined)).toThrow('must be an object');
+ });
+
+ it('should throw for primitive export', () => {
+ expect(() => normalizeEsmExport('string')).toThrow('must be an object');
+ });
+
+ it('should throw for empty object with no recognizable structure', () => {
+ expect(() => normalizeEsmExport({})).toThrow('does not export a valid');
+ });
+ });
+
+ describe('decorated class named exports', () => {
+ it('should detect a single @Tool named export and collect into manifest', () => {
+ class EchoTool {}
+ simulateTool(EchoTool, 'echo');
+
+ const moduleExport = { EchoTool };
+ const result = normalizeEsmExport(moduleExport);
+ expect(result.tools).toHaveLength(1);
+ expect(result.tools).toBeDefined();
+ expect(result.tools?.[0]).toBe(EchoTool);
+ });
+
+ it('should detect multiple @Tool named exports', () => {
+ class EchoTool {}
+ class AddTool {}
+ simulateTool(EchoTool, 'echo');
+ simulateTool(AddTool, 'add');
+
+ const moduleExport = { EchoTool, AddTool };
+ const result = normalizeEsmExport(moduleExport);
+ expect(result.tools).toHaveLength(2);
+ });
+
+ it('should detect mixed primitive types as named exports', () => {
+ class MyTool {}
+ class MyResource {}
+ class MyPrompt {}
+ simulateTool(MyTool, 'my-tool');
+ simulateResource(MyResource);
+ simulatePrompt(MyPrompt);
+
+ const moduleExport = { MyTool, MyResource, MyPrompt };
+ const result = normalizeEsmExport(moduleExport);
+ expect(result.tools).toHaveLength(1);
+ expect(result.resources).toHaveLength(1);
+ expect(result.prompts).toHaveLength(1);
+ });
+
+ it('should detect @Skill and @Job decorated exports', () => {
+ class MySkill {}
+ class MyJob {}
+ simulateSkill(MySkill);
+ simulateJob(MyJob);
+
+ const moduleExport = { MySkill, MyJob };
+ const result = normalizeEsmExport(moduleExport);
+ expect(result.skills).toHaveLength(1);
+ expect(result.jobs).toHaveLength(1);
+ });
+
+ it('should detect all 7 primitive types in a single module', () => {
+ class T {}
+ class R {}
+ class P {}
+ class S {}
+ class J {}
+ class AG {}
+ class WF {}
+ simulateTool(T, 't');
+ simulateResource(R, 'r');
+ simulatePrompt(P, 'p');
+ simulateSkill(S, 's');
+ simulateJob(J, 'j');
+ simulateAgent(AG, 'ag');
+ simulateWorkflow(WF, 'wf');
+
+ const moduleExport = { T, R, P, S, J, AG, WF };
+ const result = normalizeEsmExport(moduleExport);
+ expect(result.tools).toHaveLength(1);
+ expect(result.resources).toHaveLength(1);
+ expect(result.prompts).toHaveLength(1);
+ expect(result.skills).toHaveLength(1);
+ expect(result.jobs).toHaveLength(1);
+ expect(result.agents).toHaveLength(1);
+ expect(result.workflows).toHaveLength(1);
+ });
+
+ it('should detect @Agent and @Workflow decorated exports', () => {
+ class MyAgent {}
+ class MyWorkflow {}
+ simulateAgent(MyAgent, 'my-agent');
+ simulateWorkflow(MyWorkflow, 'my-workflow');
+
+ const moduleExport = { MyAgent, MyWorkflow };
+ const result = normalizeEsmExport(moduleExport);
+ expect(result.agents).toHaveLength(1);
+ expect(result.agents).toBeDefined();
+ expect(result.agents?.[0]).toBe(MyAgent);
+ expect(result.workflows).toHaveLength(1);
+ expect(result.workflows).toBeDefined();
+ expect(result.workflows?.[0]).toBe(MyWorkflow);
+ });
+
+ it('should ignore non-class exports when scanning decorated classes', () => {
+ class MyTool {}
+ simulateTool(MyTool, 'my-tool');
+
+ const moduleExport = {
+ MyTool,
+ someString: 'hello',
+ someNumber: 42,
+ someObject: { foo: 'bar' },
+ __esModule: true,
+ };
+ const result = normalizeEsmExport(moduleExport);
+ expect(result.tools).toHaveLength(1);
+ });
+ });
+
+ describe('single decorated default export', () => {
+ it('should detect a single @Tool as default export', () => {
+ class EchoTool {}
+ simulateTool(EchoTool, 'echo');
+
+ const moduleExport = { default: EchoTool };
+ const result = normalizeEsmExport(moduleExport);
+ expect(result.tools).toHaveLength(1);
+ expect(result.tools).toBeDefined();
+ expect(result.tools?.[0]).toBe(EchoTool);
+ });
+
+ it('should detect a single @Resource as default export', () => {
+ class StatusResource {}
+ simulateResource(StatusResource, 'status');
+
+ const moduleExport = { default: StatusResource };
+ const result = normalizeEsmExport(moduleExport);
+ expect(result.resources).toHaveLength(1);
+ });
+
+ it('should detect a single @Agent as default export', () => {
+ class ResearchAgent {}
+ simulateAgent(ResearchAgent, 'research');
+
+ const moduleExport = { default: ResearchAgent };
+ const result = normalizeEsmExport(moduleExport);
+ expect(result.agents).toHaveLength(1);
+ expect(result.agents).toBeDefined();
+ expect(result.agents?.[0]).toBe(ResearchAgent);
+ });
+
+ it('should detect a single @Workflow as default export', () => {
+ class PipelineWorkflow {}
+ simulateWorkflow(PipelineWorkflow, 'pipeline');
+
+ const moduleExport = { default: PipelineWorkflow };
+ const result = normalizeEsmExport(moduleExport);
+ expect(result.workflows).toHaveLength(1);
+ expect(result.workflows).toBeDefined();
+ expect(result.workflows?.[0]).toBe(PipelineWorkflow);
+ });
+ });
+
+ describe('decorated class exports nested in default', () => {
+ it('should scan default export object for decorated classes', () => {
+ class MyTool {}
+ class MyPrompt {}
+ simulateTool(MyTool, 'my-tool');
+ simulatePrompt(MyPrompt);
+
+ const moduleExport = { default: { MyTool, MyPrompt } };
+ const result = normalizeEsmExport(moduleExport);
+ expect(result.tools).toHaveLength(1);
+ expect(result.prompts).toHaveLength(1);
+ });
+ });
+});
+
+describe('frontMcpPackageManifestSchema', () => {
+ it('should validate a complete manifest', () => {
+ const result = frontMcpPackageManifestSchema.safeParse({
+ name: 'test',
+ version: '1.0.0',
+ tools: [],
+ prompts: [],
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('should reject manifest without name', () => {
+ const result = frontMcpPackageManifestSchema.safeParse({
+ version: '1.0.0',
+ });
+ expect(result.success).toBe(false);
+ });
+
+ it('should reject manifest without version', () => {
+ const result = frontMcpPackageManifestSchema.safeParse({
+ name: 'test',
+ });
+ expect(result.success).toBe(false);
+ });
+});
diff --git a/libs/sdk/src/esm-loader/__tests__/esm-module-loader.spec.ts b/libs/sdk/src/esm-loader/__tests__/esm-module-loader.spec.ts
new file mode 100644
index 000000000..fb6224b14
--- /dev/null
+++ b/libs/sdk/src/esm-loader/__tests__/esm-module-loader.spec.ts
@@ -0,0 +1,283 @@
+import { EsmModuleLoader } from '../esm-module-loader';
+import type { EsmCacheManager, EsmCacheEntry } from '../esm-cache';
+import { VersionResolver } from '../version-resolver';
+
+// Mock VersionResolver
+jest.mock('../version-resolver');
+
+// Mock esm-manifest
+jest.mock('../esm-manifest', () => ({
+ normalizeEsmExport: jest.fn((mod: unknown) => mod),
+}));
+
+// Mock node:url
+jest.mock('node:url', () => ({
+ pathToFileURL: jest.fn((p: string) => ({ href: `file://${p}` })),
+}));
+
+describe('EsmModuleLoader', () => {
+ let mockCache: jest.Mocked;
+ let mockResolve: jest.Mock;
+ let loader: EsmModuleLoader;
+
+ const specifier = {
+ scope: '@acme',
+ name: 'tools',
+ fullName: '@acme/tools',
+ range: '^1.0.0',
+ raw: '@acme/tools@^1.0.0',
+ };
+
+ beforeEach(() => {
+ mockCache = {
+ get: jest.fn(),
+ put: jest.fn(),
+ invalidate: jest.fn(),
+ cleanup: jest.fn(),
+ readBundle: jest.fn(),
+ } as unknown as jest.Mocked;
+
+ mockResolve = jest.fn();
+ (VersionResolver as jest.MockedClass).mockImplementation(
+ () =>
+ ({
+ resolve: mockResolve,
+ }) as unknown as VersionResolver,
+ );
+
+ loader = new EsmModuleLoader({ cache: mockCache });
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe('resolveVersion()', () => {
+ it('delegates to VersionResolver', async () => {
+ mockResolve.mockResolvedValue({
+ resolvedVersion: '1.2.0',
+ availableVersions: ['1.0.0', '1.2.0'],
+ });
+
+ const version = await loader.resolveVersion(specifier);
+ expect(version).toBe('1.2.0');
+ expect(mockResolve).toHaveBeenCalledWith(specifier);
+ });
+ });
+
+ describe('load()', () => {
+ const mockFetch = jest.fn();
+
+ beforeEach(() => {
+ (globalThis as unknown as { fetch: jest.Mock }).fetch = mockFetch;
+ mockResolve.mockResolvedValue({
+ resolvedVersion: '1.2.0',
+ availableVersions: ['1.0.0', '1.2.0'],
+ });
+ });
+
+ afterEach(() => {
+ mockFetch.mockReset();
+ });
+
+ it('returns from cache when hit (no fetch)', async () => {
+ const cachedEntry: EsmCacheEntry = {
+ packageUrl: 'https://esm.sh/@acme/tools@1.2.0?bundle',
+ packageName: '@acme/tools',
+ resolvedVersion: '1.2.0',
+ cachedAt: Date.now(),
+ bundlePath: '/tmp/cache/bundle.mjs',
+ };
+
+ mockCache.get.mockResolvedValue(cachedEntry);
+
+ // Mock dynamic import for the cached file
+ const mockModule = { name: '@acme/tools', version: '1.2.0', tools: [] };
+ jest
+ .spyOn(loader as unknown as { importFromPath: (p: string) => Promise }, 'importFromPath' as never)
+ .mockResolvedValue(mockModule as never);
+
+ const result = await loader.load(specifier);
+
+ expect(result.source).toBe('cache');
+ expect(result.resolvedVersion).toBe('1.2.0');
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
+ it('prefers the cached file path over in-memory bundle content in Node mode', async () => {
+ const cachedEntry: EsmCacheEntry = {
+ packageUrl: 'https://esm.sh/@acme/tools@1.2.0?bundle',
+ packageName: '@acme/tools',
+ resolvedVersion: '1.2.0',
+ cachedAt: Date.now(),
+ bundlePath: '/tmp/cache/bundle.cjs',
+ bundleContent: 'module.exports = { default: { name: "stale" } };',
+ };
+
+ mockCache.get.mockResolvedValue(cachedEntry);
+
+ const mockModule = { name: '@acme/tools', version: '1.2.0', tools: [] };
+ const importFromPathSpy = jest
+ .spyOn(loader as unknown as { importFromPath: (p: string) => Promise }, 'importFromPath' as never)
+ .mockResolvedValue(mockModule as never);
+ const importBundleSpy = jest
+ .spyOn(loader as unknown as { importBundle: (source: string) => Promise }, 'importBundle' as never)
+ .mockResolvedValue({ name: 'wrong-path' } as never);
+
+ const result = await loader.load(specifier);
+
+ expect(result.source).toBe('cache');
+ expect(result.manifest.name).toBe('@acme/tools');
+ expect(importFromPathSpy).toHaveBeenCalledWith('/tmp/cache/bundle.cjs');
+ expect(importBundleSpy).not.toHaveBeenCalled();
+ });
+
+ it('falls back to in-memory bundle content when the cached file import fails', async () => {
+ const cachedEntry: EsmCacheEntry = {
+ packageUrl: 'https://esm.sh/@acme/tools@1.2.0?bundle',
+ packageName: '@acme/tools',
+ resolvedVersion: '1.2.0',
+ cachedAt: Date.now(),
+ bundlePath: '/tmp/cache/bundle.cjs',
+ bundleContent: 'module.exports = { default: { name: "fallback" } };',
+ };
+
+ mockCache.get.mockResolvedValue(cachedEntry);
+
+ const fallbackModule = { name: '@acme/tools', version: '1.2.0', tools: [] };
+ const importFromPathSpy = jest
+ .spyOn(loader as unknown as { importFromPath: (p: string) => Promise }, 'importFromPath' as never)
+ .mockRejectedValue(new Error('ENOENT') as never);
+ const importBundleSpy = jest
+ .spyOn(loader as unknown as { importBundle: (source: string) => Promise }, 'importBundle' as never)
+ .mockResolvedValue(fallbackModule as never);
+
+ const result = await loader.load(specifier);
+
+ expect(result.source).toBe('cache');
+ expect(result.manifest.name).toBe('@acme/tools');
+ expect(importFromPathSpy).toHaveBeenCalledWith('/tmp/cache/bundle.cjs');
+ expect(importBundleSpy).toHaveBeenCalledWith(cachedEntry.bundleContent);
+ });
+
+ it('fetches from network on cache miss', async () => {
+ mockCache.get.mockResolvedValue(undefined);
+
+ const bundleContent = 'export default { name: "test", version: "1.0.0" }';
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ text: async () => bundleContent,
+ headers: { get: (key: string) => (key === 'etag' ? '"abc"' : null) },
+ });
+
+ const mockEntry: EsmCacheEntry = {
+ packageUrl: 'url',
+ packageName: '@acme/tools',
+ resolvedVersion: '1.2.0',
+ cachedAt: Date.now(),
+ bundlePath: '/tmp/cache/bundle.mjs',
+ };
+ mockCache.put.mockResolvedValue(mockEntry);
+
+ // Mock the private importFromPath method
+ const mockModule = { name: '@acme/tools', version: '1.2.0', tools: [] };
+ jest
+ .spyOn(loader as unknown as Record, 'importFromPath' as never)
+ .mockResolvedValue(mockModule as never);
+
+ const result = await loader.load(specifier);
+
+ expect(result.source).toBe('network');
+ expect(result.resolvedVersion).toBe('1.2.0');
+ expect(mockCache.put).toHaveBeenCalledWith(
+ '@acme/tools',
+ '1.2.0',
+ bundleContent,
+ expect.stringContaining('@acme/tools@1.2.0'),
+ '"abc"',
+ );
+ });
+
+ it('throws timeout error on fetch abort', async () => {
+ mockCache.get.mockResolvedValue(undefined);
+ mockFetch.mockRejectedValue(Object.assign(new Error('aborted'), { name: 'AbortError' }));
+
+ await expect(loader.load(specifier)).rejects.toThrow('Timeout');
+ });
+
+ it('throws on non-ok fetch response', async () => {
+ mockCache.get.mockResolvedValue(undefined);
+ mockFetch.mockResolvedValue({
+ ok: false,
+ status: 500,
+ statusText: 'Internal Server Error',
+ });
+
+ await expect(loader.load(specifier)).rejects.toThrow('esm.sh returned 500');
+ });
+
+ it('throws on generic fetch error', async () => {
+ mockCache.get.mockResolvedValue(undefined);
+ mockFetch.mockRejectedValue(new Error('connection refused'));
+
+ await expect(loader.load(specifier)).rejects.toThrow('Failed to fetch ESM bundle');
+ });
+
+ it('uses custom esmBaseUrl in fetch URL', async () => {
+ const customLoader = new EsmModuleLoader({
+ cache: mockCache,
+ esmBaseUrl: 'https://my-esm.example.com',
+ });
+
+ mockCache.get.mockResolvedValue(undefined);
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ text: async () => 'export default {}',
+ headers: { get: () => null },
+ });
+
+ const mockEntry: EsmCacheEntry = {
+ packageUrl: 'url',
+ packageName: '@acme/tools',
+ resolvedVersion: '1.2.0',
+ cachedAt: Date.now(),
+ bundlePath: '/tmp/cache/bundle.mjs',
+ };
+ mockCache.put.mockResolvedValue(mockEntry);
+
+ jest
+ .spyOn(customLoader as unknown as Record, 'importFromPath' as never)
+ .mockResolvedValue({} as never);
+
+ await customLoader.load(specifier);
+
+ const url = mockFetch.mock.calls[0][0] as string;
+ expect(url).toContain('https://my-esm.example.com/');
+ });
+ });
+
+ describe('constructor options', () => {
+ it('uses default timeout of 30000', () => {
+ const l = new EsmModuleLoader({ cache: mockCache });
+ expect(l).toBeInstanceOf(EsmModuleLoader);
+ });
+
+ it('accepts custom timeout', () => {
+ const l = new EsmModuleLoader({ cache: mockCache, timeout: 5000 });
+ expect(l).toBeInstanceOf(EsmModuleLoader);
+ });
+
+ it('accepts logger', () => {
+ const logger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() };
+ const l = new EsmModuleLoader({
+ cache: mockCache,
+ logger: logger as unknown as ConstructorParameters[0] extends { logger?: infer L }
+ ? L
+ : never,
+ });
+ expect(l).toBeInstanceOf(EsmModuleLoader);
+ });
+ });
+});
diff --git a/libs/sdk/src/esm-loader/__tests__/esm-normalize.utils.spec.ts b/libs/sdk/src/esm-loader/__tests__/esm-normalize.utils.spec.ts
new file mode 100644
index 000000000..8caaba492
--- /dev/null
+++ b/libs/sdk/src/esm-loader/__tests__/esm-normalize.utils.spec.ts
@@ -0,0 +1,244 @@
+import 'reflect-metadata';
+import {
+ normalizeToolFromEsmExport,
+ normalizeResourceFromEsmExport,
+ normalizePromptFromEsmExport,
+ isDecoratedToolClass,
+ isDecoratedResourceClass,
+ isDecoratedPromptClass,
+} from '../../app/instances/esm-normalize.utils';
+import { FrontMcpToolTokens, FrontMcpResourceTokens, FrontMcpPromptTokens } from '../../common/tokens';
+
+describe('esm-normalize.utils', () => {
+ // ═══════════════════════════════════════════════════════════════
+ // DECORATED CLASS DETECTION
+ // ═══════════════════════════════════════════════════════════════
+
+ describe('isDecoratedToolClass()', () => {
+ it('returns true for class with @Tool Symbol metadata', () => {
+ class MyTool {
+ async execute() {
+ return { content: [] };
+ }
+ }
+ Reflect.defineMetadata(FrontMcpToolTokens.type, true, MyTool);
+ expect(isDecoratedToolClass(MyTool)).toBe(true);
+ });
+
+ it('returns false for class without @Tool metadata', () => {
+ class PlainClass {}
+ expect(isDecoratedToolClass(PlainClass)).toBe(false);
+ });
+
+ it('returns false for plain objects', () => {
+ expect(isDecoratedToolClass({ name: 'tool', execute: jest.fn() })).toBe(false);
+ });
+
+ it('returns false for null/undefined/primitives', () => {
+ expect(isDecoratedToolClass(null)).toBe(false);
+ expect(isDecoratedToolClass(undefined)).toBe(false);
+ expect(isDecoratedToolClass(42)).toBe(false);
+ expect(isDecoratedToolClass('string')).toBe(false);
+ });
+
+ it('returns false for class with string-key metadata (not Symbol)', () => {
+ class OldStyleTool {}
+ Reflect.defineMetadata('frontmcp:tool:name', 'old', OldStyleTool);
+ expect(isDecoratedToolClass(OldStyleTool)).toBe(false);
+ });
+ });
+
+ describe('isDecoratedResourceClass()', () => {
+ it('returns true for class with @Resource Symbol metadata', () => {
+ class MyResource {}
+ Reflect.defineMetadata(FrontMcpResourceTokens.type, true, MyResource);
+ expect(isDecoratedResourceClass(MyResource)).toBe(true);
+ });
+
+ it('returns false for class without metadata', () => {
+ class PlainClass {}
+ expect(isDecoratedResourceClass(PlainClass)).toBe(false);
+ });
+
+ it('returns false for plain objects', () => {
+ expect(isDecoratedResourceClass({ name: 'r', uri: 'x', read: jest.fn() })).toBe(false);
+ });
+ });
+
+ describe('isDecoratedPromptClass()', () => {
+ it('returns true for class with @Prompt Symbol metadata', () => {
+ class MyPrompt {}
+ Reflect.defineMetadata(FrontMcpPromptTokens.type, true, MyPrompt);
+ expect(isDecoratedPromptClass(MyPrompt)).toBe(true);
+ });
+
+ it('returns false for class without metadata', () => {
+ class PlainClass {}
+ expect(isDecoratedPromptClass(PlainClass)).toBe(false);
+ });
+
+ it('returns false for plain objects', () => {
+ expect(isDecoratedPromptClass({ name: 'p', execute: jest.fn() })).toBe(false);
+ });
+ });
+
+ // ═══════════════════════════════════════════════════════════════
+ // PLAIN OBJECT NORMALIZATION
+ // ═══════════════════════════════════════════════════════════════
+
+ describe('normalizeToolFromEsmExport()', () => {
+ it('normalizes plain object with execute function', () => {
+ const executeFn = jest.fn().mockResolvedValue({ content: [] });
+ const raw = {
+ name: 'my-tool',
+ description: 'A test tool',
+ inputSchema: { type: 'object' },
+ outputSchema: { type: 'string' },
+ execute: executeFn,
+ };
+
+ const result = normalizeToolFromEsmExport(raw);
+
+ expect(result).toBeDefined();
+ expect(result!.name).toBe('my-tool');
+ expect(result!.description).toBe('A test tool');
+ expect(result!.inputSchema).toEqual({ type: 'object' });
+ expect(result!.execute).toBe(executeFn);
+ });
+
+ it('normalizes plain object without optional fields', () => {
+ const executeFn = jest.fn();
+ const raw = { name: 'basic', execute: executeFn };
+
+ const result = normalizeToolFromEsmExport(raw);
+
+ expect(result).toBeDefined();
+ expect(result!.name).toBe('basic');
+ expect(result!.description).toBeUndefined();
+ expect(result!.inputSchema).toBeUndefined();
+ });
+
+ it('returns undefined for decorated classes (handled by caller)', () => {
+ class DecoratedTool {
+ async execute() {
+ return { content: [] };
+ }
+ }
+ Reflect.defineMetadata(FrontMcpToolTokens.type, true, DecoratedTool);
+ // Classes are not handled by normalizeToolFromEsmExport
+ expect(normalizeToolFromEsmExport(DecoratedTool)).toBeUndefined();
+ });
+
+ it('returns undefined for undecorated classes', () => {
+ class PlainClass {
+ doSomething() {
+ return 42;
+ }
+ }
+ expect(normalizeToolFromEsmExport(PlainClass)).toBeUndefined();
+ });
+
+ it('returns undefined for null/undefined', () => {
+ expect(normalizeToolFromEsmExport(null)).toBeUndefined();
+ expect(normalizeToolFromEsmExport(undefined)).toBeUndefined();
+ });
+
+ it('returns undefined for object without execute', () => {
+ expect(normalizeToolFromEsmExport({ name: 'no-execute' })).toBeUndefined();
+ });
+
+ it('returns undefined for object without name', () => {
+ expect(normalizeToolFromEsmExport({ execute: jest.fn() })).toBeUndefined();
+ });
+
+ it('returns undefined for primitive values', () => {
+ expect(normalizeToolFromEsmExport('string')).toBeUndefined();
+ expect(normalizeToolFromEsmExport(42)).toBeUndefined();
+ expect(normalizeToolFromEsmExport(true)).toBeUndefined();
+ });
+ });
+
+ describe('normalizeResourceFromEsmExport()', () => {
+ it('normalizes plain object with read function', () => {
+ const readFn = jest.fn().mockResolvedValue({ contents: [] });
+ const raw = {
+ name: 'my-resource',
+ description: 'A test resource',
+ uri: 'file://test',
+ mimeType: 'text/plain',
+ read: readFn,
+ };
+
+ const result = normalizeResourceFromEsmExport(raw);
+
+ expect(result).toBeDefined();
+ expect(result!.name).toBe('my-resource');
+ expect(result!.description).toBe('A test resource');
+ expect(result!.uri).toBe('file://test');
+ expect(result!.mimeType).toBe('text/plain');
+ expect(result!.read).toBe(readFn);
+ });
+
+ it('returns undefined for object without read function', () => {
+ expect(normalizeResourceFromEsmExport({ name: 'r', uri: 'x' })).toBeUndefined();
+ });
+
+ it('returns undefined for object without name', () => {
+ expect(normalizeResourceFromEsmExport({ read: jest.fn(), uri: 'x' })).toBeUndefined();
+ });
+
+ it('returns undefined for object without uri', () => {
+ expect(normalizeResourceFromEsmExport({ name: 'r', read: jest.fn() })).toBeUndefined();
+ });
+
+ it('returns undefined for null/undefined', () => {
+ expect(normalizeResourceFromEsmExport(null)).toBeUndefined();
+ expect(normalizeResourceFromEsmExport(undefined)).toBeUndefined();
+ });
+
+ it('returns undefined for decorated classes', () => {
+ class DecoratedResource {}
+ Reflect.defineMetadata(FrontMcpResourceTokens.type, true, DecoratedResource);
+ expect(normalizeResourceFromEsmExport(DecoratedResource)).toBeUndefined();
+ });
+ });
+
+ describe('normalizePromptFromEsmExport()', () => {
+ it('normalizes plain object with execute function', () => {
+ const executeFn = jest.fn().mockResolvedValue({ messages: [] });
+ const raw = {
+ name: 'my-prompt',
+ description: 'A test prompt',
+ arguments: [{ name: 'topic', description: 'Topic', required: true }],
+ execute: executeFn,
+ };
+
+ const result = normalizePromptFromEsmExport(raw);
+
+ expect(result).toBeDefined();
+ expect(result!.name).toBe('my-prompt');
+ expect(result!.description).toBe('A test prompt');
+ expect(result!.arguments).toEqual([{ name: 'topic', description: 'Topic', required: true }]);
+ expect(result!.execute).toBe(executeFn);
+ });
+
+ it('returns undefined for object without execute', () => {
+ expect(normalizePromptFromEsmExport({ name: 'p' })).toBeUndefined();
+ });
+
+ it('returns undefined for object without name', () => {
+ expect(normalizePromptFromEsmExport({ execute: jest.fn() })).toBeUndefined();
+ });
+
+ it('returns undefined for null/undefined', () => {
+ expect(normalizePromptFromEsmExport(null)).toBeUndefined();
+ expect(normalizePromptFromEsmExport(undefined)).toBeUndefined();
+ });
+
+ it('returns undefined for decorated classes', () => {
+ class DecoratedPrompt {}
+ Reflect.defineMetadata(FrontMcpPromptTokens.type, true, DecoratedPrompt);
+ expect(normalizePromptFromEsmExport(DecoratedPrompt)).toBeUndefined();
+ });
+ });
+});
diff --git a/libs/sdk/src/esm-loader/__tests__/esm-record-builders.spec.ts b/libs/sdk/src/esm-loader/__tests__/esm-record-builders.spec.ts
new file mode 100644
index 000000000..1dd2b13cf
--- /dev/null
+++ b/libs/sdk/src/esm-loader/__tests__/esm-record-builders.spec.ts
@@ -0,0 +1,175 @@
+import { z } from 'zod';
+import { buildEsmToolRecord, buildEsmResourceRecord, buildEsmPromptRecord } from '../factories/esm-record-builders';
+import type { EsmToolDefinition, EsmResourceDefinition, EsmPromptDefinition } from '../factories/esm-record-builders';
+
+describe('esm-record-builders', () => {
+ describe('buildEsmToolRecord()', () => {
+ const baseTool: EsmToolDefinition = {
+ name: 'echo',
+ description: 'Echoes input',
+ inputSchema: { type: 'object', properties: { text: { type: 'string' } } },
+ execute: jest.fn(),
+ };
+
+ it('builds a record with correct metadata', () => {
+ const record = buildEsmToolRecord(baseTool);
+
+ expect(record.kind).toBe('CLASS_TOKEN');
+ expect(record.metadata.name).toBe('echo');
+ expect(record.metadata.id).toBe('echo');
+ expect(record.metadata.description).toBe('Echoes input');
+ expect(record.metadata.rawInputSchema).toEqual(baseTool.inputSchema);
+ expect(record.provide).toBeDefined();
+ });
+
+ it('adds namespace prefix when provided', () => {
+ const record = buildEsmToolRecord(baseTool, 'my-pkg');
+ expect(record.metadata.name).toBe('my-pkg:echo');
+ expect(record.metadata.id).toBe('my-pkg:echo');
+ });
+
+ it('sets esm annotations', () => {
+ const record = buildEsmToolRecord(baseTool);
+ const annotations = record.metadata.annotations as Record;
+ expect(annotations['frontmcp:esm']).toBe(true);
+ expect(annotations['frontmcp:esmTool']).toBe('echo');
+ });
+
+ it('uses default description when not provided', () => {
+ const tool: EsmToolDefinition = { name: 'bare', execute: jest.fn() };
+ const record = buildEsmToolRecord(tool);
+ expect(record.metadata.description).toBe('ESM tool: bare');
+ });
+
+ it('provide is a class with the correct name', () => {
+ const record = buildEsmToolRecord(baseTool);
+ expect(record.provide.name).toBe('EsmTool_echo');
+ });
+
+ it('routes JSON Schema to rawInputSchema', () => {
+ const jsonSchema = {
+ type: 'object',
+ properties: { text: { type: 'string' } },
+ };
+ const tool: EsmToolDefinition = {
+ name: 'json-tool',
+ inputSchema: jsonSchema,
+ execute: jest.fn(),
+ };
+ const record = buildEsmToolRecord(tool);
+ expect(record.metadata.rawInputSchema).toEqual(jsonSchema);
+ expect(record.metadata.inputSchema).toEqual({});
+ });
+
+ it('routes Zod raw shape to inputSchema (not rawInputSchema)', () => {
+ const zodShape = {
+ message: z.string(),
+ count: z.number().optional(),
+ };
+ const tool: EsmToolDefinition = {
+ name: 'zod-tool',
+ inputSchema: zodShape as unknown as Record,
+ execute: jest.fn(),
+ };
+ const record = buildEsmToolRecord(tool);
+ expect(record.metadata.inputSchema).toEqual(zodShape);
+ expect(record.metadata.rawInputSchema).toBeUndefined();
+ });
+
+ it('handles undefined inputSchema', () => {
+ const tool: EsmToolDefinition = {
+ name: 'no-schema',
+ execute: jest.fn(),
+ };
+ const record = buildEsmToolRecord(tool);
+ expect(record.metadata.inputSchema).toEqual({});
+ expect(record.metadata.rawInputSchema).toBeUndefined();
+ });
+ });
+
+ describe('buildEsmResourceRecord()', () => {
+ const baseResource: EsmResourceDefinition = {
+ name: 'status',
+ description: 'Server status',
+ uri: 'status://server',
+ mimeType: 'application/json',
+ read: jest.fn(),
+ };
+
+ it('builds a record with correct metadata', () => {
+ const record = buildEsmResourceRecord(baseResource);
+
+ expect(record.kind).toBe('CLASS_TOKEN');
+ expect(record.metadata.name).toBe('status');
+ expect(record.metadata.description).toBe('Server status');
+ expect(record.metadata.uri).toBe('status://server');
+ expect(record.metadata.mimeType).toBe('application/json');
+ });
+
+ it('adds namespace prefix when provided', () => {
+ const record = buildEsmResourceRecord(baseResource, 'ns');
+ expect(record.metadata.name).toBe('ns:status');
+ });
+
+ it('uses default description when not provided', () => {
+ const resource: EsmResourceDefinition = {
+ name: 'bare',
+ uri: 'bare://res',
+ read: jest.fn(),
+ };
+ const record = buildEsmResourceRecord(resource);
+ expect(record.metadata.description).toBe('ESM resource: bare');
+ });
+
+ it('provide is a class with the correct name', () => {
+ const record = buildEsmResourceRecord(baseResource);
+ expect(record.provide.name).toBe('EsmResource_status');
+ });
+ });
+
+ describe('buildEsmPromptRecord()', () => {
+ const basePrompt: EsmPromptDefinition = {
+ name: 'greeter',
+ description: 'Greets a user',
+ arguments: [
+ { name: 'name', description: 'Name', required: true },
+ { name: 'style', description: 'Style', required: false },
+ ],
+ execute: jest.fn(),
+ };
+
+ it('builds a record with correct metadata', () => {
+ const record = buildEsmPromptRecord(basePrompt);
+
+ expect(record.kind).toBe('CLASS_TOKEN');
+ expect(record.metadata.name).toBe('greeter');
+ expect(record.metadata.description).toBe('Greets a user');
+ expect(record.metadata.arguments).toEqual([
+ { name: 'name', description: 'Name', required: true },
+ { name: 'style', description: 'Style', required: false },
+ ]);
+ });
+
+ it('adds namespace prefix when provided', () => {
+ const record = buildEsmPromptRecord(basePrompt, 'pkg');
+ expect(record.metadata.name).toBe('pkg:greeter');
+ });
+
+ it('uses default description when not provided', () => {
+ const prompt: EsmPromptDefinition = { name: 'bare', execute: jest.fn() };
+ const record = buildEsmPromptRecord(prompt);
+ expect(record.metadata.description).toBe('ESM prompt: bare');
+ });
+
+ it('uses empty arguments array when not provided', () => {
+ const prompt: EsmPromptDefinition = { name: 'no-args', execute: jest.fn() };
+ const record = buildEsmPromptRecord(prompt);
+ expect(record.metadata.arguments).toEqual([]);
+ });
+
+ it('provide is a class with the correct name', () => {
+ const record = buildEsmPromptRecord(basePrompt);
+ expect(record.provide.name).toBe('EsmPrompt_greeter');
+ });
+ });
+});
diff --git a/libs/sdk/src/esm-loader/__tests__/helpers/esm-fixtures.ts b/libs/sdk/src/esm-loader/__tests__/helpers/esm-fixtures.ts
new file mode 100644
index 000000000..288c30193
--- /dev/null
+++ b/libs/sdk/src/esm-loader/__tests__/helpers/esm-fixtures.ts
@@ -0,0 +1,110 @@
+/**
+ * @file esm-fixtures.ts
+ * @description In-memory bundle content strings for test fixtures.
+ * These are served by LocalEsmServer and written to disk for import during E2E tests.
+ *
+ * Note: Uses CJS format (module.exports) for Jest compatibility.
+ * In production, esm.sh serves actual ESM bundles. The CJS wrapper
+ * tests the full pipeline (fetch → cache → normalize → execute) without
+ * requiring --experimental-vm-modules in Jest.
+ */
+
+/**
+ * Simple tools package v1 - one tool that echoes input.
+ */
+export const SIMPLE_TOOLS_V1 = `
+module.exports = {
+ default: {
+ name: '@test/simple-tools',
+ version: '1.0.0',
+ tools: [{
+ name: 'echo',
+ description: 'Echoes input back',
+ execute: async (input) => ({
+ content: [{ type: 'text', text: JSON.stringify(input) }],
+ }),
+ }],
+ },
+};
+`;
+
+/**
+ * Simple tools package v2 - two tools (echo + reverse).
+ */
+export const SIMPLE_TOOLS_V2 = `
+module.exports = {
+ default: {
+ name: '@test/simple-tools',
+ version: '2.0.0',
+ tools: [
+ {
+ name: 'echo',
+ description: 'Echoes input back',
+ execute: async (input) => ({
+ content: [{ type: 'text', text: JSON.stringify(input) }],
+ }),
+ },
+ {
+ name: 'reverse',
+ description: 'Reverses input text',
+ execute: async (input) => ({
+ content: [{ type: 'text', text: String(input.text || '').split('').reverse().join('') }],
+ }),
+ },
+ ],
+ },
+};
+`;
+
+/**
+ * Multi-primitive package with tools + prompts + resources.
+ */
+export const MULTI_PRIMITIVE = `
+module.exports = {
+ default: {
+ name: '@test/multi-primitive',
+ version: '1.0.0',
+ tools: [{
+ name: 'greet',
+ description: 'Greets a user',
+ execute: async (input) => ({
+ content: [{ type: 'text', text: 'Hello, ' + (input.name || 'world') + '!' }],
+ }),
+ }],
+ prompts: [{
+ name: 'greeting-prompt',
+ description: 'A greeting prompt',
+ arguments: [{ name: 'name', description: 'Name to greet', required: true }],
+ execute: async (args) => ({
+ messages: [{ role: 'user', content: { type: 'text', text: 'Greet ' + args.name } }],
+ }),
+ }],
+ resources: [{
+ name: 'status',
+ description: 'Server status resource',
+ uri: 'status://server',
+ mimeType: 'application/json',
+ read: async () => ({
+ contents: [{ uri: 'status://server', text: JSON.stringify({ status: 'ok' }) }],
+ }),
+ }],
+ },
+};
+`;
+
+/**
+ * Named exports package (simulates a module with named exports).
+ */
+export const NAMED_EXPORTS = `
+module.exports = {
+ name: '@test/named-exports',
+ version: '1.0.0',
+ tools: [{
+ name: 'ping',
+ description: 'Pings the server',
+ execute: async () => ({
+ content: [{ type: 'text', text: 'pong' }],
+ }),
+ }],
+};
+`;
diff --git a/libs/sdk/src/esm-loader/__tests__/helpers/local-esm-server.ts b/libs/sdk/src/esm-loader/__tests__/helpers/local-esm-server.ts
new file mode 100644
index 000000000..d601f5b47
--- /dev/null
+++ b/libs/sdk/src/esm-loader/__tests__/helpers/local-esm-server.ts
@@ -0,0 +1,207 @@
+/**
+ * @file local-esm-server.ts
+ * @description Reusable local HTTP server that mimics both an npm registry API and ESM CDN.
+ * Used for E2E testing of the ESM loader pipeline without real network calls.
+ */
+
+import * as http from 'node:http';
+
+/**
+ * A package hosted by the local ESM server.
+ */
+export interface LocalEsmServerPackage {
+ /** Package name (e.g., '@test/simple-tools') */
+ name: string;
+ /** Map of version → ESM bundle source code */
+ versions: Record;
+ /** Dist-tags (e.g., { latest: '1.0.0', next: '2.0.0-beta.1' }) */
+ 'dist-tags'?: Record;
+}
+
+/**
+ * A local HTTP server that acts as both an npm registry and ESM CDN.
+ *
+ * URL patterns:
+ * - `GET /{packageName}` → npm registry JSON (versions, dist-tags)
+ * - `GET /{packageName}@{version}?bundle` → ESM module source code
+ * - `GET /{packageName}@{version}` → ESM module source code (also without ?bundle)
+ */
+export class LocalEsmServer {
+ private server: http.Server | undefined;
+ private readonly packages = new Map();
+ private requiredToken: string | undefined;
+ private port = 0;
+ private requestLog: Array<{ method: string; url: string; headers: Record }> = [];
+
+ /**
+ * Add a package to the server.
+ */
+ addPackage(pkg: LocalEsmServerPackage): void {
+ this.packages.set(pkg.name, pkg);
+ }
+
+ /**
+ * Update or add a specific version to an existing package.
+ */
+ updatePackage(name: string, version: string, bundle: string): void {
+ const pkg = this.packages.get(name);
+ if (pkg) {
+ pkg.versions[version] = { bundle };
+ // Update 'latest' dist-tag to the new version
+ if (!pkg['dist-tags']) {
+ pkg['dist-tags'] = {};
+ }
+ pkg['dist-tags']['latest'] = version;
+ }
+ }
+
+ /**
+ * Require a specific bearer token for all requests.
+ * Set to undefined to disable auth.
+ */
+ setAuthToken(token: string | undefined): void {
+ this.requiredToken = token;
+ }
+
+ /**
+ * Get the request log (for verifying what was requested).
+ */
+ getRequestLog(): typeof this.requestLog {
+ return [...this.requestLog];
+ }
+
+ /**
+ * Clear the request log.
+ */
+ clearRequestLog(): void {
+ this.requestLog = [];
+ }
+
+ /**
+ * Start the server on a random port.
+ */
+ async start(): Promise<{ registryUrl: string; esmBaseUrl: string; port: number }> {
+ return new Promise((resolve, reject) => {
+ this.server = http.createServer((req, res) => this.handleRequest(req, res));
+
+ this.server.listen(0, '127.0.0.1', () => {
+ const addr = this.server!.address();
+ if (!addr || typeof addr === 'string') {
+ reject(new Error('Failed to get server address'));
+ return;
+ }
+ this.port = addr.port;
+ const baseUrl = `http://127.0.0.1:${this.port}`;
+ resolve({
+ registryUrl: baseUrl,
+ esmBaseUrl: baseUrl,
+ port: this.port,
+ });
+ });
+
+ this.server.on('error', reject);
+ });
+ }
+
+ /**
+ * Stop the server.
+ */
+ async stop(): Promise {
+ return new Promise((resolve) => {
+ if (!this.server) {
+ resolve();
+ return;
+ }
+ this.server.close(() => resolve());
+ });
+ }
+
+ private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
+ const url = req.url ?? '/';
+ const method = req.method ?? 'GET';
+
+ this.requestLog.push({
+ method,
+ url,
+ headers: {
+ authorization: req.headers['authorization'],
+ accept: req.headers['accept'],
+ },
+ });
+
+ // Check auth if required
+ if (this.requiredToken) {
+ const authHeader = req.headers['authorization'];
+ if (!authHeader || authHeader !== `Bearer ${this.requiredToken}`) {
+ res.writeHead(401, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
+ return;
+ }
+ }
+
+ // Parse the URL path
+ const urlObj = new URL(url, `http://127.0.0.1:${this.port}`);
+ const pathname = decodeURIComponent(urlObj.pathname).slice(1); // Remove leading /
+
+ // Check if this is a versioned request (e.g., @scope/name@1.0.0)
+ const versionMatch = pathname.match(/^(.+?)@(\d+\.\d+\.\d+.*)$/);
+
+ if (versionMatch) {
+ // ESM bundle request: /{package}@{version}
+ this.serveBundleRequest(res, versionMatch[1], versionMatch[2]);
+ } else {
+ // Registry metadata request: /{package}
+ this.serveRegistryRequest(res, pathname);
+ }
+ }
+
+ private serveRegistryRequest(res: http.ServerResponse, packageName: string): void {
+ const pkg = this.packages.get(packageName);
+ if (!pkg) {
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Not found' }));
+ return;
+ }
+
+ // Build npm registry-style response
+ const versions: Record = {};
+ const time: Record = {};
+
+ for (const ver of Object.keys(pkg.versions)) {
+ versions[ver] = { version: ver, name: pkg.name };
+ time[ver] = new Date().toISOString();
+ }
+
+ const registryData = {
+ name: pkg.name,
+ 'dist-tags': pkg['dist-tags'] ?? { latest: Object.keys(pkg.versions).pop() ?? '0.0.0' },
+ versions,
+ time,
+ };
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(registryData));
+ }
+
+ private serveBundleRequest(res: http.ServerResponse, packageName: string, version: string): void {
+ const pkg = this.packages.get(packageName);
+ if (!pkg) {
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
+ res.end(`Package "${packageName}" not found`);
+ return;
+ }
+
+ const versionEntry = pkg.versions[version];
+ if (!versionEntry) {
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
+ res.end(`Version "${version}" not found for "${packageName}"`);
+ return;
+ }
+
+ res.writeHead(200, {
+ 'Content-Type': 'application/javascript',
+ ETag: `"${packageName}@${version}"`,
+ });
+ res.end(versionEntry.bundle);
+ }
+}
diff --git a/libs/sdk/src/esm-loader/__tests__/load-from.spec.ts b/libs/sdk/src/esm-loader/__tests__/load-from.spec.ts
new file mode 100644
index 000000000..a0688899d
--- /dev/null
+++ b/libs/sdk/src/esm-loader/__tests__/load-from.spec.ts
@@ -0,0 +1,180 @@
+import { App } from '../../common/decorators/app.decorator';
+import type { EsmAppOptions } from '../../common/metadata/app.metadata';
+
+describe('App.esm()', () => {
+ it('creates RemoteAppMetadata with urlType esm', () => {
+ const result = App.esm('@acme/tools@^1.0.0');
+ expect(result.urlType).toBe('esm');
+ expect(result.url).toBe('@acme/tools@^1.0.0');
+ });
+
+ it('derives name from package specifier', () => {
+ const result = App.esm('@acme/tools@^1.0.0');
+ expect(result.name).toBe('@acme/tools');
+ });
+
+ it('derives name from unscoped package', () => {
+ const result = App.esm('my-tools@latest');
+ expect(result.name).toBe('my-tools');
+ });
+
+ it('allows overriding name via options', () => {
+ const result = App.esm('@acme/tools@^1.0.0', { name: 'custom-name' });
+ expect(result.name).toBe('custom-name');
+ });
+
+ it('sets namespace when provided', () => {
+ const result = App.esm('@acme/tools@^1.0.0', { namespace: 'acme' });
+ expect(result.namespace).toBe('acme');
+ });
+
+ it('sets description when provided', () => {
+ const result = App.esm('@acme/tools@^1.0.0', { description: 'Acme tools' });
+ expect(result.description).toBe('Acme tools');
+ });
+
+ it('defaults standalone to false', () => {
+ const result = App.esm('@acme/tools@^1.0.0');
+ expect(result.standalone).toBe(false);
+ });
+
+ it('allows standalone override', () => {
+ const result = App.esm('@acme/tools@^1.0.0', { standalone: true });
+ expect(result.standalone).toBe(true);
+ });
+
+ it('allows standalone "includeInParent"', () => {
+ const result = App.esm('@acme/tools@^1.0.0', { standalone: 'includeInParent' });
+ expect(result.standalone).toBe('includeInParent');
+ });
+
+ it('does not include packageConfig when no config options are set', () => {
+ const result = App.esm('@acme/tools@^1.0.0', { namespace: 'acme' });
+ expect(result.packageConfig).toBeUndefined();
+ });
+
+ it('includes packageConfig.loader when loader is provided', () => {
+ const result = App.esm('@acme/tools@^1.0.0', {
+ loader: { url: 'http://esm.internal.corp', token: 'xxx' },
+ });
+ expect(result.packageConfig?.loader).toEqual({
+ url: 'http://esm.internal.corp',
+ token: 'xxx',
+ });
+ });
+
+ it('includes packageConfig.autoUpdate when autoUpdate is provided', () => {
+ const result = App.esm('@acme/tools@^1.0.0', {
+ autoUpdate: { enabled: true, intervalMs: 5000 },
+ });
+ expect(result.packageConfig?.autoUpdate).toEqual({
+ enabled: true,
+ intervalMs: 5000,
+ });
+ });
+
+ it('includes packageConfig.cacheTTL when cacheTTL is provided', () => {
+ const result = App.esm('@acme/tools@^1.0.0', { cacheTTL: 60000 });
+ expect(result.packageConfig?.cacheTTL).toBe(60000);
+ });
+
+ it('includes packageConfig.importMap when importMap is provided', () => {
+ const result = App.esm('@acme/tools@^1.0.0', {
+ importMap: { lodash: 'https://esm.sh/lodash@4' },
+ });
+ expect(result.packageConfig?.importMap).toEqual({
+ lodash: 'https://esm.sh/lodash@4',
+ });
+ });
+
+ it('combines multiple packageConfig fields', () => {
+ const opts: EsmAppOptions = {
+ namespace: 'acme',
+ loader: { url: 'http://custom.cdn' },
+ autoUpdate: { enabled: true },
+ cacheTTL: 30000,
+ importMap: { react: 'https://esm.sh/react@18' },
+ };
+ const result = App.esm('@acme/tools@^1.0.0', opts);
+ expect(result.packageConfig).toEqual({
+ loader: { url: 'http://custom.cdn' },
+ autoUpdate: { enabled: true },
+ cacheTTL: 30000,
+ importMap: { react: 'https://esm.sh/react@18' },
+ });
+ });
+
+ it('passes filter config through', () => {
+ const result = App.esm('@acme/tools@^1.0.0', {
+ filter: { default: 'exclude', include: { tools: ['echo'] } },
+ });
+ expect(result.filter).toEqual({
+ default: 'exclude',
+ include: { tools: ['echo'] },
+ });
+ });
+
+ it('throws on empty specifier', () => {
+ expect(() => App.esm('')).toThrow('Package specifier cannot be empty');
+ });
+
+ it('throws on invalid specifier', () => {
+ expect(() => App.esm('!!!invalid!!!')).toThrow('Invalid package specifier');
+ });
+});
+
+describe('App.remote()', () => {
+ it('creates RemoteAppMetadata with urlType url', () => {
+ const result = App.remote('https://api.example.com/mcp');
+ expect(result.urlType).toBe('url');
+ expect(result.url).toBe('https://api.example.com/mcp');
+ });
+
+ it('derives name from hostname', () => {
+ const result = App.remote('https://api.example.com/mcp');
+ expect(result.name).toBe('api');
+ });
+
+ it('allows overriding name', () => {
+ const result = App.remote('https://api.example.com/mcp', { name: 'my-api' });
+ expect(result.name).toBe('my-api');
+ });
+
+ it('sets namespace when provided', () => {
+ const result = App.remote('https://api.example.com/mcp', { namespace: 'api' });
+ expect(result.namespace).toBe('api');
+ });
+
+ it('defaults standalone to false', () => {
+ const result = App.remote('https://api.example.com/mcp');
+ expect(result.standalone).toBe(false);
+ });
+
+ it('passes transport options through', () => {
+ const result = App.remote('https://api.example.com/mcp', {
+ transportOptions: { timeout: 60000, retryAttempts: 3 },
+ });
+ expect(result.transportOptions).toEqual({ timeout: 60000, retryAttempts: 3 });
+ });
+
+ it('passes remoteAuth through', () => {
+ const result = App.remote('https://api.example.com/mcp', {
+ remoteAuth: { mode: 'static', credentials: { type: 'bearer', value: 'token123' } },
+ });
+ expect(result.remoteAuth).toEqual({
+ mode: 'static',
+ credentials: { type: 'bearer', value: 'token123' },
+ });
+ });
+
+ it('passes filter config through', () => {
+ const result = App.remote('https://api.example.com/mcp', {
+ filter: { exclude: { tools: ['dangerous-*'] } },
+ });
+ expect(result.filter).toEqual({ exclude: { tools: ['dangerous-*'] } });
+ });
+
+ it('rejects invalid URLs without a valid scheme', () => {
+ expect(() => App.remote('not-a-url')).toThrow('URI must have a valid scheme');
+ });
+});
diff --git a/libs/sdk/src/esm-loader/__tests__/package-specifier.spec.ts b/libs/sdk/src/esm-loader/__tests__/package-specifier.spec.ts
new file mode 100644
index 000000000..5d9837eb4
--- /dev/null
+++ b/libs/sdk/src/esm-loader/__tests__/package-specifier.spec.ts
@@ -0,0 +1,115 @@
+import { parsePackageSpecifier, buildEsmShUrl, isPackageSpecifier, ESM_SH_BASE_URL } from '../package-specifier';
+
+describe('parsePackageSpecifier', () => {
+ it('should parse a scoped package with version range', () => {
+ const result = parsePackageSpecifier('@acme/mcp-tools@^1.0.0');
+ expect(result).toEqual({
+ scope: '@acme',
+ name: 'mcp-tools',
+ fullName: '@acme/mcp-tools',
+ range: '^1.0.0',
+ raw: '@acme/mcp-tools@^1.0.0',
+ });
+ });
+
+ it('should parse an unscoped package with version', () => {
+ const result = parsePackageSpecifier('my-tools@2.3.4');
+ expect(result).toEqual({
+ scope: undefined,
+ name: 'my-tools',
+ fullName: 'my-tools',
+ range: '2.3.4',
+ raw: 'my-tools@2.3.4',
+ });
+ });
+
+ it('should default range to latest when not specified', () => {
+ const result = parsePackageSpecifier('my-tools');
+ expect(result.range).toBe('latest');
+ expect(result.fullName).toBe('my-tools');
+ });
+
+ it('should default range to latest for scoped packages', () => {
+ const result = parsePackageSpecifier('@acme/tools');
+ expect(result.range).toBe('latest');
+ expect(result.scope).toBe('@acme');
+ expect(result.fullName).toBe('@acme/tools');
+ });
+
+ it('should handle tilde ranges', () => {
+ const result = parsePackageSpecifier('pkg@~1.2.3');
+ expect(result.range).toBe('~1.2.3');
+ });
+
+ it('should handle exact versions', () => {
+ const result = parsePackageSpecifier('pkg@1.2.3');
+ expect(result.range).toBe('1.2.3');
+ });
+
+ it('should handle dist tags', () => {
+ const result = parsePackageSpecifier('pkg@latest');
+ expect(result.range).toBe('latest');
+ });
+
+ it('should handle next tag', () => {
+ const result = parsePackageSpecifier('pkg@next');
+ expect(result.range).toBe('next');
+ });
+
+ it('should trim whitespace', () => {
+ const result = parsePackageSpecifier(' pkg@1.0.0 ');
+ expect(result.name).toBe('pkg');
+ expect(result.range).toBe('1.0.0');
+ });
+
+ it('should throw for empty string', () => {
+ expect(() => parsePackageSpecifier('')).toThrow('cannot be empty');
+ });
+
+ it('should throw for whitespace-only string', () => {
+ expect(() => parsePackageSpecifier(' ')).toThrow('cannot be empty');
+ });
+
+ it('should throw for invalid specifiers', () => {
+ expect(() => parsePackageSpecifier('INVALID/BAD NAME')).toThrow('Invalid package specifier');
+ });
+});
+
+describe('isPackageSpecifier', () => {
+ it('should return true for valid package specifiers', () => {
+ expect(isPackageSpecifier('@acme/tools@^1.0.0')).toBe(true);
+ expect(isPackageSpecifier('my-tools')).toBe(true);
+ expect(isPackageSpecifier('my-tools@latest')).toBe(true);
+ });
+
+ it('should return false for non-package strings', () => {
+ expect(isPackageSpecifier('')).toBe(false);
+ expect(isPackageSpecifier('INVALID/BAD NAME')).toBe(false);
+ });
+});
+
+describe('buildEsmShUrl', () => {
+ it('should build a bundled URL with resolved version', () => {
+ const spec = parsePackageSpecifier('@acme/tools@^1.0.0');
+ const url = buildEsmShUrl(spec, '1.2.3');
+ expect(url).toBe('https://esm.sh/@acme/tools@1.2.3?bundle');
+ });
+
+ it('should use range when no resolved version provided', () => {
+ const spec = parsePackageSpecifier('my-tools@^2.0.0');
+ const url = buildEsmShUrl(spec);
+ expect(url).toBe('https://esm.sh/my-tools@^2.0.0?bundle');
+ });
+
+ it('should support custom base URL', () => {
+ const spec = parsePackageSpecifier('pkg@1.0.0');
+ const url = buildEsmShUrl(spec, '1.0.0', { baseUrl: 'https://custom-cdn.com' });
+ expect(url).toBe('https://custom-cdn.com/pkg@1.0.0?bundle');
+ });
+
+ it('should omit bundle param when bundle=false', () => {
+ const spec = parsePackageSpecifier('pkg@1.0.0');
+ const url = buildEsmShUrl(spec, '1.0.0', { bundle: false });
+ expect(url).toBe(`${ESM_SH_BASE_URL}/pkg@1.0.0`);
+ });
+});
diff --git a/libs/sdk/src/esm-loader/__tests__/semver.utils.spec.ts b/libs/sdk/src/esm-loader/__tests__/semver.utils.spec.ts
new file mode 100644
index 000000000..ee78c73a2
--- /dev/null
+++ b/libs/sdk/src/esm-loader/__tests__/semver.utils.spec.ts
@@ -0,0 +1,114 @@
+import {
+ satisfiesRange,
+ maxSatisfying,
+ isValidRange,
+ isValidVersion,
+ compareVersions,
+ isNewerVersion,
+} from '../semver.utils';
+
+describe('satisfiesRange', () => {
+ it('should return true for matching caret range', () => {
+ expect(satisfiesRange('1.2.3', '^1.0.0')).toBe(true);
+ expect(satisfiesRange('1.9.9', '^1.0.0')).toBe(true);
+ });
+
+ it('should return false for non-matching caret range', () => {
+ expect(satisfiesRange('2.0.0', '^1.0.0')).toBe(false);
+ expect(satisfiesRange('0.9.9', '^1.0.0')).toBe(false);
+ });
+
+ it('should return true for latest', () => {
+ expect(satisfiesRange('99.99.99', 'latest')).toBe(true);
+ });
+
+ it('should return true for non-latest dist-tags', () => {
+ expect(satisfiesRange('2.0.0', 'next')).toBe(true);
+ expect(satisfiesRange('1.0.0-beta.1', 'beta')).toBe(true);
+ });
+
+ it('should return true for wildcard', () => {
+ expect(satisfiesRange('1.0.0', '*')).toBe(true);
+ });
+
+ it('should handle tilde ranges', () => {
+ expect(satisfiesRange('1.2.5', '~1.2.3')).toBe(true);
+ expect(satisfiesRange('1.3.0', '~1.2.3')).toBe(false);
+ });
+
+ it('should handle exact versions', () => {
+ expect(satisfiesRange('1.2.3', '1.2.3')).toBe(true);
+ expect(satisfiesRange('1.2.4', '1.2.3')).toBe(false);
+ });
+});
+
+describe('maxSatisfying', () => {
+ const versions = ['1.0.0', '1.1.0', '1.2.0', '2.0.0', '2.1.0'];
+
+ it('should find the highest matching version', () => {
+ expect(maxSatisfying(versions, '^1.0.0')).toBe('1.2.0');
+ });
+
+ it('should return the latest for latest tag', () => {
+ expect(maxSatisfying(versions, 'latest')).toBe('2.1.0');
+ });
+
+ it('should return null when nothing matches', () => {
+ expect(maxSatisfying(versions, '^3.0.0')).toBeNull();
+ });
+
+ it('should return the latest for non-latest dist-tags', () => {
+ expect(maxSatisfying(versions, 'next')).toBe('2.1.0');
+ });
+
+ it('should return null for empty array', () => {
+ expect(maxSatisfying([], '^1.0.0')).toBeNull();
+ });
+});
+
+describe('isValidRange', () => {
+ it('should return true for valid ranges', () => {
+ expect(isValidRange('^1.0.0')).toBe(true);
+ expect(isValidRange('~1.2.3')).toBe(true);
+ expect(isValidRange('>=1.0.0')).toBe(true);
+ expect(isValidRange('latest')).toBe(true);
+ expect(isValidRange('*')).toBe(true);
+ });
+
+ it('should return true for non-latest dist-tags', () => {
+ expect(isValidRange('next')).toBe(true);
+ expect(isValidRange('beta')).toBe(true);
+ });
+
+ it('should return false for invalid ranges', () => {
+ expect(isValidRange('not-a-range')).toBe(false);
+ });
+});
+
+describe('isValidVersion', () => {
+ it('should return true for valid semver', () => {
+ expect(isValidVersion('1.2.3')).toBe(true);
+ expect(isValidVersion('0.0.1')).toBe(true);
+ });
+
+ it('should return false for invalid semver', () => {
+ expect(isValidVersion('not-a-version')).toBe(false);
+ expect(isValidVersion('1.2')).toBe(false);
+ });
+});
+
+describe('compareVersions', () => {
+ it('should compare versions correctly', () => {
+ expect(compareVersions('1.0.0', '2.0.0')).toBeLessThan(0);
+ expect(compareVersions('2.0.0', '1.0.0')).toBeGreaterThan(0);
+ expect(compareVersions('1.0.0', '1.0.0')).toBe(0);
+ });
+});
+
+describe('isNewerVersion', () => {
+ it('should detect newer versions', () => {
+ expect(isNewerVersion('2.0.0', '1.0.0')).toBe(true);
+ expect(isNewerVersion('1.0.0', '2.0.0')).toBe(false);
+ expect(isNewerVersion('1.0.0', '1.0.0')).toBe(false);
+ });
+});
diff --git a/libs/sdk/src/esm-loader/__tests__/version-poller.spec.ts b/libs/sdk/src/esm-loader/__tests__/version-poller.spec.ts
new file mode 100644
index 000000000..d7d69d24f
--- /dev/null
+++ b/libs/sdk/src/esm-loader/__tests__/version-poller.spec.ts
@@ -0,0 +1,122 @@
+import { VersionPoller } from '../version-poller';
+import { VersionResolver } from '../version-resolver';
+import type { ParsedPackageSpecifier } from '../package-specifier';
+
+// Mock VersionResolver
+jest.mock('../version-resolver');
+
+describe('VersionPoller', () => {
+ let poller: VersionPoller;
+ let onNewVersion: jest.Mock;
+ let mockResolve: jest.Mock;
+
+ const specifier: ParsedPackageSpecifier = {
+ scope: '@acme',
+ name: 'tools',
+ fullName: '@acme/tools',
+ range: '^1.0.0',
+ raw: '@acme/tools@^1.0.0',
+ };
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ onNewVersion = jest.fn().mockResolvedValue(undefined);
+ mockResolve = jest.fn();
+
+ (VersionResolver as jest.MockedClass).mockImplementation(
+ () =>
+ ({
+ resolve: mockResolve,
+ }) as unknown as VersionResolver,
+ );
+
+ poller = new VersionPoller({
+ intervalMs: 1000,
+ onNewVersion,
+ });
+ });
+
+ afterEach(() => {
+ poller.stop();
+ jest.useRealTimers();
+ });
+
+ it('should track added packages', () => {
+ poller.addPackage(specifier, '1.0.0');
+ expect(poller.trackedCount).toBe(1);
+ });
+
+ it('should remove tracked packages', () => {
+ poller.addPackage(specifier, '1.0.0');
+ poller.removePackage('@acme/tools');
+ expect(poller.trackedCount).toBe(0);
+ });
+
+ it('should not start if no packages tracked', () => {
+ poller.start();
+ expect(poller.isRunning()).toBe(false);
+ });
+
+ it('should start and stop', () => {
+ poller.addPackage(specifier, '1.0.0');
+ poller.start();
+ expect(poller.isRunning()).toBe(true);
+
+ poller.stop();
+ expect(poller.isRunning()).toBe(false);
+ });
+
+ it('should not start twice', () => {
+ poller.addPackage(specifier, '1.0.0');
+ poller.start();
+ poller.start(); // Should be a no-op
+ expect(poller.isRunning()).toBe(true);
+ });
+
+ it('should checkNow and return results', async () => {
+ mockResolve.mockResolvedValue({
+ resolvedVersion: '1.0.0',
+ availableVersions: ['1.0.0'],
+ });
+
+ poller.addPackage(specifier, '1.0.0');
+ const results = await poller.checkNow();
+
+ expect(results).toHaveLength(1);
+ expect(results[0].hasUpdate).toBe(false);
+ expect(results[0].currentVersion).toBe('1.0.0');
+ expect(results[0].latestVersion).toBe('1.0.0');
+ });
+
+ it('should detect newer versions', async () => {
+ mockResolve.mockResolvedValue({
+ resolvedVersion: '1.1.0',
+ availableVersions: ['1.0.0', '1.1.0'],
+ });
+
+ poller.addPackage(specifier, '1.0.0');
+ const results = await poller.checkNow();
+
+ expect(results).toHaveLength(1);
+ expect(results[0].hasUpdate).toBe(true);
+ expect(results[0].latestVersion).toBe('1.1.0');
+ });
+
+ it('should handle version check errors gracefully', async () => {
+ mockResolve.mockRejectedValue(new Error('Network error'));
+
+ poller.addPackage(specifier, '1.0.0');
+ const results = await poller.checkNow();
+
+ // Should not throw, just return empty results for failed checks
+ expect(results).toHaveLength(0);
+ });
+
+ it('should update current version after successful callback', () => {
+ poller.addPackage(specifier, '1.0.0');
+ poller.updateCurrentVersion('@acme/tools', '1.1.0');
+ // Internal state updated - no public accessor for current version per package
+ // but the next check should compare against 1.1.0
+ expect(poller.trackedCount).toBe(1);
+ });
+});
diff --git a/libs/sdk/src/esm-loader/__tests__/version-resolver.spec.ts b/libs/sdk/src/esm-loader/__tests__/version-resolver.spec.ts
new file mode 100644
index 000000000..288663809
--- /dev/null
+++ b/libs/sdk/src/esm-loader/__tests__/version-resolver.spec.ts
@@ -0,0 +1,227 @@
+import { VersionResolver } from '../version-resolver';
+
+// We mock global fetch
+const mockFetch = jest.fn();
+(globalThis as unknown as { fetch: jest.Mock }).fetch = mockFetch;
+
+describe('VersionResolver', () => {
+ afterEach(() => {
+ mockFetch.mockReset();
+ });
+
+ function makeRegistryResponse(overrides?: Record) {
+ return {
+ name: '@acme/tools',
+ 'dist-tags': { latest: '1.2.0', next: '2.0.0-beta.1' },
+ versions: {
+ '1.0.0': { version: '1.0.0' },
+ '1.1.0': { version: '1.1.0' },
+ '1.2.0': { version: '1.2.0' },
+ '2.0.0-beta.1': { version: '2.0.0-beta.1' },
+ },
+ time: {
+ '1.0.0': '2025-01-01T00:00:00.000Z',
+ '1.2.0': '2025-06-01T00:00:00.000Z',
+ },
+ ...overrides,
+ };
+ }
+
+ function makeSpecifier(range = '^1.0.0', fullName = '@acme/tools') {
+ return {
+ scope: fullName.startsWith('@') ? fullName.split('/')[0] : undefined,
+ name: fullName.includes('/') ? fullName.split('/')[1] : fullName,
+ fullName,
+ range,
+ raw: `${fullName}@${range}`,
+ };
+ }
+
+ describe('resolve()', () => {
+ it('resolves latest tag via dist-tags', async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => makeRegistryResponse(),
+ });
+
+ const resolver = new VersionResolver();
+ const result = await resolver.resolve(makeSpecifier('latest'));
+
+ expect(result.resolvedVersion).toBe('1.2.0');
+ expect(result.availableVersions).toContain('1.0.0');
+ expect(result.availableVersions).toContain('1.2.0');
+ });
+
+ it('resolves next tag via dist-tags', async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => makeRegistryResponse(),
+ });
+
+ const resolver = new VersionResolver();
+ const result = await resolver.resolve(makeSpecifier('next'));
+
+ expect(result.resolvedVersion).toBe('2.0.0-beta.1');
+ });
+
+ it('resolves semver range via maxSatisfying', async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => makeRegistryResponse(),
+ });
+
+ const resolver = new VersionResolver();
+ const result = await resolver.resolve(makeSpecifier('^1.0.0'));
+
+ expect(result.resolvedVersion).toBe('1.2.0');
+ expect(result.publishedAt).toBe('2025-06-01T00:00:00.000Z');
+ });
+
+ it('throws on 404 response', async () => {
+ mockFetch.mockResolvedValue({
+ ok: false,
+ status: 404,
+ statusText: 'Not Found',
+ });
+
+ const resolver = new VersionResolver();
+ await expect(resolver.resolve(makeSpecifier('latest'))).rejects.toThrow(
+ 'Package "@acme/tools" not found in registry',
+ );
+ });
+
+ it('throws on non-ok response (e.g., 401)', async () => {
+ mockFetch.mockResolvedValue({
+ ok: false,
+ status: 401,
+ statusText: 'Unauthorized',
+ });
+
+ const resolver = new VersionResolver();
+ await expect(resolver.resolve(makeSpecifier('latest'))).rejects.toThrow('Registry returned 401');
+ });
+
+ it('throws on fetch timeout (AbortError)', async () => {
+ mockFetch.mockRejectedValue(Object.assign(new Error('aborted'), { name: 'AbortError' }));
+
+ const resolver = new VersionResolver({ timeout: 100 });
+ await expect(resolver.resolve(makeSpecifier('latest'))).rejects.toThrow('Timeout');
+ });
+
+ it('throws on network error', async () => {
+ mockFetch.mockRejectedValue(new Error('network failure'));
+
+ const resolver = new VersionResolver();
+ await expect(resolver.resolve(makeSpecifier('latest'))).rejects.toThrow('Failed to fetch package info');
+ });
+
+ it('sends auth header when token provided', async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => makeRegistryResponse(),
+ });
+
+ const resolver = new VersionResolver({
+ registryAuth: { token: 'secret-token' },
+ });
+ await resolver.resolve(makeSpecifier('latest'));
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({
+ headers: expect.objectContaining({
+ Authorization: 'Bearer secret-token',
+ }),
+ }),
+ );
+ });
+
+ it('encodes scoped package names correctly', async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => makeRegistryResponse(),
+ });
+
+ const resolver = new VersionResolver();
+ await resolver.resolve(makeSpecifier('latest', '@acme/tools'));
+
+ const url = mockFetch.mock.calls[0][0] as string;
+ expect(url).toContain('@acme%2ftools');
+ });
+
+ it('uses custom registry URL', async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => makeRegistryResponse(),
+ });
+
+ const resolver = new VersionResolver({
+ registryAuth: { registryUrl: 'https://npm.example.com' },
+ });
+ await resolver.resolve(makeSpecifier('latest'));
+
+ const url = mockFetch.mock.calls[0][0] as string;
+ expect(url).toStartWith('https://npm.example.com/');
+ });
+
+ it('throws when no versions exist', async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => makeRegistryResponse({ versions: {} }),
+ });
+
+ const resolver = new VersionResolver();
+ await expect(resolver.resolve(makeSpecifier('latest'))).rejects.toThrow('No versions found');
+ });
+
+ it('throws when no version satisfies range', async () => {
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () =>
+ makeRegistryResponse({
+ 'dist-tags': { latest: '1.2.0' },
+ versions: { '1.0.0': {}, '1.1.0': {}, '1.2.0': {} },
+ }),
+ });
+
+ const resolver = new VersionResolver();
+ await expect(resolver.resolve(makeSpecifier('^5.0.0'))).rejects.toThrow(
+ 'No version of "@acme/tools" satisfies range "^5.0.0"',
+ );
+ });
+
+ it('uses default timeout of 15000ms', () => {
+ const resolver = new VersionResolver();
+ // Just verify construction works - timeout is internal
+ expect(resolver).toBeInstanceOf(VersionResolver);
+ });
+ });
+});
+
+// Custom matcher for string prefix
+expect.extend({
+ toStartWith(received: string, prefix: string) {
+ const pass = received.startsWith(prefix);
+ return {
+ pass,
+ message: () => `expected "${received}" to start with "${prefix}"`,
+ };
+ },
+});
+
+declare global {
+ // eslint-disable-next-line @typescript-eslint/no-namespace
+ namespace jest {
+ interface Matchers {
+ toStartWith(prefix: string): R;
+ }
+ }
+}
diff --git a/libs/sdk/src/esm-loader/app-helpers.ts b/libs/sdk/src/esm-loader/app-helpers.ts
new file mode 100644
index 000000000..4bb8682e6
--- /dev/null
+++ b/libs/sdk/src/esm-loader/app-helpers.ts
@@ -0,0 +1,124 @@
+/**
+ * @file app-helpers.ts
+ * @description Unified API for declaring external apps in @FrontMcp({ apps: [...] }).
+ *
+ * Provides `app.esm()` for loading @App classes from npm packages and
+ * `app.remote()` for connecting to external MCP servers.
+ *
+ * @example
+ * ```ts
+ * import { FrontMcp, app } from '@frontmcp/sdk';
+ *
+ * @FrontMcp({
+ * info: { name: 'Gateway', version: '1.0.0' },
+ * apps: [
+ * LocalApp,
+ * app.esm('@acme/tools@^1.0.0', { namespace: 'acme' }),
+ * app.remote('https://api.example.com/mcp', { namespace: 'api' }),
+ * ],
+ * })
+ * export default class Server {}
+ * ```
+ */
+
+import { parsePackageSpecifier } from './package-specifier';
+import type { RemoteAppMetadata } from '../common/metadata/app.metadata';
+import type { EsmAppOptions, RemoteUrlAppOptions } from '../common/metadata/app.metadata';
+
+export type { EsmAppOptions, RemoteUrlAppOptions } from '../common/metadata/app.metadata';
+export type { PackageLoader } from '../common/metadata/app.metadata';
+export type { AppFilterConfig, PrimitiveFilterMap } from '../common/metadata/app-filter.metadata';
+
+// ═══════════════════════════════════════════════════════════════════
+// APP NAMESPACE
+// ═══════════════════════════════════════════════════════════════════
+
+/**
+ * Unified namespace for declaring external apps.
+ *
+ * @example
+ * ```ts
+ * // Load @App class from npm
+ * app.esm('@acme/tools@^1.0.0', { namespace: 'acme' })
+ *
+ * // Connect to external MCP server
+ * app.remote('https://api.example.com/mcp', { namespace: 'api' })
+ * ```
+ */
+export const app = {
+ /**
+ * Declare an npm package to load at runtime.
+ * The package should export an `@App`-decorated class as its default export.
+ *
+ * @param specifier - npm package specifier (e.g., '@acme/tools@^1.0.0')
+ * @param options - Optional per-app overrides
+ * @returns A `RemoteAppMetadata` ready for use in `@FrontMcp({ apps: [...] })`
+ */
+ esm(specifier: string, options?: EsmAppOptions): RemoteAppMetadata {
+ const parsed = parsePackageSpecifier(specifier);
+
+ const packageConfig: RemoteAppMetadata['packageConfig'] = {};
+ let hasPackageConfig = false;
+
+ if (options?.loader) {
+ packageConfig.loader = options.loader;
+ hasPackageConfig = true;
+ }
+ if (options?.autoUpdate) {
+ packageConfig.autoUpdate = options.autoUpdate;
+ hasPackageConfig = true;
+ }
+ if (options?.cacheTTL !== undefined) {
+ packageConfig.cacheTTL = options.cacheTTL;
+ hasPackageConfig = true;
+ }
+ if (options?.importMap) {
+ packageConfig.importMap = options.importMap;
+ hasPackageConfig = true;
+ }
+
+ return {
+ name: options?.name ?? parsed.fullName,
+ urlType: 'esm',
+ url: specifier,
+ namespace: options?.namespace,
+ description: options?.description,
+ standalone: options?.standalone ?? false,
+ filter: options?.filter,
+ ...(hasPackageConfig ? { packageConfig } : {}),
+ };
+ },
+
+ /**
+ * Connect to an external MCP server via HTTP.
+ * The remote server's tools, resources, and prompts are proxied through your gateway.
+ *
+ * @param url - MCP server endpoint URL (e.g., 'https://api.example.com/mcp')
+ * @param options - Optional per-app overrides
+ * @returns A `RemoteAppMetadata` ready for use in `@FrontMcp({ apps: [...] })`
+ */
+ remote(url: string, options?: RemoteUrlAppOptions): RemoteAppMetadata {
+ // Derive name from URL hostname if not provided
+ let derivedName: string;
+ try {
+ const parsed = new URL(url);
+ derivedName = parsed.hostname.split('.')[0];
+ } catch {
+ derivedName = url;
+ }
+
+ return {
+ name: options?.name ?? derivedName,
+ urlType: 'url',
+ url,
+ namespace: options?.namespace,
+ description: options?.description,
+ standalone: options?.standalone ?? false,
+ transportOptions: options?.transportOptions,
+ remoteAuth: options?.remoteAuth,
+ refreshInterval: options?.refreshInterval,
+ cacheTTL: options?.cacheTTL,
+ filter: options?.filter,
+ };
+ },
+} as const;
diff --git a/libs/sdk/src/esm-loader/esm-auth.types.ts b/libs/sdk/src/esm-loader/esm-auth.types.ts
new file mode 100644
index 000000000..63e5db6ac
--- /dev/null
+++ b/libs/sdk/src/esm-loader/esm-auth.types.ts
@@ -0,0 +1,84 @@
+/**
+ * @file esm-auth.types.ts
+ * @description Authentication configuration for private npm registries and esm.sh CDN.
+ */
+
+import { z } from 'zod';
+
+/**
+ * Authentication configuration for accessing private npm registries.
+ * Used both for version resolution (npm registry API) and ESM bundle fetching.
+ */
+export interface EsmRegistryAuth {
+ /**
+ * Custom registry URL (e.g., 'https://npm.pkg.github.com').
+ * If not provided, defaults to 'https://registry.npmjs.org'.
+ */
+ registryUrl?: string;
+
+ /**
+ * Bearer token for authentication.
+ * Mutually exclusive with `tokenEnvVar`.
+ */
+ token?: string;
+
+ /**
+ * Environment variable name containing the bearer token.
+ * Resolved at runtime for security (avoids storing tokens in config).
+ * Mutually exclusive with `token`.
+ */
+ tokenEnvVar?: string;
+}
+
+/**
+ * Zod schema for EsmRegistryAuth validation.
+ */
+export const esmRegistryAuthSchema = z
+ .object({
+ registryUrl: z.string().url().optional(),
+ token: z.string().min(1).optional(),
+ tokenEnvVar: z.string().min(1).optional(),
+ })
+ .refine((data) => !(data.token && data.tokenEnvVar), {
+ message: 'Cannot specify both "token" and "tokenEnvVar" — use one or the other',
+ });
+
+/**
+ * Default npm registry URL.
+ */
+export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org';
+
+/**
+ * Resolve the bearer token from an EsmRegistryAuth configuration.
+ * Handles both direct tokens and environment variable references.
+ *
+ * @param auth - Registry auth configuration (optional)
+ * @returns The resolved token, or undefined if no auth configured
+ * @throws Error if tokenEnvVar is specified but the environment variable is not set
+ */
+export function resolveRegistryToken(auth?: EsmRegistryAuth): string | undefined {
+ if (!auth) return undefined;
+
+ if (auth.token) {
+ return auth.token;
+ }
+
+ if (auth.tokenEnvVar) {
+ const token = process.env[auth.tokenEnvVar];
+ if (!token) {
+ throw new Error(
+ `Environment variable "${auth.tokenEnvVar}" is not set. ` + 'Required for private npm registry authentication.',
+ );
+ }
+ return token;
+ }
+
+ return undefined;
+}
+
+/**
+ * Get the registry URL from auth configuration, falling back to default.
+ */
+export function getRegistryUrl(auth?: EsmRegistryAuth): string {
+ return auth?.registryUrl ?? DEFAULT_NPM_REGISTRY;
+}
diff --git a/libs/sdk/src/esm-loader/esm-cache.ts b/libs/sdk/src/esm-loader/esm-cache.ts
new file mode 100644
index 000000000..61ee5e755
--- /dev/null
+++ b/libs/sdk/src/esm-loader/esm-cache.ts
@@ -0,0 +1,402 @@
+/**
+ * @file esm-cache.ts
+ * @description Cache manager for downloaded ESM bundles.
+ *
+ * Supports two modes:
+ * - **Node.js**: File-based cache with `.mjs`/`.cjs` bundles and metadata JSON
+ * - **Browser**: In-memory cache (no file system access)
+ *
+ * The mode is auto-detected. In-memory cache is always used as a fast first-level cache,
+ * with disk persistence as a second level in Node.js environments.
+ */
+
+import { sha256Hex, isValidMcpUri } from '@frontmcp/utils';
+
+/**
+ * Detect if we're running in a browser environment.
+ */
+function isBrowserEnv(): boolean {
+ return (
+ typeof window !== 'undefined' || (typeof globalThis !== 'undefined' && typeof globalThis.document !== 'undefined')
+ );
+}
+
+/**
+ * Detect whether a bundle already contains ESM syntax and should stay as .mjs.
+ */
+function isEsmSource(content: string): boolean {
+ return (
+ /\bexport\s+(default\b|{|\*|async\b|const\b|class\b|function\b|let\b|var\b)/.test(content) ||
+ /\bimport\s+/.test(content)
+ );
+}
+
+/**
+ * Wrap CJS bundle content so Node and Jest can both load it through import().
+ * The bridge keeps all CJS state local, then flattens `module.exports.default`
+ * into the final CommonJS export. Native import() will then expose a single
+ * `default` manifest namespace instead of a nested default object.
+ */
+function wrapCjsForImport(content: string): string {
+ return [
+ 'const __frontmcpModule = { exports: {} };',
+ 'const __frontmcpExports = __frontmcpModule.exports;',
+ '((module, exports) => {',
+ content,
+ '})(__frontmcpModule, __frontmcpExports);',
+ 'module.exports =',
+ " __frontmcpModule.exports && typeof __frontmcpModule.exports === 'object' && 'default' in __frontmcpModule.exports",
+ ' ? __frontmcpModule.exports.default',
+ ' : __frontmcpModule.exports;',
+ ].join('\n');
+}
+
+/**
+ * Build the on-disk bundle artifact for a source bundle.
+ * ESM stays as `.mjs`; CJS is bridged into a `.cjs` module for import().
+ */
+function toCachedBundle(content: string): { fileName: string; content: string } {
+ if (isEsmSource(content)) {
+ return { fileName: 'bundle.mjs', content };
+ }
+
+ return {
+ fileName: 'bundle.cjs',
+ content: wrapCjsForImport(content),
+ };
+}
+
+/**
+ * Metadata stored alongside each cached ESM bundle.
+ */
+export interface EsmCacheEntry {
+ /** Full package URL used to fetch */
+ packageUrl: string;
+ /** Full package name (e.g., '@acme/mcp-tools') */
+ packageName: string;
+ /** Concrete resolved version */
+ resolvedVersion: string;
+ /** Timestamp when cached */
+ cachedAt: number;
+ /** Path to the cached bundle file (.mjs for ESM, .cjs for bridged CJS; empty in browser) */
+ bundlePath: string;
+ /** HTTP ETag for conditional requests */
+ etag?: string;
+ /** In-memory bundle content (used in browser mode and as fast cache in Node.js) */
+ bundleContent?: string;
+}
+
+/**
+ * Options for the ESM cache manager.
+ */
+export interface EsmCacheOptions {
+ /**
+ * Root cache directory for disk persistence. Environment-aware default:
+ * - **Browser**: empty string (disk cache disabled, in-memory only)
+ * - **Node.js server** (project has `node_modules/`): `{cwd}/node_modules/.cache/frontmcp-esm/`
+ * - **CLI binary** (no `node_modules/`): `~/.frontmcp/esm-cache/`
+ */
+ cacheDir?: string;
+ /** Maximum age of cached entries in milliseconds. Defaults to 24 hours. */
+ maxAgeMs?: number;
+}
+
+/**
+ * Determine the default cache directory based on the runtime environment.
+ */
+function getDefaultCacheDir(): string {
+ // Browser: no file-based cache
+ if (isBrowserEnv()) {
+ return '';
+ }
+
+ try {
+ const path = require('node:path');
+
+ // When running inside a project with node_modules, use project-local cache.
+ // This allows cached ESM bundles with externalized imports (@frontmcp/sdk, zod)
+ // to resolve bare specifiers through the project's node_modules tree.
+ try {
+ const nodeModulesDir = path.join(process.cwd(), 'node_modules');
+ const fs = require('node:fs');
+ if (fs.existsSync(nodeModulesDir)) {
+ return path.join(nodeModulesDir, '.cache', 'frontmcp-esm');
+ }
+ } catch {
+ // Not in a project context
+ }
+
+ // Fallback: homedir cache (CLI binary, standalone, no project context)
+ try {
+ const os = require('node:os');
+ return path.join(os.homedir(), '.frontmcp', 'esm-cache');
+ } catch {
+ // Restricted environment
+ }
+
+ return path.join(require('node:os').tmpdir?.() ?? '/tmp', '.frontmcp-esm-cache');
+ } catch {
+ // node:path not available (browser fallback)
+ return '';
+ }
+}
+
+const DEFAULT_CACHE_DIR = getDefaultCacheDir();
+
+/** Default max age: 24 hours */
+const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000;
+
+/**
+ * Cache manager for ESM bundles.
+ *
+ * **Node.js mode**: File-based disk cache with in-memory first-level cache.
+ * ```
+ * {cacheDir}/{hash}/
+ * bundle.mjs|cjs - Native ESM or bridged CJS bundle code
+ * meta.json - Cache metadata (version, timestamp, etag)
+ * ```
+ *
+ * **Browser mode**: In-memory Map only (no file system).
+ */
+export class EsmCacheManager {
+ private readonly cacheDir: string;
+ private readonly maxAgeMs: number;
+ private readonly memoryStore = new Map();
+
+ constructor(options?: EsmCacheOptions) {
+ this.cacheDir = options?.cacheDir ?? DEFAULT_CACHE_DIR;
+ this.maxAgeMs = options?.maxAgeMs ?? DEFAULT_MAX_AGE_MS;
+ }
+
+ /**
+ * Get a cached ESM bundle entry if it exists and is not expired.
+ */
+ async get(packageName: string, version: string): Promise {
+ const memKey = `${packageName}@${version}`;
+
+ // Check in-memory cache first (works in both browser and Node.js)
+ const memEntry = this.memoryStore.get(memKey);
+ if (memEntry) {
+ if (Date.now() - memEntry.cachedAt <= this.maxAgeMs) {
+ return memEntry;
+ }
+ this.memoryStore.delete(memKey);
+ }
+
+ // Disk cache (Node.js only)
+ if (!this.cacheDir) return undefined;
+
+ try {
+ const path = require('node:path');
+ const { fileExists, readJSON } = require('@frontmcp/utils');
+
+ const entryDir = this.getEntryDir(packageName, version);
+ const metaPath = path.join(entryDir, 'meta.json');
+
+ if (!(await fileExists(metaPath))) {
+ return undefined;
+ }
+
+ const meta = await (readJSON as (p: string) => Promise)(metaPath);
+ if (!meta) {
+ return undefined;
+ }
+
+ if (Date.now() - meta.cachedAt > this.maxAgeMs) {
+ return undefined;
+ }
+
+ if (!(await fileExists(meta.bundlePath))) {
+ return undefined;
+ }
+
+ // Populate memory cache from disk
+ this.memoryStore.set(memKey, meta);
+ return meta;
+ } catch {
+ return undefined;
+ }
+ }
+
+ /**
+ * Store an ESM bundle in the cache.
+ */
+ async put(
+ packageName: string,
+ version: string,
+ bundleContent: string,
+ packageUrl: string,
+ etag?: string,
+ ): Promise {
+ if (!isValidMcpUri(packageUrl)) {
+ throw new Error('URI must have a valid scheme (e.g., file://, https://, custom://)');
+ }
+
+ const memKey = `${packageName}@${version}`;
+ let bundlePath = '';
+
+ // Persist to disk (Node.js only)
+ if (this.cacheDir) {
+ try {
+ const path = require('node:path');
+ const { writeFile, ensureDir, writeJSON } = require('@frontmcp/utils');
+
+ const entryDir = this.getEntryDir(packageName, version);
+ await ensureDir(entryDir);
+
+ const cachedBundle = toCachedBundle(bundleContent);
+ bundlePath = path.join(entryDir, cachedBundle.fileName);
+ await writeFile(bundlePath, cachedBundle.content);
+
+ const diskEntry: EsmCacheEntry = {
+ packageUrl,
+ packageName,
+ resolvedVersion: version,
+ cachedAt: Date.now(),
+ bundlePath,
+ etag,
+ };
+
+ const metaPath = path.join(entryDir, 'meta.json');
+ await writeJSON(metaPath, diskEntry);
+ } catch {
+ // Disk write failed (browser or restricted env) — memory cache is sufficient
+ }
+ }
+
+ // Always store in memory (works in both browser and Node.js)
+ const entry: EsmCacheEntry = {
+ packageUrl,
+ packageName,
+ resolvedVersion: version,
+ cachedAt: Date.now(),
+ bundlePath,
+ etag,
+ bundleContent,
+ };
+
+ this.memoryStore.set(memKey, entry);
+ return entry;
+ }
+
+ /**
+ * Invalidate all cached versions for a package.
+ */
+ async invalidate(packageName: string): Promise {
+ // Clear from memory
+ for (const [key, entry] of this.memoryStore) {
+ if (entry.packageName === packageName) {
+ this.memoryStore.delete(key);
+ }
+ }
+
+ // Clear from disk (Node.js only)
+ if (!this.cacheDir) return;
+
+ try {
+ const path = require('node:path');
+ const { fileExists, readJSON, rm } = require('@frontmcp/utils');
+ const { readdir } = require('@frontmcp/utils');
+
+ if (!(await fileExists(this.cacheDir))) {
+ return;
+ }
+
+ let entries: string[];
+ try {
+ entries = await readdir(this.cacheDir);
+ } catch {
+ return;
+ }
+
+ for (const dirEntry of entries) {
+ const metaPath = path.join(this.cacheDir, dirEntry, 'meta.json');
+ if (await fileExists(metaPath)) {
+ const meta = await (readJSON as (p: string) => Promise)(metaPath);
+ if (meta?.packageName === packageName) {
+ await rm(path.join(this.cacheDir, dirEntry), { recursive: true, force: true });
+ }
+ }
+ }
+ } catch {
+ // Disk operations not available
+ }
+ }
+
+ /**
+ * Remove expired cache entries.
+ */
+ async cleanup(maxAgeMs?: number): Promise {
+ const threshold = maxAgeMs ?? this.maxAgeMs;
+ const now = Date.now();
+ let removed = 0;
+
+ // Clean memory (housekeeping — doesn't count toward removed total for disk-backed entries)
+ for (const [key, entry] of this.memoryStore) {
+ if (now - entry.cachedAt > threshold) {
+ this.memoryStore.delete(key);
+ // Only count as removed if there's no disk cache (browser-only mode)
+ if (!this.cacheDir) removed++;
+ }
+ }
+
+ // Clean disk (Node.js only) — this is the authoritative count
+ if (!this.cacheDir) return removed;
+
+ try {
+ const path = require('node:path');
+ const { fileExists, readJSON, rm } = require('@frontmcp/utils');
+ const { readdir } = require('@frontmcp/utils');
+
+ if (!(await fileExists(this.cacheDir))) {
+ return removed;
+ }
+
+ let entries: string[];
+ try {
+ entries = await readdir(this.cacheDir);
+ } catch {
+ return removed;
+ }
+
+ for (const dirEntry of entries) {
+ const metaPath = path.join(this.cacheDir, dirEntry, 'meta.json');
+ if (await fileExists(metaPath)) {
+ const meta = await (readJSON as (p: string) => Promise)(metaPath);
+ if (meta && now - meta.cachedAt > threshold) {
+ await rm(path.join(this.cacheDir, dirEntry), { recursive: true, force: true });
+ removed++;
+ }
+ }
+ }
+ } catch {
+ // Disk operations not available
+ }
+
+ return removed;
+ }
+
+ /**
+ * Read the cached bundle content from a cache entry.
+ */
+ async readBundle(entry: EsmCacheEntry): Promise {
+ // In-memory content available (browser or populated cache)
+ // Apply the same disk artifact selection so memory/disk contents stay identical.
+ if (entry.bundleContent) {
+ return toCachedBundle(entry.bundleContent).content;
+ }
+
+ // Read from disk (Node.js only) — already wrapped by put()
+ const { readFile } = require('@frontmcp/utils');
+ return readFile(entry.bundlePath);
+ }
+
+ /**
+ * Get the cache directory for a specific package+version combination.
+ */
+ private getEntryDir(packageName: string, version: string): string {
+ const path = require('node:path');
+ const hash = sha256Hex(`${packageName}@${version}`);
+ return path.join(this.cacheDir, hash);
+ }
+}
diff --git a/libs/sdk/src/esm-loader/esm-manifest.ts b/libs/sdk/src/esm-loader/esm-manifest.ts
new file mode 100644
index 000000000..7940f95d7
--- /dev/null
+++ b/libs/sdk/src/esm-loader/esm-manifest.ts
@@ -0,0 +1,306 @@
+/**
+ * @file esm-manifest.ts
+ * @description Package manifest interface and normalizer for ESM-loaded packages.
+ * Defines the contract that npm packages must follow to be loadable by FrontMCP.
+ */
+
+import { z } from 'zod';
+import {
+ isDecoratedToolClass,
+ isDecoratedResourceClass,
+ isDecoratedPromptClass,
+ isDecoratedSkillClass,
+ isDecoratedJobClass,
+ isDecoratedAgentClass,
+ isDecoratedWorkflowClass,
+} from '../app/instances/esm-normalize.utils';
+
+/**
+ * The manifest that ESM packages export to declare their MCP primitives.
+ *
+ * Package authors export this as the default export of their package:
+ * ```typescript
+ * export default {
+ * name: '@acme/mcp-tools',
+ * version: '1.0.0',
+ * tools: [SearchTool, CreateIssueTool],
+ * skills: [{ name: 'triage', ... }],
+ * } satisfies FrontMcpPackageManifest;
+ * ```
+ */
+export interface FrontMcpPackageManifest {
+ /** Package name (should match npm package name) */
+ name: string;
+ /** Package version (should match npm package version) */
+ version: string;
+ /** Package description */
+ description?: string;
+ /** Tool classes or function-style tools */
+ tools?: unknown[];
+ /** Prompt classes or function-style prompts */
+ prompts?: unknown[];
+ /** Resource classes or function-style resources */
+ resources?: unknown[];
+ /** Skill definitions (can include embedded tools via the skill's tools array) */
+ skills?: unknown[];
+ /** Agent classes or function-style agents */
+ agents?: unknown[];
+ /** Job classes or function-style jobs */
+ jobs?: unknown[];
+ /** Workflow classes or function-style workflows */
+ workflows?: unknown[];
+ /** Shared providers for dependency injection */
+ providers?: unknown[];
+}
+
+/**
+ * Zod schema for basic manifest validation.
+ * We validate the shape loosely since the actual primitives are validated
+ * by their respective registries during registration.
+ */
+export const frontMcpPackageManifestSchema = z.object({
+ name: z.string().min(1),
+ version: z.string().min(1),
+ description: z.string().optional(),
+ tools: z.array(z.unknown()).optional(),
+ prompts: z.array(z.unknown()).optional(),
+ resources: z.array(z.unknown()).optional(),
+ skills: z.array(z.unknown()).optional(),
+ agents: z.array(z.unknown()).optional(),
+ jobs: z.array(z.unknown()).optional(),
+ workflows: z.array(z.unknown()).optional(),
+ providers: z.array(z.unknown()).optional(),
+});
+
+/**
+ * Primitive type keys available in a manifest.
+ */
+export const MANIFEST_PRIMITIVE_KEYS = [
+ 'tools',
+ 'prompts',
+ 'resources',
+ 'skills',
+ 'agents',
+ 'jobs',
+ 'workflows',
+ 'providers',
+] as const;
+
+export type ManifestPrimitiveKey = (typeof MANIFEST_PRIMITIVE_KEYS)[number];
+
+/**
+ * Normalize the default export of an ESM module into a FrontMcpPackageManifest.
+ *
+ * Handles five formats:
+ * 1. A plain manifest object with tools/prompts/etc arrays
+ * 2. A class decorated with @FrontMcp (detected via reflect-metadata)
+ * 3. A module with named exports (tools, prompts, etc. arrays)
+ * 4. A single default export of a decorated primitive class (@Tool, @Resource, etc.)
+ * 5. Named exports of individual decorated classes (scanned and grouped by type)
+ *
+ * @param moduleExport - The raw module export (result of dynamic import())
+ * @returns Normalized FrontMcpPackageManifest
+ * @throws Error if the export cannot be normalized
+ */
+export function normalizeEsmExport(moduleExport: unknown): FrontMcpPackageManifest {
+ if (!moduleExport || typeof moduleExport !== 'object') {
+ throw new Error('ESM module export must be an object');
+ }
+
+ const mod = moduleExport as Record;
+
+ // Case 1: Module has a `default` export
+ if ('default' in mod && mod['default']) {
+ const defaultExport = mod['default'] as Record;
+
+ // Case 1a: Default export is a plain manifest object
+ if (isManifestObject(defaultExport)) {
+ return validateManifest(defaultExport);
+ }
+
+ // Case 1b: Default export is a @FrontMcp-decorated class
+ if (isDecoratedClass(defaultExport)) {
+ return extractFromDecoratedClass(defaultExport);
+ }
+
+ // Case 1c: Default export is a single decorated primitive class (@Tool, @Resource, etc.)
+ if (isDecoratedPrimitive(defaultExport)) {
+ return collectDecoratedExports({ default: defaultExport });
+ }
+
+ // Case 1d: Default is itself a module-like object with named exports
+ if (hasManifestPrimitives(defaultExport)) {
+ return collectNamedExports(defaultExport);
+ }
+
+ // Case 1e: Default is a module-like object with decorated class exports
+ if (hasDecoratedClassExports(defaultExport)) {
+ return collectDecoratedExports(defaultExport);
+ }
+ }
+
+ // Case 2: Module itself is a manifest object
+ if (isManifestObject(mod)) {
+ return validateManifest(mod);
+ }
+
+ // Case 3: Module has named exports with primitive arrays
+ if (hasManifestPrimitives(mod)) {
+ return collectNamedExports(mod);
+ }
+
+ // Case 4: Module has named exports of individual decorated classes
+ if (hasDecoratedClassExports(mod)) {
+ return collectDecoratedExports(mod);
+ }
+
+ throw new Error(
+ 'ESM module does not export a valid FrontMcpPackageManifest. ' +
+ 'Expected a default export with { name, version, tools?, ... }, ' +
+ 'named exports of primitive arrays, or exported decorated classes (@Tool, @Resource, etc.).',
+ );
+}
+
+/**
+ * Check if an object looks like a FrontMcpPackageManifest (has name + version).
+ */
+function isManifestObject(obj: Record): boolean {
+ return typeof obj['name'] === 'string' && typeof obj['version'] === 'string';
+}
+
+/**
+ * Check if a value is a class decorated with @FrontMcp.
+ */
+function isDecoratedClass(value: unknown): boolean {
+ if (typeof value !== 'function') return false;
+ try {
+ // Check for FrontMcp decorator metadata
+ return Reflect.getMetadata?.('frontmcp:type', value) === true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Check if an object has any manifest primitive arrays.
+ */
+function hasManifestPrimitives(obj: Record): boolean {
+ return MANIFEST_PRIMITIVE_KEYS.some((key) => Array.isArray(obj[key]));
+}
+
+/**
+ * Extract manifest from a @FrontMcp decorated class.
+ */
+function extractFromDecoratedClass(cls: unknown): FrontMcpPackageManifest {
+ const config = Reflect.getMetadata?.('__frontmcp:config', cls as object) as Record | undefined;
+ if (!config) {
+ throw new Error('Decorated class does not have FrontMcp configuration metadata');
+ }
+
+ return {
+ name:
+ config['info'] &&
+ typeof config['info'] === 'object' &&
+ typeof (config['info'] as Record)['name'] === 'string'
+ ? ((config['info'] as Record)['name'] as string)
+ : 'unknown',
+ version: '0.0.0',
+ tools: Array.isArray(config['tools']) ? (config['tools'] as unknown[]) : undefined,
+ prompts: Array.isArray(config['prompts']) ? (config['prompts'] as unknown[]) : undefined,
+ resources: Array.isArray(config['resources']) ? (config['resources'] as unknown[]) : undefined,
+ skills: Array.isArray(config['skills']) ? (config['skills'] as unknown[]) : undefined,
+ agents: Array.isArray(config['agents']) ? (config['agents'] as unknown[]) : undefined,
+ jobs: Array.isArray(config['jobs']) ? (config['jobs'] as unknown[]) : undefined,
+ workflows: Array.isArray(config['workflows']) ? (config['workflows'] as unknown[]) : undefined,
+ providers: Array.isArray(config['providers']) ? (config['providers'] as unknown[]) : undefined,
+ };
+}
+
+/**
+ * Collect named exports into a manifest.
+ */
+function collectNamedExports(mod: Record): FrontMcpPackageManifest {
+ return {
+ name: typeof mod['name'] === 'string' ? mod['name'] : 'unknown',
+ version: typeof mod['version'] === 'string' ? mod['version'] : '0.0.0',
+ description: typeof mod['description'] === 'string' ? mod['description'] : undefined,
+ tools: Array.isArray(mod['tools']) ? (mod['tools'] as unknown[]) : undefined,
+ prompts: Array.isArray(mod['prompts']) ? (mod['prompts'] as unknown[]) : undefined,
+ resources: Array.isArray(mod['resources']) ? (mod['resources'] as unknown[]) : undefined,
+ skills: Array.isArray(mod['skills']) ? (mod['skills'] as unknown[]) : undefined,
+ agents: Array.isArray(mod['agents']) ? (mod['agents'] as unknown[]) : undefined,
+ jobs: Array.isArray(mod['jobs']) ? (mod['jobs'] as unknown[]) : undefined,
+ workflows: Array.isArray(mod['workflows']) ? (mod['workflows'] as unknown[]) : undefined,
+ providers: Array.isArray(mod['providers']) ? (mod['providers'] as unknown[]) : undefined,
+ };
+}
+
+/**
+ * Check if a value is a decorated primitive class (@Tool, @Resource, @Prompt, @Skill, @Job, @Agent, @Workflow).
+ */
+function isDecoratedPrimitive(value: unknown): boolean {
+ return (
+ isDecoratedToolClass(value) ||
+ isDecoratedResourceClass(value) ||
+ isDecoratedPromptClass(value) ||
+ isDecoratedSkillClass(value) ||
+ isDecoratedJobClass(value) ||
+ isDecoratedAgentClass(value) ||
+ isDecoratedWorkflowClass(value)
+ );
+}
+
+/**
+ * Check if a module has any exports that are decorated primitive classes.
+ */
+function hasDecoratedClassExports(obj: Record): boolean {
+ return Object.values(obj).some((value) => value && typeof value === 'function' && isDecoratedPrimitive(value));
+}
+
+/**
+ * Collect individually exported decorated classes into a manifest.
+ * Scans all exports (including `default`) and groups them by decorator type.
+ */
+function collectDecoratedExports(mod: Record): FrontMcpPackageManifest {
+ const tools: unknown[] = [];
+ const resources: unknown[] = [];
+ const prompts: unknown[] = [];
+ const skills: unknown[] = [];
+ const jobs: unknown[] = [];
+ const agents: unknown[] = [];
+ const workflows: unknown[] = [];
+
+ for (const value of Object.values(mod)) {
+ if (!value || typeof value !== 'function') continue;
+ if (isDecoratedToolClass(value)) tools.push(value);
+ else if (isDecoratedResourceClass(value)) resources.push(value);
+ else if (isDecoratedPromptClass(value)) prompts.push(value);
+ else if (isDecoratedSkillClass(value)) skills.push(value);
+ else if (isDecoratedJobClass(value)) jobs.push(value);
+ else if (isDecoratedAgentClass(value)) agents.push(value);
+ else if (isDecoratedWorkflowClass(value)) workflows.push(value);
+ }
+
+ return {
+ name: typeof mod['name'] === 'string' ? mod['name'] : 'unknown',
+ version: typeof mod['version'] === 'string' ? mod['version'] : '0.0.0',
+ ...(tools.length ? { tools } : {}),
+ ...(resources.length ? { resources } : {}),
+ ...(prompts.length ? { prompts } : {}),
+ ...(skills.length ? { skills } : {}),
+ ...(jobs.length ? { jobs } : {}),
+ ...(agents.length ? { agents } : {}),
+ ...(workflows.length ? { workflows } : {}),
+ };
+}
+
+/**
+ * Validate a manifest object against the Zod schema.
+ */
+function validateManifest(obj: Record): FrontMcpPackageManifest {
+ const result = frontMcpPackageManifestSchema.safeParse(obj);
+ if (!result.success) {
+ throw new Error(`Invalid FrontMcpPackageManifest: ${result.error.message}`);
+ }
+ return result.data as FrontMcpPackageManifest;
+}
diff --git a/libs/sdk/src/esm-loader/esm-module-loader.ts b/libs/sdk/src/esm-loader/esm-module-loader.ts
new file mode 100644
index 000000000..320d06c73
--- /dev/null
+++ b/libs/sdk/src/esm-loader/esm-module-loader.ts
@@ -0,0 +1,278 @@
+/**
+ * @file esm-module-loader.ts
+ * @description Core engine for dynamically loading npm packages.
+ *
+ * Supports two runtime environments:
+ * - **Node.js**: fetch → cache to disk → import() from file:// URL
+ * - **Browser**: fetch → in-memory cache → evaluate via Function constructor
+ */
+
+import type { FrontMcpLogger } from '../common';
+import type { EsmRegistryAuth } from './esm-auth.types';
+import type { EsmCacheManager, EsmCacheEntry } from './esm-cache';
+import type { ParsedPackageSpecifier } from './package-specifier';
+import type { FrontMcpPackageManifest } from './esm-manifest';
+import { buildEsmShUrl } from './package-specifier';
+import { normalizeEsmExport } from './esm-manifest';
+import { VersionResolver } from './version-resolver';
+
+/**
+ * Result of loading an ESM package.
+ */
+export interface EsmLoadResult {
+ /** Normalized package manifest with primitives */
+ manifest: FrontMcpPackageManifest;
+ /** Concrete version that was loaded */
+ resolvedVersion: string;
+ /** Whether the bundle came from cache or network */
+ source: 'cache' | 'network';
+ /** Timestamp when the load completed */
+ loadedAt: number;
+ /** The raw module export (for direct access to classes/functions) */
+ rawModule: unknown;
+}
+
+/**
+ * Options for the ESM module loader.
+ */
+export interface EsmModuleLoaderOptions {
+ /** Cache manager for storing ESM bundles */
+ cache: EsmCacheManager;
+ /** Authentication for private registries */
+ registryAuth?: EsmRegistryAuth;
+ /** Request timeout in milliseconds (default: 30000) */
+ timeout?: number;
+ /** Logger instance */
+ logger?: FrontMcpLogger;
+ /** Custom ESM CDN base URL */
+ esmBaseUrl?: string;
+}
+
+/**
+ * Core ESM module loader.
+ *
+ * Loads npm packages dynamically at runtime:
+ * 1. Resolves semver range to concrete version via npm registry
+ * 2. Checks cache for the resolved version (in-memory + disk)
+ * 3. On cache miss, fetches the bundle from esm.sh (or custom CDN)
+ * 4. Caches the bundle (disk in Node.js, in-memory in browser)
+ * 5. Evaluates the module (import() in Node.js, Function constructor in browser)
+ * 6. Normalizes the module export into a FrontMcpPackageManifest
+ */
+export class EsmModuleLoader {
+ private readonly cache: EsmCacheManager;
+ private readonly versionResolver: VersionResolver;
+ private readonly timeout: number;
+ private readonly logger?: FrontMcpLogger;
+ private readonly esmBaseUrl?: string;
+
+ constructor(options: EsmModuleLoaderOptions) {
+ this.cache = options.cache;
+ this.timeout = options.timeout ?? 30000;
+ this.logger = options.logger;
+ this.esmBaseUrl = options.esmBaseUrl;
+ this.versionResolver = new VersionResolver({
+ registryAuth: options.registryAuth,
+ timeout: options.timeout,
+ });
+ }
+
+ /**
+ * Load an npm package and return its normalized manifest.
+ *
+ * @param specifier - Parsed package specifier
+ * @returns Load result with manifest and metadata
+ */
+ async load(specifier: ParsedPackageSpecifier): Promise {
+ this.logger?.debug(`Loading ESM package: ${specifier.fullName}@${specifier.range}`);
+
+ // Step 1: Resolve semver range to concrete version
+ const resolvedVersion = await this.resolveVersion(specifier);
+ this.logger?.debug(`Resolved ${specifier.fullName}@${specifier.range} → ${resolvedVersion}`);
+
+ // Step 2: Check cache
+ const cached = await this.cache.get(specifier.fullName, resolvedVersion);
+ if (cached) {
+ this.logger?.debug(`Cache hit for ${specifier.fullName}@${resolvedVersion}`);
+ return this.loadFromCache(cached);
+ }
+
+ // Step 3: Fetch from esm.sh
+ this.logger?.debug(`Cache miss, fetching ${specifier.fullName}@${resolvedVersion} from esm.sh`);
+ return this.fetchAndCache(specifier, resolvedVersion);
+ }
+
+ /**
+ * Resolve a package specifier's range to a concrete version.
+ */
+ async resolveVersion(specifier: ParsedPackageSpecifier): Promise {
+ const result = await this.versionResolver.resolve(specifier);
+ return result.resolvedVersion;
+ }
+
+ /**
+ * Load a module from a cached bundle.
+ */
+ private async loadFromCache(entry: EsmCacheEntry): Promise {
+ let rawModule: unknown;
+
+ if (entry.bundlePath) {
+ // In Node.js, always prefer the cached file so ESM stays native and CJS uses
+ // the same disk bridge that Jest and regular import() both understand.
+ try {
+ rawModule = await this.importFromPath(entry.bundlePath);
+ } catch (error) {
+ if (!entry.bundleContent) {
+ throw error;
+ }
+
+ // If the disk artifact disappears after a warm cache hit, fall back to the
+ // in-memory copy instead of failing the whole load.
+ this.logger?.debug(
+ `importFromPath failed for ${entry.bundlePath}, falling back to in-memory bundle: ${(error as Error).message}`,
+ );
+ rawModule = await this.importBundle(entry.bundleContent);
+ }
+ } else if (entry.bundleContent) {
+ // Browser mode or in-memory-only fallback
+ rawModule = await this.importBundle(entry.bundleContent);
+ } else {
+ throw new Error(`Cached bundle for "${entry.packageName}@${entry.resolvedVersion}" has no importable content`);
+ }
+
+ const manifest = normalizeEsmExport(rawModule);
+
+ return {
+ manifest,
+ resolvedVersion: entry.resolvedVersion,
+ source: 'cache',
+ loadedAt: Date.now(),
+ rawModule,
+ };
+ }
+
+ /**
+ * Fetch ESM bundle from esm.sh, cache it, and load it.
+ */
+ private async fetchAndCache(specifier: ParsedPackageSpecifier, resolvedVersion: string): Promise {
+ const url = buildEsmShUrl(specifier, resolvedVersion, {
+ baseUrl: this.esmBaseUrl,
+ bundle: true,
+ });
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
+
+ let response: Response;
+ try {
+ response = await fetch(url, {
+ signal: controller.signal,
+ });
+ } catch (error) {
+ if ((error as Error).name === 'AbortError') {
+ throw new Error(
+ `Timeout fetching ESM bundle for "${specifier.fullName}@${resolvedVersion}" after ${this.timeout}ms`,
+ );
+ }
+ throw new Error(
+ `Failed to fetch ESM bundle for "${specifier.fullName}@${resolvedVersion}": ${(error as Error).message}`,
+ );
+ } finally {
+ clearTimeout(timeoutId);
+ }
+
+ if (!response.ok) {
+ throw new Error(
+ `esm.sh returned ${response.status} for "${specifier.fullName}@${resolvedVersion}": ${response.statusText}`,
+ );
+ }
+
+ const bundleContent = await response.text();
+ const etag = response.headers.get('etag') ?? undefined;
+
+ // Cache the bundle (disk + memory in Node.js, memory-only in browser)
+ const entry = await this.cache.put(specifier.fullName, resolvedVersion, bundleContent, url, etag);
+
+ // Import the bundle
+ const rawModule = entry.bundlePath
+ ? await this.importFromPath(entry.bundlePath)
+ : await this.importBundle(bundleContent);
+
+ const manifest = normalizeEsmExport(rawModule);
+
+ this.logger?.info(`Loaded ${specifier.fullName}@${resolvedVersion} from esm.sh`);
+
+ return {
+ manifest,
+ resolvedVersion,
+ source: 'network',
+ loadedAt: Date.now(),
+ rawModule,
+ };
+ }
+
+ /**
+ * Import a bundle from a local file path (Node.js only).
+ * Uses dynamic import with file:// URL for cross-platform compatibility.
+ */
+ private async importFromPath(filePath: string): Promise {
+ const { pathToFileURL } = await import('node:url');
+ const fileUrl = pathToFileURL(filePath).href;
+ // Append cache-busting query to avoid Node.js module cache
+ const bustUrl = `${fileUrl}?t=${Date.now()}`;
+ return import(bustUrl);
+ }
+
+ /**
+ * Import a bundle from its source text.
+ * Detects ESM vs CJS and uses the appropriate evaluation strategy.
+ */
+ private async importBundle(bundleContent: string): Promise {
+ if (this.looksLikeEsm(bundleContent)) {
+ return this.importEsmBundle(bundleContent);
+ }
+ // CJS path: Function constructor with module/exports scope
+ const module = { exports: {} as Record };
+ const fn = new Function('module', 'exports', bundleContent);
+ fn(module, module.exports);
+ return module.exports;
+ }
+
+ /**
+ * Heuristic: content uses ESM export/import syntax at line boundaries.
+ */
+ private looksLikeEsm(content: string): boolean {
+ return /^\s*(export\s|import\s)/m.test(content);
+ }
+
+ /**
+ * Import ESM content via Blob URL (browser) or temp file (Node.js).
+ */
+ private async importEsmBundle(bundleContent: string): Promise {
+ // Browser: Blob + URL.createObjectURL + dynamic import
+ if (typeof Blob !== 'undefined' && typeof URL?.createObjectURL === 'function') {
+ const blob = new Blob([bundleContent], { type: 'text/javascript' });
+ const url = URL.createObjectURL(blob);
+ try {
+ return await import(/* webpackIgnore: true */ url);
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+ }
+
+ // Node.js: write to temp .mjs file and use native import()
+ const { mkdtemp, writeFile, rm } = await import('@frontmcp/utils');
+ const nodePath = await import('node:path');
+ const nodeOs = await import('node:os');
+ const { pathToFileURL } = await import('node:url');
+
+ const tempDir = await mkdtemp(nodePath.join(nodeOs.tmpdir(), 'frontmcp-esm-'));
+ const tempPath = nodePath.join(tempDir, 'bundle.mjs');
+ try {
+ await writeFile(tempPath, bundleContent);
+ return await import(pathToFileURL(tempPath).href + `?t=${Date.now()}`);
+ } finally {
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
+ }
+ }
+}
diff --git a/libs/sdk/src/esm-loader/factories/esm-context-factories.ts b/libs/sdk/src/esm-loader/factories/esm-context-factories.ts
new file mode 100644
index 000000000..397602ff4
--- /dev/null
+++ b/libs/sdk/src/esm-loader/factories/esm-context-factories.ts
@@ -0,0 +1,98 @@
+/**
+ * @file esm-context-factories.ts
+ * @description Factory functions that create context classes for ESM-loaded primitives.
+ *
+ * Unlike remote-mcp context factories (which proxy to a remote MCP server),
+ * these factories create classes that execute code locally in-process.
+ * The ESM module's functions are closed over via closure.
+ */
+
+import { Type } from '@frontmcp/di';
+import { ToolContext, ToolInputType, ToolOutputType, ResourceContext, PromptContext } from '../../common';
+import type { CallToolResult, ReadResourceResult, GetPromptResult } from '@frontmcp/protocol';
+
+/**
+ * Handler type for ESM tool execution.
+ * This is the execute function exported by the ESM package.
+ */
+export type EsmToolExecuteHandler = (input: Record) => Promise;
+
+/**
+ * Handler type for ESM resource reading.
+ */
+export type EsmResourceReadHandler = (uri: string, params: Record) => Promise;
+
+/**
+ * Handler type for ESM prompt execution.
+ */
+export type EsmPromptExecuteHandler = (args: Record) => Promise;
+
+/**
+ * Creates an ESM tool context class that executes locally in-process.
+ *
+ * The returned class closes over the ESM module's execute function.
+ * When called, it runs the tool handler directly in the same Node.js process.
+ *
+ * @param executeFn - The tool's execute function from the ESM module
+ * @param toolName - The name of the tool (for debugging)
+ * @returns A ToolContext class that executes the ESM tool locally
+ */
+export function createEsmToolContextClass(
+ executeFn: EsmToolExecuteHandler,
+ toolName: string,
+): Type> {
+ const cls = class DynamicEsmToolContext extends ToolContext {
+ async execute(input: unknown): Promise {
+ return executeFn(input as Record);
+ }
+ };
+
+ // Set a readable name for debugging
+ Object.defineProperty(cls, 'name', { value: `EsmTool_${toolName}` });
+
+ return cls as Type>;
+}
+
+/**
+ * Creates an ESM resource context class that reads locally in-process.
+ *
+ * @param readFn - The resource's read function from the ESM module
+ * @param resourceName - The name of the resource (for debugging)
+ * @returns A ResourceContext class that reads the ESM resource locally
+ */
+export function createEsmResourceContextClass = Record>(
+ readFn: EsmResourceReadHandler,
+ resourceName: string,
+): Type> {
+ const cls = class DynamicEsmResourceContext extends ResourceContext {
+ async execute(uri: string, params: Params): Promise {
+ return readFn(uri, params);
+ }
+ };
+
+ Object.defineProperty(cls, 'name', { value: `EsmResource_${resourceName}` });
+
+ return cls as Type>;
+}
+
+/**
+ * Creates an ESM prompt context class that executes locally in-process.
+ *
+ * @param executeFn - The prompt's execute function from the ESM module
+ * @param promptName - The name of the prompt (for debugging)
+ * @returns A PromptContext class that executes the ESM prompt locally
+ */
+export function createEsmPromptContextClass(
+ executeFn: EsmPromptExecuteHandler,
+ promptName: string,
+): Type {
+ const cls = class DynamicEsmPromptContext extends PromptContext {
+ async execute(args: Record): Promise {
+ return executeFn(args);
+ }
+ };
+
+ Object.defineProperty(cls, 'name', { value: `EsmPrompt_${promptName}` });
+
+ return cls as Type;
+}
diff --git a/libs/sdk/src/esm-loader/factories/esm-instance-factories.ts b/libs/sdk/src/esm-loader/factories/esm-instance-factories.ts
new file mode 100644
index 000000000..486d5f1e1
--- /dev/null
+++ b/libs/sdk/src/esm-loader/factories/esm-instance-factories.ts
@@ -0,0 +1,76 @@
+/**
+ * @file esm-instance-factories.ts
+ * @description Factory functions that create standard instances for ESM-loaded primitives.
+ *
+ * Uses the esm-record-builders to create records, then instantiates standard
+ * ToolInstance, ResourceInstance, and PromptInstance. This allows ESM-loaded
+ * entities to use the same hook lifecycle and registry infrastructure as local entities.
+ */
+
+import type { EntryOwnerRef } from '../../common';
+import type ProviderRegistry from '../../provider/provider.registry';
+import { ToolInstance } from '../../tool/tool.instance';
+import { ResourceInstance } from '../../resource/resource.instance';
+import { PromptInstance } from '../../prompt/prompt.instance';
+import { buildEsmToolRecord, buildEsmResourceRecord, buildEsmPromptRecord } from './esm-record-builders';
+import type { EsmToolDefinition, EsmResourceDefinition, EsmPromptDefinition } from './esm-record-builders';
+
+/**
+ * Create a standard ToolInstance for an ESM-loaded tool.
+ *
+ * The resulting ToolInstance executes the tool's code locally in-process
+ * and participates fully in the hook lifecycle.
+ *
+ * @param tool - ESM tool definition with execute function
+ * @param providers - The provider registry for DI and scope access
+ * @param owner - The entry owner reference (app owner)
+ * @param namespace - Optional namespace prefix for the tool name
+ * @returns A standard ToolInstance that executes the ESM tool locally
+ */
+export function createEsmToolInstance(
+ tool: EsmToolDefinition,
+ providers: ProviderRegistry,
+ owner: EntryOwnerRef,
+ namespace?: string,
+): ToolInstance {
+ const record = buildEsmToolRecord(tool, namespace);
+ return new ToolInstance(record, providers, owner);
+}
+
+/**
+ * Create a standard ResourceInstance for an ESM-loaded resource.
+ *
+ * @param resource - ESM resource definition with read function
+ * @param providers - The provider registry for DI and scope access
+ * @param owner - The entry owner reference (app owner)
+ * @param namespace - Optional namespace prefix
+ * @returns A standard ResourceInstance that reads the ESM resource locally
+ */
+export function createEsmResourceInstance(
+ resource: EsmResourceDefinition,
+ providers: ProviderRegistry,
+ owner: EntryOwnerRef,
+ namespace?: string,
+): ResourceInstance {
+ const record = buildEsmResourceRecord(resource, namespace);
+ return new ResourceInstance(record, providers, owner);
+}
+
+/**
+ * Create a standard PromptInstance for an ESM-loaded prompt.
+ *
+ * @param prompt - ESM prompt definition with execute function
+ * @param providers - The provider registry for DI and scope access
+ * @param owner - The entry owner reference (app owner)
+ * @param namespace - Optional namespace prefix
+ * @returns A standard PromptInstance that executes the ESM prompt locally
+ */
+export function createEsmPromptInstance(
+ prompt: EsmPromptDefinition,
+ providers: ProviderRegistry,
+ owner: EntryOwnerRef,
+ namespace?: string,
+): PromptInstance {
+ const record = buildEsmPromptRecord(prompt, namespace);
+ return new PromptInstance(record, providers, owner);
+}
diff --git a/libs/sdk/src/esm-loader/factories/esm-record-builders.ts b/libs/sdk/src/esm-loader/factories/esm-record-builders.ts
new file mode 100644
index 000000000..225f38c5b
--- /dev/null
+++ b/libs/sdk/src/esm-loader/factories/esm-record-builders.ts
@@ -0,0 +1,174 @@
+/**
+ * @file esm-record-builders.ts
+ * @description Functions that build standard records for ESM-loaded primitives.
+ *
+ * Unlike remote-mcp record-builders (which proxy to a remote server), these builders
+ * create records for tools/resources/prompts that execute locally in-process.
+ */
+
+import { z } from 'zod';
+import type { Type } from '@frontmcp/di';
+import {
+ ToolKind,
+ ToolMetadata,
+ ToolContext,
+ ResourceKind,
+ ResourceMetadata,
+ ResourceEntry,
+ PromptKind,
+ PromptMetadata,
+ PromptEntry,
+} from '../../common';
+import type { ToolClassTokenRecord, ResourceClassTokenRecord, PromptClassTokenRecord } from '../../common';
+import {
+ createEsmToolContextClass,
+ createEsmResourceContextClass,
+ createEsmPromptContextClass,
+} from './esm-context-factories';
+import type { EsmToolExecuteHandler, EsmResourceReadHandler, EsmPromptExecuteHandler } from './esm-context-factories';
+
+/**
+ * Metadata for an ESM-loaded tool (simplified shape for tools loaded from packages).
+ */
+export interface EsmToolDefinition {
+ name: string;
+ description?: string;
+ inputSchema?: Record;
+ outputSchema?: unknown;
+ execute: EsmToolExecuteHandler;
+}
+
+/**
+ * Metadata for an ESM-loaded resource.
+ */
+export interface EsmResourceDefinition {
+ name: string;
+ description?: string;
+ uri: string;
+ mimeType?: string;
+ read: EsmResourceReadHandler;
+}
+
+/**
+ * Metadata for an ESM-loaded prompt.
+ */
+export interface EsmPromptDefinition {
+ name: string;
+ description?: string;
+ arguments?: Array<{ name: string; description?: string; required?: boolean }>;
+ execute: EsmPromptExecuteHandler;
+}
+
+/**
+ * Detect whether a schema object is a Zod raw shape (values are ZodType instances)
+ * vs a JSON Schema object (plain data).
+ */
+function isZodShape(schema: Record): boolean {
+ return Object.values(schema).some((v) => v instanceof z.ZodType);
+}
+
+/**
+ * Build a ToolClassTokenRecord for an ESM-loaded tool.
+ *
+ * Detects whether the tool's `inputSchema` is a Zod raw shape or JSON Schema
+ * and routes accordingly:
+ * - Zod shape → stored in `inputSchema` (converted by `getInputJsonSchema()`)
+ * - JSON Schema → stored in `rawInputSchema` (used as-is)
+ *
+ * @param tool - ESM tool definition with execute function
+ * @param namespace - Optional namespace prefix for the tool name
+ * @returns Standard ToolClassTokenRecord for use with ToolInstance
+ */
+export function buildEsmToolRecord(tool: EsmToolDefinition, namespace?: string): ToolClassTokenRecord {
+ const toolName = namespace ? `${namespace}:${tool.name}` : tool.name;
+
+ const ContextClass = createEsmToolContextClass(tool.execute, tool.name);
+
+ let inputSchema: Record = {};
+ let rawInputSchema: Record | undefined = undefined;
+
+ if (tool.inputSchema) {
+ if (isZodShape(tool.inputSchema)) {
+ // Zod raw shape → store in inputSchema field for conversion by getInputJsonSchema()
+ inputSchema = tool.inputSchema;
+ } else {
+ // JSON Schema → store in rawInputSchema (already serialized)
+ rawInputSchema = tool.inputSchema;
+ }
+ }
+
+ const metadata: ToolMetadata & Record = {
+ name: toolName,
+ id: toolName,
+ description: tool.description ?? `ESM tool: ${tool.name}`,
+ inputSchema: inputSchema as ToolMetadata['inputSchema'],
+ rawInputSchema,
+ outputSchema: (tool.outputSchema ?? 'json') as ToolMetadata['outputSchema'],
+ annotations: {
+ 'frontmcp:esm': true,
+ 'frontmcp:esmTool': tool.name,
+ },
+ };
+
+ return {
+ kind: ToolKind.CLASS_TOKEN,
+ provide: ContextClass as Type,
+ metadata,
+ };
+}
+
+/**
+ * Build a ResourceClassTokenRecord for an ESM-loaded resource.
+ *
+ * @param resource - ESM resource definition with read function
+ * @param namespace - Optional namespace prefix
+ * @returns Standard ResourceClassTokenRecord
+ */
+export function buildEsmResourceRecord(resource: EsmResourceDefinition, namespace?: string): ResourceClassTokenRecord {
+ const resourceName = namespace ? `${namespace}:${resource.name}` : resource.name;
+
+ const ContextClass = createEsmResourceContextClass(resource.read, resource.name);
+
+ const metadata: ResourceMetadata = {
+ name: resourceName,
+ description: resource.description ?? `ESM resource: ${resource.name}`,
+ uri: resource.uri,
+ mimeType: resource.mimeType,
+ };
+
+ return {
+ kind: ResourceKind.CLASS_TOKEN,
+ provide: ContextClass as unknown as Type,
+ metadata,
+ };
+}
+
+/**
+ * Build a PromptClassTokenRecord for an ESM-loaded prompt.
+ *
+ * @param prompt - ESM prompt definition with execute function
+ * @param namespace - Optional namespace prefix
+ * @returns Standard PromptClassTokenRecord
+ */
+export function buildEsmPromptRecord(prompt: EsmPromptDefinition, namespace?: string): PromptClassTokenRecord {
+ const promptName = namespace ? `${namespace}:${prompt.name}` : prompt.name;
+
+ const ContextClass = createEsmPromptContextClass(prompt.execute, prompt.name);
+
+ const metadata: PromptMetadata = {
+ name: promptName,
+ description: prompt.description ?? `ESM prompt: ${prompt.name}`,
+ arguments:
+ prompt.arguments?.map((arg) => ({
+ name: arg.name,
+ description: arg.description,
+ required: arg.required,
+ })) ?? [],
+ };
+
+ return {
+ kind: PromptKind.CLASS_TOKEN,
+ provide: ContextClass as unknown as Type,
+ metadata,
+ };
+}
diff --git a/libs/sdk/src/esm-loader/factories/index.ts b/libs/sdk/src/esm-loader/factories/index.ts
new file mode 100644
index 000000000..211077553
--- /dev/null
+++ b/libs/sdk/src/esm-loader/factories/index.ts
@@ -0,0 +1,19 @@
+export {
+ createEsmToolContextClass,
+ createEsmResourceContextClass,
+ createEsmPromptContextClass,
+ type EsmToolExecuteHandler,
+ type EsmResourceReadHandler,
+ type EsmPromptExecuteHandler,
+} from './esm-context-factories';
+
+export {
+ buildEsmToolRecord,
+ buildEsmResourceRecord,
+ buildEsmPromptRecord,
+ type EsmToolDefinition,
+ type EsmResourceDefinition,
+ type EsmPromptDefinition,
+} from './esm-record-builders';
+
+export { createEsmToolInstance, createEsmResourceInstance, createEsmPromptInstance } from './esm-instance-factories';
diff --git a/libs/sdk/src/esm-loader/index.ts b/libs/sdk/src/esm-loader/index.ts
new file mode 100644
index 000000000..44f8f7c6b
--- /dev/null
+++ b/libs/sdk/src/esm-loader/index.ts
@@ -0,0 +1,88 @@
+// ═══════════════════════════════════════════════════════════════════
+// Package Specifier
+// ═══════════════════════════════════════════════════════════════════
+export {
+ parsePackageSpecifier,
+ isPackageSpecifier,
+ buildEsmShUrl,
+ ESM_SH_BASE_URL,
+ type ParsedPackageSpecifier,
+} from './package-specifier';
+
+// ═══════════════════════════════════════════════════════════════════
+// Auth Types
+// ═══════════════════════════════════════════════════════════════════
+export {
+ type EsmRegistryAuth,
+ esmRegistryAuthSchema,
+ resolveRegistryToken,
+ getRegistryUrl,
+ DEFAULT_NPM_REGISTRY,
+} from './esm-auth.types';
+
+// ═══════════════════════════════════════════════════════════════════
+// Semver Utilities
+// ═══════════════════════════════════════════════════════════════════
+export {
+ satisfiesRange,
+ maxSatisfying,
+ isValidRange,
+ isValidVersion,
+ compareVersions,
+ isNewerVersion,
+} from './semver.utils';
+
+// ═══════════════════════════════════════════════════════════════════
+// Cache Manager
+// ═══════════════════════════════════════════════════════════════════
+export { EsmCacheManager, type EsmCacheEntry, type EsmCacheOptions } from './esm-cache';
+
+// ═══════════════════════════════════════════════════════════════════
+// Package Manifest
+// ═══════════════════════════════════════════════════════════════════
+export {
+ normalizeEsmExport,
+ frontMcpPackageManifestSchema,
+ MANIFEST_PRIMITIVE_KEYS,
+ type FrontMcpPackageManifest,
+ type ManifestPrimitiveKey,
+} from './esm-manifest';
+
+// ═══════════════════════════════════════════════════════════════════
+// Version Resolution
+// ═══════════════════════════════════════════════════════════════════
+export { VersionResolver, type VersionResolutionResult, type VersionResolverOptions } from './version-resolver';
+
+// ═══════════════════════════════════════════════════════════════════
+// ESM Module Loader
+// ═══════════════════════════════════════════════════════════════════
+export { EsmModuleLoader, type EsmLoadResult, type EsmModuleLoaderOptions } from './esm-module-loader';
+
+// ═══════════════════════════════════════════════════════════════════
+// Version Poller
+// ═══════════════════════════════════════════════════════════════════
+export { VersionPoller, type VersionPollerOptions, type VersionCheckResult } from './version-poller';
+
+// ═══════════════════════════════════════════════════════════════════
+// Factories
+// ═══════════════════════════════════════════════════════════════════
+export {
+ // Context factories
+ createEsmToolContextClass,
+ createEsmResourceContextClass,
+ createEsmPromptContextClass,
+ type EsmToolExecuteHandler,
+ type EsmResourceReadHandler,
+ type EsmPromptExecuteHandler,
+ // Record builders
+ buildEsmToolRecord,
+ buildEsmResourceRecord,
+ buildEsmPromptRecord,
+ type EsmToolDefinition,
+ type EsmResourceDefinition,
+ type EsmPromptDefinition,
+ // Instance factories
+ createEsmToolInstance,
+ createEsmResourceInstance,
+ createEsmPromptInstance,
+} from './factories';
diff --git a/libs/sdk/src/esm-loader/package-specifier.ts b/libs/sdk/src/esm-loader/package-specifier.ts
new file mode 100644
index 000000000..9d4393e2e
--- /dev/null
+++ b/libs/sdk/src/esm-loader/package-specifier.ts
@@ -0,0 +1,104 @@
+/**
+ * @file package-specifier.ts
+ * @description Parse npm package specifiers (e.g., '@scope/pkg@^1.0.0') and build esm.sh URLs.
+ */
+
+/**
+ * Parsed representation of an npm package specifier.
+ */
+export interface ParsedPackageSpecifier {
+ /** Scope portion, e.g. '@acme' (includes the @) */
+ scope?: string;
+ /** Package name without scope, e.g. 'mcp-tools' */
+ name: string;
+ /** Full package name, e.g. '@acme/mcp-tools' */
+ fullName: string;
+ /** Semver range or tag, e.g. '^1.0.0', 'latest' */
+ range: string;
+ /** Original input string */
+ raw: string;
+}
+
+/**
+ * Regex for parsing npm package specifiers.
+ * Supports: @scope/name@range, @scope/name, name@range, name
+ */
+const PACKAGE_SPECIFIER_RE = /^(?:(@[a-z0-9-~][a-z0-9-._~]*)\/)?([a-z0-9-~][a-z0-9-._~]*)(?:@(.+))?$/;
+
+/**
+ * Parse an npm-style package specifier string into structured parts.
+ *
+ * @param spec - Package specifier string (e.g., '@acme/mcp-tools@^1.0.0')
+ * @returns Parsed specifier with scope, name, range
+ * @throws Error if the specifier is invalid
+ *
+ * @example
+ * parsePackageSpecifier('@acme/mcp-tools@^1.0.0')
+ * // { scope: '@acme', name: 'mcp-tools', fullName: '@acme/mcp-tools', range: '^1.0.0', raw: '@acme/mcp-tools@^1.0.0' }
+ *
+ * parsePackageSpecifier('my-tools')
+ * // { scope: undefined, name: 'my-tools', fullName: 'my-tools', range: 'latest', raw: 'my-tools' }
+ */
+export function parsePackageSpecifier(spec: string): ParsedPackageSpecifier {
+ const trimmed = spec.trim();
+ if (!trimmed) {
+ throw new Error('Package specifier cannot be empty');
+ }
+
+ const match = PACKAGE_SPECIFIER_RE.exec(trimmed);
+ if (!match) {
+ throw new Error(`Invalid package specifier: "${trimmed}"`);
+ }
+
+ const [, scope, name, range] = match;
+ const fullName = scope ? `${scope}/${name}` : name;
+
+ return {
+ scope: scope || undefined,
+ name,
+ fullName,
+ range: range || 'latest',
+ raw: trimmed,
+ };
+}
+
+/**
+ * Check whether a string looks like a package specifier (starts with @ or contains alphanumeric).
+ * Used by normalize functions to distinguish package strings from other string inputs.
+ */
+export function isPackageSpecifier(value: string): boolean {
+ return PACKAGE_SPECIFIER_RE.test(value.trim());
+}
+
+/**
+ * Default esm.sh CDN base URL.
+ */
+export const ESM_SH_BASE_URL = 'https://esm.sh';
+
+/**
+ * Build an esm.sh CDN URL for a given package specifier.
+ *
+ * @param spec - Parsed package specifier
+ * @param resolvedVersion - Concrete version to pin to (overrides range)
+ * @param options - Additional URL options
+ * @returns Full esm.sh URL for dynamic import
+ *
+ * @example
+ * buildEsmShUrl(parsePackageSpecifier('@acme/tools@^1.0.0'), '1.2.3')
+ * // 'https://esm.sh/@acme/tools@1.2.3?bundle'
+ */
+export function buildEsmShUrl(
+ spec: ParsedPackageSpecifier,
+ resolvedVersion?: string,
+ options?: { baseUrl?: string; bundle?: boolean },
+): string {
+ const base = options?.baseUrl ?? ESM_SH_BASE_URL;
+ const version = resolvedVersion ?? spec.range;
+ const bundle = options?.bundle !== false;
+
+ let url = `${base}/${spec.fullName}@${version}`;
+ if (bundle) {
+ url += '?bundle';
+ }
+ return url;
+}
diff --git a/libs/sdk/src/esm-loader/semver.utils.ts b/libs/sdk/src/esm-loader/semver.utils.ts
new file mode 100644
index 000000000..a01db078b
--- /dev/null
+++ b/libs/sdk/src/esm-loader/semver.utils.ts
@@ -0,0 +1,77 @@
+/**
+ * @file semver.utils.ts
+ * @description Thin wrapper around the `semver` package for version comparison utilities.
+ * Isolates the semver dependency to a single file for easier maintenance.
+ */
+
+import * as semver from 'semver';
+
+/**
+ * Check if a range string is a dist-tag (not a semver range).
+ * Dist-tags like 'next', 'beta', 'canary' are simple alphanumeric identifiers
+ * that are not valid semver ranges.
+ * Note: `semver.validRange('latest')` returns `'*'` (not null), so 'latest'
+ * must be guarded separately before calling this function.
+ */
+function isDistTag(range: string): boolean {
+ return /^[a-zA-Z][a-zA-Z0-9]*$/.test(range) && semver.validRange(range) === null;
+}
+
+/**
+ * Check if a specific version satisfies a semver range.
+ *
+ * @param version - Concrete version string (e.g., '1.2.3')
+ * @param range - Semver range string (e.g., '^1.0.0', '>=2.0.0', 'latest')
+ * @returns true if the version satisfies the range
+ */
+export function satisfiesRange(version: string, range: string): boolean {
+ if (range === 'latest' || isDistTag(range)) return true;
+ return semver.satisfies(version, range);
+}
+
+/**
+ * Find the highest version in a list that satisfies a semver range.
+ *
+ * @param versions - Array of version strings
+ * @param range - Semver range to match against
+ * @returns The highest matching version, or null if none match
+ */
+export function maxSatisfying(versions: string[], range: string): string | null {
+ if (range === 'latest' || isDistTag(range)) {
+ const sorted = versions.filter((v) => semver.valid(v)).sort(semver.rcompare);
+ return sorted[0] ?? null;
+ }
+ return semver.maxSatisfying(versions, range);
+}
+
+/**
+ * Check if a string is a valid semver range.
+ */
+export function isValidRange(range: string): boolean {
+ if (range === 'latest' || isDistTag(range)) return true;
+ return semver.validRange(range) !== null;
+}
+
+/**
+ * Check if a string is a valid semver version.
+ */
+export function isValidVersion(version: string): boolean {
+ return semver.valid(version) !== null;
+}
+
+/**
+ * Compare two versions. Returns:
+ * - negative if v1 < v2
+ * - 0 if v1 === v2
+ * - positive if v1 > v2
+ */
+export function compareVersions(v1: string, v2: string): number {
+ return semver.compare(v1, v2);
+}
+
+/**
+ * Check if v1 is greater than v2.
+ */
+export function isNewerVersion(v1: string, v2: string): boolean {
+ return semver.gt(v1, v2);
+}
diff --git a/libs/sdk/src/esm-loader/version-poller.ts b/libs/sdk/src/esm-loader/version-poller.ts
new file mode 100644
index 000000000..5f023ba74
--- /dev/null
+++ b/libs/sdk/src/esm-loader/version-poller.ts
@@ -0,0 +1,225 @@
+/**
+ * @file version-poller.ts
+ * @description Background polling service that checks npm registry for new package versions.
+ * Triggers callbacks when a new version matching the semver range is detected.
+ */
+
+import type { FrontMcpLogger } from '../common';
+import type { EsmRegistryAuth } from './esm-auth.types';
+import type { ParsedPackageSpecifier } from './package-specifier';
+import { VersionResolver } from './version-resolver';
+import { isNewerVersion, satisfiesRange } from './semver.utils';
+
+/**
+ * Result of checking a single package for updates.
+ */
+export interface VersionCheckResult {
+ /** Full package name */
+ packageName: string;
+ /** Currently loaded version */
+ currentVersion: string;
+ /** Latest version matching the range */
+ latestVersion: string;
+ /** Whether a newer version is available */
+ hasUpdate: boolean;
+ /** Whether the latest version satisfies the original range */
+ satisfiesRange: boolean;
+}
+
+/**
+ * A tracked package entry in the poller.
+ */
+interface TrackedPackage {
+ specifier: ParsedPackageSpecifier;
+ currentVersion: string;
+}
+
+/**
+ * Options for the version poller.
+ */
+export interface VersionPollerOptions {
+ /** Polling interval in milliseconds (default: 300000 = 5 minutes) */
+ intervalMs?: number;
+ /** Authentication for private registries */
+ registryAuth?: EsmRegistryAuth;
+ /** Logger instance */
+ logger?: FrontMcpLogger;
+ /** Callback when a new version is detected */
+ onNewVersion: (packageName: string, oldVersion: string, newVersion: string) => Promise;
+}
+
+/** Default polling interval: 5 minutes */
+const DEFAULT_INTERVAL_MS = 5 * 60 * 1000;
+
+/**
+ * Background service that periodically checks npm registry for package updates.
+ *
+ * When a new version matching the semver range is detected, triggers the
+ * `onNewVersion` callback to drive hot-reload of ESM packages.
+ */
+export class VersionPoller {
+ private readonly intervalMs: number;
+ private readonly logger?: FrontMcpLogger;
+ private readonly onNewVersion: VersionPollerOptions['onNewVersion'];
+ private readonly versionResolver: VersionResolver;
+ private readonly packages: Map = new Map();
+ private intervalId?: ReturnType;
+ private running = false;
+ private polling = false;
+
+ constructor(options: VersionPollerOptions) {
+ this.intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
+ this.logger = options.logger;
+ this.onNewVersion = options.onNewVersion;
+ this.versionResolver = new VersionResolver({
+ registryAuth: options.registryAuth,
+ });
+ }
+
+ /**
+ * Add a package to the polling watchlist.
+ *
+ * @param specifier - Parsed package specifier with semver range
+ * @param currentVersion - Currently loaded concrete version
+ */
+ addPackage(specifier: ParsedPackageSpecifier, currentVersion: string): void {
+ this.packages.set(specifier.fullName, { specifier, currentVersion });
+ this.logger?.debug(
+ `Version poller: tracking ${specifier.fullName}@${specifier.range} (current: ${currentVersion})`,
+ );
+ }
+
+ /**
+ * Remove a package from the polling watchlist.
+ */
+ removePackage(packageName: string): void {
+ this.packages.delete(packageName);
+ this.logger?.debug(`Version poller: stopped tracking ${packageName}`);
+ }
+
+ /**
+ * Update the current version for a tracked package (after hot-reload).
+ *
+ * @returns `true` if the package was found and updated, `false` if unknown.
+ */
+ updateCurrentVersion(packageName: string, newVersion: string): boolean {
+ const entry = this.packages.get(packageName);
+ if (entry) {
+ entry.currentVersion = newVersion;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Start the background polling loop.
+ */
+ start(): void {
+ if (this.running) return;
+ if (this.packages.size === 0) return;
+
+ this.running = true;
+ this.intervalId = setInterval(() => {
+ void this.poll();
+ }, this.intervalMs);
+
+ this.logger?.info(`Version poller started (interval: ${this.intervalMs}ms, packages: ${this.packages.size})`);
+ }
+
+ /**
+ * Stop the background polling loop.
+ */
+ stop(): void {
+ if (!this.running) return;
+ this.running = false;
+
+ if (this.intervalId) {
+ clearInterval(this.intervalId);
+ this.intervalId = undefined;
+ }
+
+ this.logger?.info('Version poller stopped');
+ }
+
+ /**
+ * Check all tracked packages for updates immediately (without waiting for the interval).
+ *
+ * @returns Array of check results for all tracked packages
+ */
+ async checkNow(): Promise {
+ const results: VersionCheckResult[] = [];
+
+ for (const [, entry] of this.packages) {
+ try {
+ const result = await this.checkPackage(entry);
+ results.push(result);
+ } catch (error) {
+ this.logger?.warn(`Version check failed for ${entry.specifier.fullName}: ${(error as Error).message}`);
+ }
+ }
+
+ return results;
+ }
+
+ /**
+ * Whether the poller is currently running.
+ */
+ isRunning(): boolean {
+ return this.running;
+ }
+
+ /**
+ * Number of packages being tracked.
+ */
+ get trackedCount(): number {
+ return this.packages.size;
+ }
+
+ /**
+ * Internal polling loop iteration.
+ */
+ private async poll(): Promise {
+ if (this.polling) return;
+ this.polling = true;
+ try {
+ for (const [, entry] of this.packages) {
+ try {
+ const result = await this.checkPackage(entry);
+ if (result.hasUpdate) {
+ this.logger?.info(
+ `New version available for ${entry.specifier.fullName}: ${entry.currentVersion} → ${result.latestVersion}`,
+ );
+ await this.onNewVersion(entry.specifier.fullName, entry.currentVersion, result.latestVersion);
+ // Update the tracked version after successful callback
+ entry.currentVersion = result.latestVersion;
+ }
+ } catch (error) {
+ this.logger?.warn(`Version poll failed for ${entry.specifier.fullName}: ${(error as Error).message}`);
+ }
+ }
+ } finally {
+ this.polling = false;
+ }
+ }
+
+ /**
+ * Check a single package for updates.
+ */
+ private async checkPackage(entry: TrackedPackage): Promise {
+ const resolution = await this.versionResolver.resolve(entry.specifier);
+
+ const hasUpdate =
+ resolution.resolvedVersion !== entry.currentVersion &&
+ isNewerVersion(resolution.resolvedVersion, entry.currentVersion);
+
+ const rangeMatch = satisfiesRange(resolution.resolvedVersion, entry.specifier.range);
+
+ return {
+ packageName: entry.specifier.fullName,
+ currentVersion: entry.currentVersion,
+ latestVersion: resolution.resolvedVersion,
+ hasUpdate: hasUpdate && rangeMatch,
+ satisfiesRange: rangeMatch,
+ };
+ }
+}
diff --git a/libs/sdk/src/esm-loader/version-resolver.ts b/libs/sdk/src/esm-loader/version-resolver.ts
new file mode 100644
index 000000000..35c0ca5a7
--- /dev/null
+++ b/libs/sdk/src/esm-loader/version-resolver.ts
@@ -0,0 +1,164 @@
+/**
+ * @file version-resolver.ts
+ * @description Resolves semver ranges to concrete versions using the npm registry API.
+ */
+
+import type { EsmRegistryAuth } from './esm-auth.types';
+import { resolveRegistryToken, getRegistryUrl } from './esm-auth.types';
+import { maxSatisfying, isValidVersion } from './semver.utils';
+import type { ParsedPackageSpecifier } from './package-specifier';
+
+/**
+ * Result of a version resolution.
+ */
+export interface VersionResolutionResult {
+ /** The concrete version that was resolved */
+ resolvedVersion: string;
+ /** All available versions from the registry */
+ availableVersions: string[];
+ /** When the resolved version was published (ISO string) */
+ publishedAt?: string;
+}
+
+/**
+ * Options for the version resolver.
+ */
+export interface VersionResolverOptions {
+ /** Authentication for private registries */
+ registryAuth?: EsmRegistryAuth;
+ /** Request timeout in milliseconds (default: 15000) */
+ timeout?: number;
+}
+
+/**
+ * Resolves semver ranges to concrete versions by querying the npm registry API.
+ */
+export class VersionResolver {
+ private readonly registryAuth?: EsmRegistryAuth;
+ private readonly timeout: number;
+
+ constructor(options?: VersionResolverOptions) {
+ this.registryAuth = options?.registryAuth;
+ this.timeout = options?.timeout ?? 15000;
+ }
+
+ /**
+ * Resolve a package specifier's semver range to a concrete version.
+ *
+ * @param specifier - Parsed package specifier with range
+ * @returns Resolution result with concrete version
+ * @throws Error if the package is not found or no version matches the range
+ */
+ async resolve(specifier: ParsedPackageSpecifier): Promise {
+ const registryUrl = getRegistryUrl(this.registryAuth);
+ const packageUrl = `${registryUrl}/${encodePackageName(specifier.fullName)}`;
+
+ const headers: Record = {
+ Accept: 'application/json',
+ };
+
+ const token = resolveRegistryToken(this.registryAuth);
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`;
+ }
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
+
+ let response: Response;
+ try {
+ response = await fetch(packageUrl, {
+ headers,
+ signal: controller.signal,
+ });
+ } catch (error) {
+ if ((error as Error).name === 'AbortError') {
+ throw new Error(`Timeout resolving version for "${specifier.fullName}" after ${this.timeout}ms`);
+ }
+ throw new Error(`Failed to fetch package info for "${specifier.fullName}": ${(error as Error).message}`);
+ } finally {
+ clearTimeout(timeoutId);
+ }
+
+ if (response.status === 404) {
+ throw new Error(`Package "${specifier.fullName}" not found in registry at ${registryUrl}`);
+ }
+
+ if (!response.ok) {
+ throw new Error(`Registry returned ${response.status} for "${specifier.fullName}": ${response.statusText}`);
+ }
+
+ const data = (await response.json()) as NpmRegistryResponse;
+ return this.resolveFromRegistryData(specifier, data);
+ }
+
+ /**
+ * Resolve version from already-fetched registry data.
+ */
+ private resolveFromRegistryData(
+ specifier: ParsedPackageSpecifier,
+ data: NpmRegistryResponse,
+ ): VersionResolutionResult {
+ const versions = Object.keys(data.versions ?? {});
+ if (versions.length === 0) {
+ throw new Error(`No versions found for "${specifier.fullName}"`);
+ }
+
+ // Handle 'latest' tag by checking dist-tags
+ if (specifier.range === 'latest') {
+ const latestVersion = data['dist-tags']?.['latest'];
+ if (latestVersion && isValidVersion(latestVersion)) {
+ return {
+ resolvedVersion: latestVersion,
+ availableVersions: versions,
+ publishedAt: data.time?.[latestVersion],
+ };
+ }
+ }
+
+ // Handle other dist-tags (e.g., 'next', 'beta')
+ if (data['dist-tags']?.[specifier.range]) {
+ const tagVersion = data['dist-tags'][specifier.range];
+ if (isValidVersion(tagVersion)) {
+ return {
+ resolvedVersion: tagVersion,
+ availableVersions: versions,
+ publishedAt: data.time?.[tagVersion],
+ };
+ }
+ }
+
+ // Semver range resolution
+ const resolved = maxSatisfying(versions, specifier.range);
+ if (!resolved) {
+ throw new Error(
+ `No version of "${specifier.fullName}" satisfies range "${specifier.range}". ` +
+ `Available versions: ${versions.slice(-5).join(', ')}${versions.length > 5 ? '...' : ''}`,
+ );
+ }
+
+ return {
+ resolvedVersion: resolved,
+ availableVersions: versions,
+ publishedAt: data.time?.[resolved],
+ };
+ }
+}
+
+/**
+ * Encode a scoped package name for use in registry URLs.
+ * '@scope/name' -> '@scope%2fname'
+ */
+function encodePackageName(name: string): string {
+ return name.replace(/\//g, '%2f');
+}
+
+/**
+ * Minimal npm registry response shape (we only use what we need).
+ */
+interface NpmRegistryResponse {
+ name: string;
+ 'dist-tags'?: Record;
+ versions?: Record;
+ time?: Record;
+}
diff --git a/libs/sdk/src/index.ts b/libs/sdk/src/index.ts
index 455e5c0b0..530c5345f 100644
--- a/libs/sdk/src/index.ts
+++ b/libs/sdk/src/index.ts
@@ -31,6 +31,7 @@ export * from './common';
export * from './errors';
export * from './elicitation';
export * from './remote-mcp';
+export * from './esm-loader';
// Re-export MCP types commonly needed by consumers
export type {
diff --git a/libs/sdk/src/job/__tests__/job-npm-remote.spec.ts b/libs/sdk/src/job/__tests__/job-npm-remote.spec.ts
new file mode 100644
index 000000000..65fc9e8e6
--- /dev/null
+++ b/libs/sdk/src/job/__tests__/job-npm-remote.spec.ts
@@ -0,0 +1,145 @@
+import { Job } from '../../common/decorators/job.decorator';
+import { JobKind } from '../../common/records/job.record';
+import type { JobEsmTargetRecord, JobRemoteRecord } from '../../common/records/job.record';
+import { normalizeJob, jobDiscoveryDeps } from '../job.utils';
+
+describe('Job.esm()', () => {
+ it('creates JobEsmTargetRecord with kind ESM', () => {
+ const record = Job.esm('@acme/jobs@^1.0.0', 'cleanup') as JobEsmTargetRecord;
+ expect(record.kind).toBe(JobKind.ESM);
+ });
+
+ it('parses scoped specifier correctly', () => {
+ const record = Job.esm('@acme/jobs@^1.0.0', 'cleanup') as JobEsmTargetRecord;
+ expect(record.specifier.scope).toBe('@acme');
+ expect(record.specifier.name).toBe('jobs');
+ expect(record.specifier.fullName).toBe('@acme/jobs');
+ expect(record.specifier.range).toBe('^1.0.0');
+ });
+
+ it('parses unscoped specifier', () => {
+ const record = Job.esm('my-jobs@2.0.0', 'report') as JobEsmTargetRecord;
+ expect(record.specifier.scope).toBeUndefined();
+ expect(record.specifier.fullName).toBe('my-jobs');
+ expect(record.specifier.range).toBe('2.0.0');
+ });
+
+ it('sets targetName', () => {
+ const record = Job.esm('@acme/jobs@^1.0.0', 'cleanup') as JobEsmTargetRecord;
+ expect(record.targetName).toBe('cleanup');
+ });
+
+ it('creates unique symbol provide token', () => {
+ const record = Job.esm('@acme/jobs@^1.0.0', 'cleanup') as JobEsmTargetRecord;
+ expect(typeof record.provide).toBe('symbol');
+ expect(record.provide.toString()).toContain('esm-job:@acme/jobs:cleanup');
+ });
+
+ it('creates different symbols for different targets', () => {
+ const r1 = Job.esm('@acme/jobs@^1.0.0', 'cleanup') as JobEsmTargetRecord;
+ const r2 = Job.esm('@acme/jobs@^1.0.0', 'report') as JobEsmTargetRecord;
+ expect(r1.provide).not.toBe(r2.provide);
+ });
+
+ it('passes options through', () => {
+ const record = Job.esm('@acme/jobs@^1.0.0', 'cleanup', {
+ loader: { url: 'https://custom.cdn', token: 'xxx' },
+ cacheTTL: 30000,
+ }) as JobEsmTargetRecord;
+ expect(record.options?.loader).toEqual({ url: 'https://custom.cdn', token: 'xxx' });
+ expect(record.options?.cacheTTL).toBe(30000);
+ });
+
+ it('generates placeholder metadata with inputSchema and outputSchema', () => {
+ const record = Job.esm('@acme/jobs@^1.0.0', 'cleanup') as JobEsmTargetRecord;
+ expect(record.metadata.name).toBe('cleanup');
+ expect(record.metadata.description).toContain('cleanup');
+ expect(record.metadata.description).toContain('@acme/jobs');
+ expect(record.metadata.inputSchema).toEqual({});
+ expect(record.metadata.outputSchema).toEqual({});
+ });
+
+ it('allows overriding metadata via options', () => {
+ const record = Job.esm('@acme/jobs@^1.0.0', 'cleanup', {
+ metadata: { description: 'Custom cleanup job' },
+ }) as JobEsmTargetRecord;
+ expect(record.metadata.description).toBe('Custom cleanup job');
+ expect(record.metadata.name).toBe('cleanup');
+ expect(record.metadata.inputSchema).toEqual({});
+ });
+
+ it('throws on empty specifier', () => {
+ expect(() => Job.esm('', 'cleanup')).toThrow('Package specifier cannot be empty');
+ });
+
+ it('throws on invalid specifier', () => {
+ expect(() => Job.esm('!!!', 'cleanup')).toThrow('Invalid package specifier');
+ });
+});
+
+describe('Job.remote()', () => {
+ it('creates JobRemoteRecord with kind REMOTE', () => {
+ const record = Job.remote('https://api.example.com/mcp', 'sync') as JobRemoteRecord;
+ expect(record.kind).toBe(JobKind.REMOTE);
+ });
+
+ it('sets url and targetName', () => {
+ const record = Job.remote('https://api.example.com/mcp', 'sync') as JobRemoteRecord;
+ expect(record.url).toBe('https://api.example.com/mcp');
+ expect(record.targetName).toBe('sync');
+ });
+
+ it('creates unique symbol provide token', () => {
+ const record = Job.remote('https://api.example.com/mcp', 'sync') as JobRemoteRecord;
+ expect(typeof record.provide).toBe('symbol');
+ expect(record.provide.toString()).toContain('remote-job:https://api.example.com/mcp:sync');
+ });
+
+ it('passes transportOptions and remoteAuth', () => {
+ const record = Job.remote('https://api.example.com/mcp', 'sync', {
+ transportOptions: { timeout: 120000, retryAttempts: 5 },
+ remoteAuth: { mode: 'static', credentials: { type: 'bearer', value: 'jwt-token' } },
+ }) as JobRemoteRecord;
+ expect(record.transportOptions).toEqual({ timeout: 120000, retryAttempts: 5 });
+ expect(record.remoteAuth).toEqual({
+ mode: 'static',
+ credentials: { type: 'bearer', value: 'jwt-token' },
+ });
+ });
+
+ it('generates placeholder metadata with inputSchema and outputSchema', () => {
+ const record = Job.remote('https://api.example.com/mcp', 'sync') as JobRemoteRecord;
+ expect(record.metadata.name).toBe('sync');
+ expect(record.metadata.description).toContain('sync');
+ expect(record.metadata.inputSchema).toEqual({});
+ expect(record.metadata.outputSchema).toEqual({});
+ });
+});
+
+describe('normalizeJob() with ESM/REMOTE records', () => {
+ it('passes through JobEsmTargetRecord unchanged', () => {
+ const record = Job.esm('@acme/jobs@^1.0.0', 'cleanup') as JobEsmTargetRecord;
+ const normalized = normalizeJob(record);
+ expect(normalized).toBe(record);
+ expect(normalized.kind).toBe(JobKind.ESM);
+ });
+
+ it('passes through JobRemoteRecord unchanged', () => {
+ const record = Job.remote('https://api.example.com/mcp', 'sync') as JobRemoteRecord;
+ const normalized = normalizeJob(record);
+ expect(normalized).toBe(record);
+ expect(normalized.kind).toBe(JobKind.REMOTE);
+ });
+});
+
+describe('jobDiscoveryDeps() with ESM/REMOTE records', () => {
+ it('returns empty array for ESM record', () => {
+ const record = Job.esm('@acme/jobs@^1.0.0', 'cleanup') as JobEsmTargetRecord;
+ expect(jobDiscoveryDeps(record)).toEqual([]);
+ });
+
+ it('returns empty array for REMOTE record', () => {
+ const record = Job.remote('https://api.example.com/mcp', 'sync') as JobRemoteRecord;
+ expect(jobDiscoveryDeps(record)).toEqual([]);
+ });
+});
diff --git a/libs/sdk/src/job/job.utils.ts b/libs/sdk/src/job/job.utils.ts
index 8b0a63f3b..f65eb04e3 100644
--- a/libs/sdk/src/job/job.utils.ts
+++ b/libs/sdk/src/job/job.utils.ts
@@ -21,6 +21,16 @@ export function collectJobMetadata(cls: JobType): JobMetadata {
}
export function normalizeJob(item: unknown): JobRecord {
+ // ESM/REMOTE record objects (from Job.esm() / Job.remote())
+ if (item && typeof item === 'object' && 'kind' in item && 'provide' in item && 'metadata' in item) {
+ if (item.kind === JobKind.ESM && 'specifier' in item && 'targetName' in item) {
+ return item as JobRecord;
+ }
+ if (item.kind === JobKind.REMOTE && 'url' in item && 'targetName' in item) {
+ return item as JobRecord;
+ }
+ }
+
// Function-style job
const fn = item as Record;
if (
@@ -58,6 +68,9 @@ export function jobDiscoveryDeps(rec: JobRecord): Token[] {
case JobKind.CLASS_TOKEN:
return depsOfClass(rec.provide, 'discovery');
case JobKind.DYNAMIC:
- return []; // Dynamic jobs have no compile-time deps
+ case JobKind.ESM:
+ case JobKind.REMOTE:
+ // Dynamic, ESM, and remote jobs have no compile-time deps
+ return [];
}
}
diff --git a/libs/sdk/src/prompt/__tests__/prompt-npm-remote.spec.ts b/libs/sdk/src/prompt/__tests__/prompt-npm-remote.spec.ts
new file mode 100644
index 000000000..a36ff6bb1
--- /dev/null
+++ b/libs/sdk/src/prompt/__tests__/prompt-npm-remote.spec.ts
@@ -0,0 +1,143 @@
+import { Prompt } from '../../common/decorators/prompt.decorator';
+import { PromptKind } from '../../common/records/prompt.record';
+import type { PromptEsmTargetRecord, PromptRemoteRecord } from '../../common/records/prompt.record';
+import { normalizePrompt, promptDiscoveryDeps } from '../prompt.utils';
+
+describe('Prompt.esm()', () => {
+ it('creates PromptEsmTargetRecord with kind ESM', () => {
+ const record = Prompt.esm('@acme/tools@^1.0.0', 'greeting') as PromptEsmTargetRecord;
+ expect(record.kind).toBe(PromptKind.ESM);
+ });
+
+ it('parses scoped specifier correctly', () => {
+ const record = Prompt.esm('@acme/tools@^1.0.0', 'greeting') as PromptEsmTargetRecord;
+ expect(record.specifier.scope).toBe('@acme');
+ expect(record.specifier.name).toBe('tools');
+ expect(record.specifier.fullName).toBe('@acme/tools');
+ expect(record.specifier.range).toBe('^1.0.0');
+ });
+
+ it('parses unscoped specifier', () => {
+ const record = Prompt.esm('prompts-lib@2.0.0', 'welcome') as PromptEsmTargetRecord;
+ expect(record.specifier.scope).toBeUndefined();
+ expect(record.specifier.fullName).toBe('prompts-lib');
+ expect(record.specifier.range).toBe('2.0.0');
+ });
+
+ it('sets targetName', () => {
+ const record = Prompt.esm('@acme/tools@^1.0.0', 'greeting') as PromptEsmTargetRecord;
+ expect(record.targetName).toBe('greeting');
+ });
+
+ it('creates unique symbol provide token', () => {
+ const record = Prompt.esm('@acme/tools@^1.0.0', 'greeting') as PromptEsmTargetRecord;
+ expect(typeof record.provide).toBe('symbol');
+ expect(record.provide.toString()).toContain('esm-prompt:@acme/tools:greeting');
+ });
+
+ it('creates different symbols for different targets', () => {
+ const r1 = Prompt.esm('@acme/tools@^1.0.0', 'greeting') as PromptEsmTargetRecord;
+ const r2 = Prompt.esm('@acme/tools@^1.0.0', 'farewell') as PromptEsmTargetRecord;
+ expect(r1.provide).not.toBe(r2.provide);
+ });
+
+ it('passes options through', () => {
+ const record = Prompt.esm('@acme/tools@^1.0.0', 'greeting', {
+ cacheTTL: 120000,
+ }) as PromptEsmTargetRecord;
+ expect(record.options?.cacheTTL).toBe(120000);
+ });
+
+ it('generates placeholder metadata with arguments array', () => {
+ const record = Prompt.esm('@acme/tools@^1.0.0', 'greeting') as PromptEsmTargetRecord;
+ expect(record.metadata.name).toBe('greeting');
+ expect(record.metadata.description).toContain('greeting');
+ expect(record.metadata.arguments).toEqual([]);
+ });
+
+ it('allows overriding metadata via options', () => {
+ const record = Prompt.esm('@acme/tools@^1.0.0', 'greeting', {
+ metadata: { description: 'Custom greeting prompt' },
+ }) as PromptEsmTargetRecord;
+ expect(record.metadata.description).toBe('Custom greeting prompt');
+ expect(record.metadata.name).toBe('greeting');
+ expect(record.metadata.arguments).toEqual([]);
+ });
+
+ it('throws on empty specifier', () => {
+ expect(() => Prompt.esm('', 'greeting')).toThrow('Package specifier cannot be empty');
+ });
+
+ it('throws on invalid specifier', () => {
+ expect(() => Prompt.esm('!!!', 'greeting')).toThrow('Invalid package specifier');
+ });
+});
+
+describe('Prompt.remote()', () => {
+ it('creates PromptRemoteRecord with kind REMOTE', () => {
+ const record = Prompt.remote('https://api.example.com/mcp', 'greeting') as PromptRemoteRecord;
+ expect(record.kind).toBe(PromptKind.REMOTE);
+ });
+
+ it('sets url and targetName', () => {
+ const record = Prompt.remote('https://api.example.com/mcp', 'greeting') as PromptRemoteRecord;
+ expect(record.url).toBe('https://api.example.com/mcp');
+ expect(record.targetName).toBe('greeting');
+ });
+
+ it('creates unique symbol provide token', () => {
+ const record = Prompt.remote('https://api.example.com/mcp', 'greeting') as PromptRemoteRecord;
+ expect(typeof record.provide).toBe('symbol');
+ expect(record.provide.toString()).toContain('remote-prompt:https://api.example.com/mcp:greeting');
+ });
+
+ it('passes transportOptions and remoteAuth', () => {
+ const record = Prompt.remote('https://api.example.com/mcp', 'greeting', {
+ transportOptions: { timeout: 15000 },
+ remoteAuth: { mode: 'static', credentials: { type: 'bearer', value: 'abc' } },
+ }) as PromptRemoteRecord;
+ expect(record.transportOptions).toEqual({ timeout: 15000 });
+ expect(record.remoteAuth).toEqual({
+ mode: 'static',
+ credentials: { type: 'bearer', value: 'abc' },
+ });
+ });
+
+ it('generates placeholder metadata with arguments array', () => {
+ const record = Prompt.remote('https://api.example.com/mcp', 'greeting') as PromptRemoteRecord;
+ expect(record.metadata.name).toBe('greeting');
+ expect(record.metadata.arguments).toEqual([]);
+ });
+
+ it('throws on invalid URI without a scheme', () => {
+ expect(() => Prompt.remote('not-a-uri', 'test')).toThrow('URI must have a valid scheme');
+ });
+});
+
+describe('normalizePrompt() with ESM/REMOTE records', () => {
+ it('passes through PromptEsmTargetRecord unchanged', () => {
+ const record = Prompt.esm('@acme/tools@^1.0.0', 'greeting') as PromptEsmTargetRecord;
+ const normalized = normalizePrompt(record);
+ expect(normalized).toBe(record);
+ expect(normalized.kind).toBe(PromptKind.ESM);
+ });
+
+ it('passes through PromptRemoteRecord unchanged', () => {
+ const record = Prompt.remote('https://api.example.com/mcp', 'greeting') as PromptRemoteRecord;
+ const normalized = normalizePrompt(record);
+ expect(normalized).toBe(record);
+ expect(normalized.kind).toBe(PromptKind.REMOTE);
+ });
+});
+
+describe('promptDiscoveryDeps() with ESM/REMOTE records', () => {
+ it('returns empty array for ESM record', () => {
+ const record = Prompt.esm('@acme/tools@^1.0.0', 'greeting') as PromptEsmTargetRecord;
+ expect(promptDiscoveryDeps(record)).toEqual([]);
+ });
+
+ it('returns empty array for REMOTE record', () => {
+ const record = Prompt.remote('https://api.example.com/mcp', 'greeting') as PromptRemoteRecord;
+ expect(promptDiscoveryDeps(record)).toEqual([]);
+ });
+});
diff --git a/libs/sdk/src/prompt/prompt.utils.ts b/libs/sdk/src/prompt/prompt.utils.ts
index 397e12092..aeb67983d 100644
--- a/libs/sdk/src/prompt/prompt.utils.ts
+++ b/libs/sdk/src/prompt/prompt.utils.ts
@@ -40,6 +40,11 @@ export function collectPromptMetadata(cls: PromptType): PromptMetadata {
* meaningful error messages for invalid inputs.
*/
export function normalizePrompt(item: any): PromptRecord {
+ // ESM/REMOTE record objects (from Prompt.esm() / Prompt.remote())
+ if (item && typeof item === 'object' && (item.kind === PromptKind.ESM || item.kind === PromptKind.REMOTE)) {
+ return item as PromptRecord;
+ }
+
// Function-style decorator: prompt({ name: '...' })(handler)
if (
item &&
@@ -73,6 +78,10 @@ export function promptDiscoveryDeps(rec: PromptRecord): Token[] {
return depsOfFunc(rec.provide, 'discovery');
case PromptKind.CLASS_TOKEN:
return depsOfClass(rec.provide, 'discovery');
+ case PromptKind.ESM:
+ case PromptKind.REMOTE:
+ // External packages/services have no local DI dependencies at discovery time
+ return [];
}
}
diff --git a/libs/sdk/src/resource/__tests__/resource-npm-remote.spec.ts b/libs/sdk/src/resource/__tests__/resource-npm-remote.spec.ts
new file mode 100644
index 000000000..39ec9f4f5
--- /dev/null
+++ b/libs/sdk/src/resource/__tests__/resource-npm-remote.spec.ts
@@ -0,0 +1,147 @@
+import { Resource } from '../../common/decorators/resource.decorator';
+import { ResourceKind } from '../../common/records/resource.record';
+import type { ResourceEsmTargetRecord, ResourceRemoteRecord } from '../../common/records/resource.record';
+import { normalizeResource, resourceDiscoveryDeps } from '../resource.utils';
+
+describe('Resource.esm()', () => {
+ it('creates ResourceEsmTargetRecord with kind ESM', () => {
+ const record = Resource.esm('@acme/tools@^1.0.0', 'status') as ResourceEsmTargetRecord;
+ expect(record.kind).toBe(ResourceKind.ESM);
+ });
+
+ it('parses scoped specifier correctly', () => {
+ const record = Resource.esm('@acme/tools@^1.0.0', 'status') as ResourceEsmTargetRecord;
+ expect(record.specifier.scope).toBe('@acme');
+ expect(record.specifier.name).toBe('tools');
+ expect(record.specifier.fullName).toBe('@acme/tools');
+ expect(record.specifier.range).toBe('^1.0.0');
+ });
+
+ it('parses unscoped specifier correctly', () => {
+ const record = Resource.esm('my-tools@latest', 'config') as ResourceEsmTargetRecord;
+ expect(record.specifier.scope).toBeUndefined();
+ expect(record.specifier.fullName).toBe('my-tools');
+ });
+
+ it('sets targetName', () => {
+ const record = Resource.esm('@acme/tools@^1.0.0', 'status') as ResourceEsmTargetRecord;
+ expect(record.targetName).toBe('status');
+ });
+
+ it('creates unique symbol provide token', () => {
+ const record = Resource.esm('@acme/tools@^1.0.0', 'status') as ResourceEsmTargetRecord;
+ expect(typeof record.provide).toBe('symbol');
+ expect(record.provide.toString()).toContain('esm-resource:@acme/tools:status');
+ });
+
+ it('creates different symbols for different targets', () => {
+ const r1 = Resource.esm('@acme/tools@^1.0.0', 'status') as ResourceEsmTargetRecord;
+ const r2 = Resource.esm('@acme/tools@^1.0.0', 'config') as ResourceEsmTargetRecord;
+ expect(r1.provide).not.toBe(r2.provide);
+ });
+
+ it('passes options through', () => {
+ const record = Resource.esm('@acme/tools@^1.0.0', 'status', {
+ loader: { url: 'https://custom.cdn' },
+ cacheTTL: 60000,
+ }) as ResourceEsmTargetRecord;
+ expect(record.options?.loader).toEqual({ url: 'https://custom.cdn' });
+ expect(record.options?.cacheTTL).toBe(60000);
+ });
+
+ it('generates placeholder metadata with uri', () => {
+ const record = Resource.esm('@acme/tools@^1.0.0', 'status') as ResourceEsmTargetRecord;
+ expect(record.metadata.name).toBe('status');
+ expect(record.metadata.uri).toBe('esm://status');
+ expect(record.metadata.description).toContain('status');
+ });
+
+ it('allows overriding metadata via options', () => {
+ const record = Resource.esm('@acme/tools@^1.0.0', 'status', {
+ metadata: { description: 'Custom desc', uri: 'custom://status' },
+ }) as ResourceEsmTargetRecord;
+ expect(record.metadata.description).toBe('Custom desc');
+ expect(record.metadata.uri).toBe('custom://status');
+ expect(record.metadata.name).toBe('status');
+ });
+
+ it('throws on empty specifier', () => {
+ expect(() => Resource.esm('', 'status')).toThrow('Package specifier cannot be empty');
+ });
+
+ it('throws on invalid specifier', () => {
+ expect(() => Resource.esm('!!!', 'status')).toThrow('Invalid package specifier');
+ });
+});
+
+describe('Resource.remote()', () => {
+ it('creates ResourceRemoteRecord with kind REMOTE', () => {
+ const record = Resource.remote('https://api.example.com/mcp', 'config') as ResourceRemoteRecord;
+ expect(record.kind).toBe(ResourceKind.REMOTE);
+ });
+
+ it('sets url and targetName', () => {
+ const record = Resource.remote('https://api.example.com/mcp', 'config') as ResourceRemoteRecord;
+ expect(record.url).toBe('https://api.example.com/mcp');
+ expect(record.targetName).toBe('config');
+ });
+
+ it('creates unique symbol provide token', () => {
+ const record = Resource.remote('https://api.example.com/mcp', 'config') as ResourceRemoteRecord;
+ expect(typeof record.provide).toBe('symbol');
+ expect(record.provide.toString()).toContain('remote-resource:https://api.example.com/mcp:config');
+ });
+
+ it('passes transportOptions and remoteAuth', () => {
+ const record = Resource.remote('https://api.example.com/mcp', 'config', {
+ transportOptions: { timeout: 30000 },
+ remoteAuth: { mode: 'static', credentials: { type: 'bearer', value: 'tok' } },
+ }) as ResourceRemoteRecord;
+ expect(record.transportOptions).toEqual({ timeout: 30000 });
+ expect(record.remoteAuth).toEqual({
+ mode: 'static',
+ credentials: { type: 'bearer', value: 'tok' },
+ });
+ });
+
+ it('generates placeholder metadata with uri', () => {
+ const record = Resource.remote('https://api.example.com/mcp', 'config') as ResourceRemoteRecord;
+ expect(record.metadata.name).toBe('config');
+ expect(record.metadata.uri).toBe('remote://config');
+ });
+
+ it('allows overriding metadata via options', () => {
+ const record = Resource.remote('https://api.example.com/mcp', 'config', {
+ metadata: { description: 'Custom remote resource' },
+ }) as ResourceRemoteRecord;
+ expect(record.metadata.description).toBe('Custom remote resource');
+ });
+});
+
+describe('normalizeResource() with ESM/REMOTE records', () => {
+ it('passes through ResourceEsmTargetRecord unchanged', () => {
+ const record = Resource.esm('@acme/tools@^1.0.0', 'status') as ResourceEsmTargetRecord;
+ const normalized = normalizeResource(record);
+ expect(normalized).toBe(record);
+ expect(normalized.kind).toBe(ResourceKind.ESM);
+ });
+
+ it('passes through ResourceRemoteRecord unchanged', () => {
+ const record = Resource.remote('https://api.example.com/mcp', 'config') as ResourceRemoteRecord;
+ const normalized = normalizeResource(record);
+ expect(normalized).toBe(record);
+ expect(normalized.kind).toBe(ResourceKind.REMOTE);
+ });
+});
+
+describe('resourceDiscoveryDeps() with ESM/REMOTE records', () => {
+ it('returns empty array for ESM record', () => {
+ const record = Resource.esm('@acme/tools@^1.0.0', 'status') as ResourceEsmTargetRecord;
+ expect(resourceDiscoveryDeps(record)).toEqual([]);
+ });
+
+ it('returns empty array for REMOTE record', () => {
+ const record = Resource.remote('https://api.example.com/mcp', 'config') as ResourceRemoteRecord;
+ expect(resourceDiscoveryDeps(record)).toEqual([]);
+ });
+});
diff --git a/libs/sdk/src/resource/resource.utils.ts b/libs/sdk/src/resource/resource.utils.ts
index 10ea04da7..97e6fbdca 100644
--- a/libs/sdk/src/resource/resource.utils.ts
+++ b/libs/sdk/src/resource/resource.utils.ts
@@ -56,6 +56,11 @@ export function collectResourceTemplateMetadata(cls: ResourceTemplateType): Reso
* Normalize any resource input (class or function) to a ResourceRecord
*/
export function normalizeResource(item: any): ResourceRecord {
+ // ESM/REMOTE record objects (from Resource.esm() / Resource.remote())
+ if (item && typeof item === 'object' && (item.kind === ResourceKind.ESM || item.kind === ResourceKind.REMOTE)) {
+ return item as ResourceRecord;
+ }
+
// Function-style decorator: resource({ uri: '...' })(handler)
if (
item &&
@@ -141,5 +146,9 @@ export function resourceDiscoveryDeps(rec: ResourceRecord | ResourceTemplateReco
case ResourceKind.CLASS_TOKEN:
case ResourceTemplateKind.CLASS_TOKEN:
return depsOfClass(rec.provide, 'discovery');
+ case ResourceKind.ESM:
+ case ResourceKind.REMOTE:
+ // External packages/services have no local DI dependencies at discovery time
+ return [];
}
}
diff --git a/libs/sdk/src/scope/__tests__/scope-init-perf.spec.ts b/libs/sdk/src/scope/__tests__/scope-init-perf.spec.ts
index daf244d1e..9e0c6089a 100644
--- a/libs/sdk/src/scope/__tests__/scope-init-perf.spec.ts
+++ b/libs/sdk/src/scope/__tests__/scope-init-perf.spec.ts
@@ -135,9 +135,15 @@ describe('Scope initialization performance', () => {
}
const fullMs = performance.now() - fullStart;
- // Lite should not be dramatically slower than full parse.
- // We use a 3x margin to tolerate CI variance on small inputs.
- expect(liteMs).toBeLessThanOrEqual(fullMs * 3);
+ // Log timing info only when FRONTMCP_PERF=1 is set
+ if (process.env['FRONTMCP_PERF'] === '1') {
+ console.log(
+ `Lite: ${liteMs.toFixed(2)}ms | Full: ${fullMs.toFixed(2)}ms | Ratio: ${(liteMs / fullMs).toFixed(2)}x`,
+ );
+ }
+
+ // Guard against catastrophic regression — lite should not be 10x slower than full
+ expect(liteMs).toBeLessThanOrEqual(fullMs * 10);
});
it('ToolEntry should cache getInputJsonSchema result', () => {
diff --git a/libs/sdk/src/skill/__tests__/skill-npm-remote.spec.ts b/libs/sdk/src/skill/__tests__/skill-npm-remote.spec.ts
new file mode 100644
index 000000000..3cc2e2c2c
--- /dev/null
+++ b/libs/sdk/src/skill/__tests__/skill-npm-remote.spec.ts
@@ -0,0 +1,149 @@
+import { Skill } from '../../common/decorators/skill.decorator';
+import { SkillKind } from '../../common/records/skill.record';
+import type { SkillEsmTargetRecord, SkillRemoteRecord } from '../../common/records/skill.record';
+import { normalizeSkill, isSkillRecord, skillDiscoveryDeps } from '../skill.utils';
+
+describe('Skill.esm()', () => {
+ it('creates SkillEsmTargetRecord with kind ESM', () => {
+ const record = Skill.esm('@acme/skills@^1.0.0', 'deploy') as SkillEsmTargetRecord;
+ expect(record.kind).toBe(SkillKind.ESM);
+ });
+
+ it('parses scoped specifier correctly', () => {
+ const record = Skill.esm('@acme/skills@^1.0.0', 'deploy') as SkillEsmTargetRecord;
+ expect(record.specifier.scope).toBe('@acme');
+ expect(record.specifier.name).toBe('skills');
+ expect(record.specifier.fullName).toBe('@acme/skills');
+ expect(record.specifier.range).toBe('^1.0.0');
+ });
+
+ it('parses unscoped specifier', () => {
+ const record = Skill.esm('my-skills@latest', 'review') as SkillEsmTargetRecord;
+ expect(record.specifier.scope).toBeUndefined();
+ expect(record.specifier.fullName).toBe('my-skills');
+ });
+
+ it('sets targetName', () => {
+ const record = Skill.esm('@acme/skills@^1.0.0', 'deploy') as SkillEsmTargetRecord;
+ expect(record.targetName).toBe('deploy');
+ });
+
+ it('creates unique symbol provide token', () => {
+ const record = Skill.esm('@acme/skills@^1.0.0', 'deploy') as SkillEsmTargetRecord;
+ expect(typeof record.provide).toBe('symbol');
+ expect(record.provide.toString()).toContain('esm-skill:@acme/skills:deploy');
+ });
+
+ it('creates different symbols for different targets', () => {
+ const r1 = Skill.esm('@acme/skills@^1.0.0', 'deploy') as SkillEsmTargetRecord;
+ const r2 = Skill.esm('@acme/skills@^1.0.0', 'review') as SkillEsmTargetRecord;
+ expect(r1.provide).not.toBe(r2.provide);
+ });
+
+ it('passes options through', () => {
+ const record = Skill.esm('@acme/skills@^1.0.0', 'deploy', {
+ cacheTTL: 180000,
+ }) as SkillEsmTargetRecord;
+ expect(record.options?.cacheTTL).toBe(180000);
+ });
+
+ it('generates placeholder metadata', () => {
+ const record = Skill.esm('@acme/skills@^1.0.0', 'deploy') as SkillEsmTargetRecord;
+ expect(record.metadata.name).toBe('deploy');
+ expect(record.metadata.description).toContain('deploy');
+ expect(record.metadata.description).toContain('@acme/skills');
+ });
+
+ it('allows overriding metadata via options', () => {
+ const record = Skill.esm('@acme/skills@^1.0.0', 'deploy', {
+ metadata: { description: 'Custom deploy skill' },
+ }) as SkillEsmTargetRecord;
+ expect(record.metadata.description).toBe('Custom deploy skill');
+ expect(record.metadata.name).toBe('deploy');
+ });
+
+ it('throws on empty specifier', () => {
+ expect(() => Skill.esm('', 'deploy')).toThrow('Package specifier cannot be empty');
+ });
+
+ it('throws on invalid specifier', () => {
+ expect(() => Skill.esm('!!!', 'deploy')).toThrow('Invalid package specifier');
+ });
+});
+
+describe('Skill.remote()', () => {
+ it('creates SkillRemoteRecord with kind REMOTE', () => {
+ const record = Skill.remote('https://api.example.com/mcp', 'audit') as SkillRemoteRecord;
+ expect(record.kind).toBe(SkillKind.REMOTE);
+ });
+
+ it('sets url and targetName', () => {
+ const record = Skill.remote('https://api.example.com/mcp', 'audit') as SkillRemoteRecord;
+ expect(record.url).toBe('https://api.example.com/mcp');
+ expect(record.targetName).toBe('audit');
+ });
+
+ it('creates unique symbol provide token', () => {
+ const record = Skill.remote('https://api.example.com/mcp', 'audit') as SkillRemoteRecord;
+ expect(typeof record.provide).toBe('symbol');
+ expect(record.provide.toString()).toContain('remote-skill:https://api.example.com/mcp:audit');
+ });
+
+ it('passes transportOptions and remoteAuth', () => {
+ const record = Skill.remote('https://api.example.com/mcp', 'audit', {
+ transportOptions: { timeout: 45000 },
+ remoteAuth: { mode: 'static', credentials: { type: 'bearer', value: 'sk-123' } },
+ }) as SkillRemoteRecord;
+ expect(record.transportOptions).toEqual({ timeout: 45000 });
+ expect(record.remoteAuth).toEqual({
+ mode: 'static',
+ credentials: { type: 'bearer', value: 'sk-123' },
+ });
+ });
+
+ it('generates placeholder metadata', () => {
+ const record = Skill.remote('https://api.example.com/mcp', 'audit') as SkillRemoteRecord;
+ expect(record.metadata.name).toBe('audit');
+ expect(record.metadata.description).toContain('audit');
+ });
+});
+
+describe('isSkillRecord() with ESM/REMOTE records', () => {
+ it('recognizes SkillEsmTargetRecord', () => {
+ const record = Skill.esm('@acme/skills@^1.0.0', 'deploy') as SkillEsmTargetRecord;
+ expect(isSkillRecord(record)).toBe(true);
+ });
+
+ it('recognizes SkillRemoteRecord', () => {
+ const record = Skill.remote('https://api.example.com/mcp', 'audit') as SkillRemoteRecord;
+ expect(isSkillRecord(record)).toBe(true);
+ });
+});
+
+describe('normalizeSkill() with ESM/REMOTE records', () => {
+ it('passes through SkillEsmTargetRecord unchanged', () => {
+ const record = Skill.esm('@acme/skills@^1.0.0', 'deploy') as SkillEsmTargetRecord;
+ const normalized = normalizeSkill(record);
+ expect(normalized).toBe(record);
+ expect(normalized.kind).toBe(SkillKind.ESM);
+ });
+
+ it('passes through SkillRemoteRecord unchanged', () => {
+ const record = Skill.remote('https://api.example.com/mcp', 'audit') as SkillRemoteRecord;
+ const normalized = normalizeSkill(record);
+ expect(normalized).toBe(record);
+ expect(normalized.kind).toBe(SkillKind.REMOTE);
+ });
+});
+
+describe('skillDiscoveryDeps() with ESM/REMOTE records', () => {
+ it('returns empty array for ESM record', () => {
+ const record = Skill.esm('@acme/skills@^1.0.0', 'deploy') as SkillEsmTargetRecord;
+ expect(skillDiscoveryDeps(record)).toEqual([]);
+ });
+
+ it('returns empty array for REMOTE record', () => {
+ const record = Skill.remote('https://api.example.com/mcp', 'audit') as SkillRemoteRecord;
+ expect(skillDiscoveryDeps(record)).toEqual([]);
+ });
+});
diff --git a/libs/sdk/src/skill/skill.utils.ts b/libs/sdk/src/skill/skill.utils.ts
index f42f6c69f..76f215a32 100644
--- a/libs/sdk/src/skill/skill.utils.ts
+++ b/libs/sdk/src/skill/skill.utils.ts
@@ -95,7 +95,7 @@ export function isSkillRecord(item: unknown): item is SkillRecord {
}
// Validate kind is one of the allowed values
- const validKinds = [SkillKind.CLASS_TOKEN, SkillKind.VALUE, SkillKind.FILE];
+ const validKinds = [SkillKind.CLASS_TOKEN, SkillKind.VALUE, SkillKind.FILE, SkillKind.ESM, SkillKind.REMOTE];
if (!validKinds.includes(record['kind'] as SkillKind)) {
return false;
}
@@ -120,7 +120,9 @@ export function skillDiscoveryDeps(rec: SkillRecord): Token[] {
return depsOfClass(rec.provide, 'discovery');
case SkillKind.VALUE:
case SkillKind.FILE:
- // Value and file records don't have class dependencies
+ case SkillKind.ESM:
+ case SkillKind.REMOTE:
+ // Value, file, ESM, and remote records don't have class dependencies
return [];
}
}
diff --git a/libs/sdk/src/tool/__tests__/tool-npm-remote.spec.ts b/libs/sdk/src/tool/__tests__/tool-npm-remote.spec.ts
new file mode 100644
index 000000000..a62c5dac9
--- /dev/null
+++ b/libs/sdk/src/tool/__tests__/tool-npm-remote.spec.ts
@@ -0,0 +1,169 @@
+import { Tool } from '../../common/decorators/tool.decorator';
+import { ToolKind } from '../../common/records/tool.record';
+import type { ToolEsmTargetRecord, ToolRemoteRecord } from '../../common/records/tool.record';
+import { normalizeTool, toolDiscoveryDeps } from '../tool.utils';
+
+describe('Tool.esm()', () => {
+ it('creates ToolEsmTargetRecord with kind ESM', () => {
+ const record = (Tool as any).esm('@acme/tools@^1.0.0', 'echo') as ToolEsmTargetRecord;
+ expect(record.kind).toBe(ToolKind.ESM);
+ });
+
+ it('parses scoped specifier correctly', () => {
+ const record = (Tool as any).esm('@acme/tools@^1.0.0', 'echo') as ToolEsmTargetRecord;
+ expect(record.specifier.scope).toBe('@acme');
+ expect(record.specifier.name).toBe('tools');
+ expect(record.specifier.fullName).toBe('@acme/tools');
+ expect(record.specifier.range).toBe('^1.0.0');
+ });
+
+ it('parses unscoped specifier correctly', () => {
+ const record = (Tool as any).esm('my-tools@latest', 'echo') as ToolEsmTargetRecord;
+ expect(record.specifier.scope).toBeUndefined();
+ expect(record.specifier.name).toBe('my-tools');
+ expect(record.specifier.fullName).toBe('my-tools');
+ expect(record.specifier.range).toBe('latest');
+ });
+
+ it('parses specifier without version range', () => {
+ const record = (Tool as any).esm('my-tools', 'echo') as ToolEsmTargetRecord;
+ expect(record.specifier.range).toBe('latest');
+ });
+
+ it('sets targetName', () => {
+ const record = (Tool as any).esm('@acme/tools@^1.0.0', 'echo') as ToolEsmTargetRecord;
+ expect(record.targetName).toBe('echo');
+ });
+
+ it('creates unique symbol provide token', () => {
+ const record = (Tool as any).esm('@acme/tools@^1.0.0', 'echo') as ToolEsmTargetRecord;
+ expect(typeof record.provide).toBe('symbol');
+ expect(record.provide.toString()).toContain('esm-tool:@acme/tools:echo');
+ });
+
+ it('creates different symbols for different targets', () => {
+ const r1 = (Tool as any).esm('@acme/tools@^1.0.0', 'echo') as ToolEsmTargetRecord;
+ const r2 = (Tool as any).esm('@acme/tools@^1.0.0', 'add') as ToolEsmTargetRecord;
+ expect(r1.provide).not.toBe(r2.provide);
+ });
+
+ it('passes options through (loader, cacheTTL)', () => {
+ const record = (Tool as any).esm('@acme/tools@^1.0.0', 'echo', {
+ loader: { url: 'https://custom.cdn' },
+ cacheTTL: 60000,
+ }) as ToolEsmTargetRecord;
+ expect(record.options?.loader).toEqual({ url: 'https://custom.cdn' });
+ expect(record.options?.cacheTTL).toBe(60000);
+ });
+
+ it('generates placeholder metadata', () => {
+ const record = (Tool as any).esm('@acme/tools@^1.0.0', 'echo') as ToolEsmTargetRecord;
+ expect(record.metadata.name).toBe('echo');
+ expect(record.metadata.description).toContain('echo');
+ expect(record.metadata.description).toContain('@acme/tools');
+ expect(record.metadata.inputSchema).toEqual({});
+ });
+
+ it('allows overriding metadata via options', () => {
+ const record = (Tool as any).esm('@acme/tools@^1.0.0', 'echo', {
+ metadata: { description: 'Custom description', name: 'custom-echo' },
+ }) as ToolEsmTargetRecord;
+ expect(record.metadata.description).toBe('Custom description');
+ expect(record.metadata.name).toBe('custom-echo');
+ });
+
+ it('merges partial metadata with defaults', () => {
+ const record = (Tool as any).esm('@acme/tools@^1.0.0', 'echo', {
+ metadata: { description: 'Override only description' },
+ }) as ToolEsmTargetRecord;
+ expect(record.metadata.description).toBe('Override only description');
+ expect(record.metadata.inputSchema).toEqual({});
+ });
+
+ it('throws on empty specifier', () => {
+ expect(() => (Tool as any).esm('', 'echo')).toThrow('Package specifier cannot be empty');
+ });
+
+ it('throws on invalid specifier', () => {
+ expect(() => (Tool as any).esm('!!!invalid!!!', 'echo')).toThrow('Invalid package specifier');
+ });
+});
+
+describe('Tool.remote()', () => {
+ it('creates ToolRemoteRecord with kind REMOTE', () => {
+ const record = (Tool as any).remote('https://api.example.com/mcp', 'search') as ToolRemoteRecord;
+ expect(record.kind).toBe(ToolKind.REMOTE);
+ });
+
+ it('sets url and targetName', () => {
+ const record = (Tool as any).remote('https://api.example.com/mcp', 'search') as ToolRemoteRecord;
+ expect(record.url).toBe('https://api.example.com/mcp');
+ expect(record.targetName).toBe('search');
+ });
+
+ it('creates unique symbol provide token', () => {
+ const record = (Tool as any).remote('https://api.example.com/mcp', 'search') as ToolRemoteRecord;
+ expect(typeof record.provide).toBe('symbol');
+ expect(record.provide.toString()).toContain('remote-tool:https://api.example.com/mcp:search');
+ });
+
+ it('passes transportOptions', () => {
+ const record = (Tool as any).remote('https://api.example.com/mcp', 'search', {
+ transportOptions: { timeout: 30000, retryAttempts: 3 },
+ }) as ToolRemoteRecord;
+ expect(record.transportOptions).toEqual({ timeout: 30000, retryAttempts: 3 });
+ });
+
+ it('passes remoteAuth', () => {
+ const record = (Tool as any).remote('https://api.example.com/mcp', 'search', {
+ remoteAuth: { mode: 'static', credentials: { type: 'bearer', value: 'tok' } },
+ }) as ToolRemoteRecord;
+ expect(record.remoteAuth).toEqual({
+ mode: 'static',
+ credentials: { type: 'bearer', value: 'tok' },
+ });
+ });
+
+ it('generates placeholder metadata', () => {
+ const record = (Tool as any).remote('https://api.example.com/mcp', 'search') as ToolRemoteRecord;
+ expect(record.metadata.name).toBe('search');
+ expect(record.metadata.description).toContain('search');
+ expect(record.metadata.description).toContain('https://api.example.com/mcp');
+ });
+
+ it('allows overriding metadata via options', () => {
+ const record = (Tool as any).remote('https://api.example.com/mcp', 'search', {
+ metadata: { description: 'Custom remote desc' },
+ }) as ToolRemoteRecord;
+ expect(record.metadata.description).toBe('Custom remote desc');
+ expect(record.metadata.name).toBe('search');
+ });
+});
+
+describe('normalizeTool() with ESM/REMOTE records', () => {
+ it('passes through ToolEsmTargetRecord unchanged', () => {
+ const record = (Tool as any).esm('@acme/tools@^1.0.0', 'echo') as ToolEsmTargetRecord;
+ const normalized = normalizeTool(record);
+ expect(normalized).toBe(record);
+ expect(normalized.kind).toBe(ToolKind.ESM);
+ });
+
+ it('passes through ToolRemoteRecord unchanged', () => {
+ const record = (Tool as any).remote('https://api.example.com/mcp', 'search') as ToolRemoteRecord;
+ const normalized = normalizeTool(record);
+ expect(normalized).toBe(record);
+ expect(normalized.kind).toBe(ToolKind.REMOTE);
+ });
+});
+
+describe('toolDiscoveryDeps() with ESM/REMOTE records', () => {
+ it('returns empty array for ESM record', () => {
+ const record = (Tool as any).esm('@acme/tools@^1.0.0', 'echo') as ToolEsmTargetRecord;
+ expect(toolDiscoveryDeps(record)).toEqual([]);
+ });
+
+ it('returns empty array for REMOTE record', () => {
+ const record = (Tool as any).remote('https://api.example.com/mcp', 'search') as ToolRemoteRecord;
+ expect(toolDiscoveryDeps(record)).toEqual([]);
+ });
+});
diff --git a/libs/sdk/src/tool/tool.instance.ts b/libs/sdk/src/tool/tool.instance.ts
index 840f125e0..f1f30a4fa 100644
--- a/libs/sdk/src/tool/tool.instance.ts
+++ b/libs/sdk/src/tool/tool.instance.ts
@@ -168,14 +168,16 @@ export class ToolInstance<
}
override parseInput(input: CallToolRequest['params']): CallToolRequest['params']['arguments'] {
- // For remote tools, use passthrough to preserve all arguments since validation
- // happens on the remote server. Remote tools have 'frontmcp:remote' annotation.
+ // Tools backed by raw JSON Schema cannot be validated with the local Zod raw shape.
+ // Preserve their object arguments instead of stripping everything to `{}`.
+ // This covers remote tools plus local adapters (including ESM) that provide
+ // rawInputSchema for discovery/runtime interoperability.
const isRemoteTool = this.metadata.annotations?.['frontmcp:remote'] === true;
+ const hasRawJsonSchema = this.rawInputSchema !== undefined && this.rawInputSchema !== null;
- if (isRemoteTool) {
+ if (isRemoteTool || hasRawJsonSchema) {
// Pass through all arguments without stripping unknown keys
- const inputSchema = z.object(this.inputSchema).passthrough();
- return inputSchema.parse(input.arguments);
+ return z.looseObject({}).parse(input.arguments ?? {});
}
// For local tools, use strict validation
diff --git a/libs/sdk/src/tool/tool.utils.ts b/libs/sdk/src/tool/tool.utils.ts
index 7ef0def6b..7477972d1 100644
--- a/libs/sdk/src/tool/tool.utils.ts
+++ b/libs/sdk/src/tool/tool.utils.ts
@@ -1,6 +1,7 @@
// file: libs/sdk/src/tool/tool.utils.ts
import { Token, Type, depsOfClass, depsOfFunc, isClass, getMetadata } from '@frontmcp/di';
import { InvalidEntityError } from '../errors';
+import { parsePackageSpecifier, isPackageSpecifier } from '../esm-loader/package-specifier';
import {
ToolMetadata,
FrontMcpToolTokens,
@@ -47,6 +48,29 @@ export function collectToolMetadata(cls: ToolType): ToolMetadata {
}
export function normalizeTool(item: any): ToolRecord {
+ // ESM/REMOTE record objects (from Tool.esm() / Tool.remote())
+ if (item && typeof item === 'object' && (item.kind === ToolKind.ESM || item.kind === ToolKind.REMOTE)) {
+ return item as ToolRecord;
+ }
+
+ // ESM package specifier string (e.g., '@acme/mcp-tools@^1.0.0')
+ if (typeof item === 'string') {
+ if (isPackageSpecifier(item)) {
+ const specifier = parsePackageSpecifier(item);
+ return {
+ kind: ToolKind.ESM,
+ provide: item,
+ specifier,
+ metadata: {
+ name: specifier.fullName,
+ description: `ESM tools from ${specifier.fullName}`,
+ inputSchema: {},
+ },
+ };
+ }
+ throw new InvalidEntityError('tool', item, 'a class, a tool object, or a valid package specifier');
+ }
+
if (
item &&
typeof item === 'function' &&
@@ -80,6 +104,10 @@ export function toolDiscoveryDeps(rec: ToolRecord): Token[] {
return depsOfFunc(rec.provide, 'discovery');
case ToolKind.CLASS_TOKEN:
return depsOfClass(rec.provide, 'discovery');
+ case ToolKind.ESM:
+ case ToolKind.REMOTE:
+ // External packages/services have no local DI dependencies at discovery time
+ return [];
}
}
diff --git a/libs/sdk/src/transport/adapters/transport.sse.adapter.ts b/libs/sdk/src/transport/adapters/transport.sse.adapter.ts
index 3a602a591..2dc94a1a5 100644
--- a/libs/sdk/src/transport/adapters/transport.sse.adapter.ts
+++ b/libs/sdk/src/transport/adapters/transport.sse.adapter.ts
@@ -81,7 +81,6 @@ export class TransportSSEAdapter extends LocalTransportAdapter,
- url: req.url ? new URL(req.url, 'http://localhost') : undefined,
},
authInfo,
});
diff --git a/libs/testing/src/server/port-registry.ts b/libs/testing/src/server/port-registry.ts
index 08868b3a0..9ccc87d3f 100644
--- a/libs/testing/src/server/port-registry.ts
+++ b/libs/testing/src/server/port-registry.ts
@@ -57,6 +57,11 @@ export const E2E_PORT_RANGES = {
'demo-e2e-uipack': { start: 50320, size: 10 },
'demo-e2e-agent-adapters': { start: 50330, size: 10 },
+ // ESM E2E tests (50400-50449)
+ 'esm-package-server': { start: 50400, size: 10 },
+ 'esm-package-server-hot-reload': { start: 50410, size: 10 },
+ 'esm-package-server-cli': { start: 50420, size: 10 },
+
// Mock servers and utilities (50900-50999)
'mock-oauth': { start: 50900, size: 10 },
'mock-api': { start: 50910, size: 10 },
diff --git a/libs/testing/src/server/test-server.ts b/libs/testing/src/server/test-server.ts
index 0252d1936..2dce57ab2 100644
--- a/libs/testing/src/server/test-server.ts
+++ b/libs/testing/src/server/test-server.ts
@@ -4,6 +4,7 @@
*/
import { spawn, ChildProcess } from 'child_process';
+import { sha256Hex } from '@frontmcp/utils';
import { ServerStartError } from '../errors';
import { reservePort } from './port-registry';
@@ -98,18 +99,47 @@ export class TestServer {
* Start a test server with custom command
*/
static async start(options: TestServerOptions): Promise {
- // Use port registry for allocation
const project = options.project ?? 'default';
- const { port, release } = await reservePort(project, options.port);
+ const maxAttempts = 3;
- const server = new TestServer(options, port, release);
- try {
- await server.startProcess();
- } catch (error) {
- await server.stop(); // Clean up spawned process to prevent leaks
- throw error;
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ const { port, release } = await reservePort(project, options.port);
+ const server = new TestServer(options, port, release);
+
+ try {
+ await server.startProcess();
+ return server;
+ } catch (error) {
+ try {
+ await server.stop();
+ } catch (cleanupError) {
+ if (options.debug || DEBUG_SERVER) {
+ const msg = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
+ console.warn(`[TestServer] Cleanup failed after startup error: ${msg}`);
+ }
+ }
+
+ const isEADDRINUSE =
+ error instanceof Error &&
+ (error.message.includes('EADDRINUSE') || server.getLogs().some((l) => l.includes('EADDRINUSE')));
+
+ if (isEADDRINUSE && attempt < maxAttempts) {
+ const delayMs = attempt * 500;
+ if (options.debug || DEBUG_SERVER) {
+ console.warn(
+ `[TestServer] EADDRINUSE on port ${port}, retrying in ${delayMs}ms (attempt ${attempt}/${maxAttempts})`,
+ );
+ }
+ await sleep(delayMs);
+ continue;
+ }
+
+ throw error;
+ }
}
- return server;
+
+ // Unreachable, but TypeScript requires a return
+ throw new Error(`[TestServer] Failed to start after ${maxAttempts} attempts`);
}
/**
@@ -123,25 +153,54 @@ export class TestServer {
);
}
- // Use the Nx project name for port range allocation
- const { port, release } = await reservePort(project, options.port);
+ const maxAttempts = 3;
- const serverOptions: TestServerOptions = {
- ...options,
- port,
- project,
- command: `npx nx serve ${project} --port ${port}`,
- cwd: options.cwd ?? process.cwd(),
- };
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
+ const { port, release } = await reservePort(project, options.port);
- const server = new TestServer(serverOptions, port, release);
- try {
- await server.startProcess();
- } catch (error) {
- await server.stop(); // Clean up spawned process to prevent leaks
- throw error;
+ const serverOptions: TestServerOptions = {
+ ...options,
+ port,
+ project,
+ command: `npx nx serve ${project} --port ${port}`,
+ cwd: options.cwd ?? process.cwd(),
+ };
+
+ const server = new TestServer(serverOptions, port, release);
+ try {
+ await server.startProcess();
+ return server;
+ } catch (error) {
+ try {
+ await server.stop();
+ } catch (cleanupError) {
+ if (options.debug || DEBUG_SERVER) {
+ const msg = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
+ console.warn(`[TestServer] Cleanup failed after startup error: ${msg}`);
+ }
+ }
+
+ const isEADDRINUSE =
+ error instanceof Error &&
+ (error.message.includes('EADDRINUSE') || server.getLogs().some((l) => l.includes('EADDRINUSE')));
+
+ if (isEADDRINUSE && attempt < maxAttempts) {
+ const delayMs = attempt * 500;
+ if (options.debug || DEBUG_SERVER) {
+ console.warn(
+ `[TestServer] EADDRINUSE on port ${port}, retrying in ${delayMs}ms (attempt ${attempt}/${maxAttempts})`,
+ );
+ }
+ await sleep(delayMs);
+ continue;
+ }
+
+ throw error;
+ }
}
- return server;
+
+ // Unreachable, but TypeScript requires a return
+ throw new Error(`[TestServer] Failed to start after ${maxAttempts} attempts`);
}
/**
@@ -290,6 +349,7 @@ export class TestServer {
...this.options.env,
PORT: String(this.options.port),
};
+ const runtimeEnv = withWorkspaceProtocolFallback(env, this.options.cwd);
// Release port reservation just before spawning so the server can bind it
if (this.portRelease) {
@@ -303,7 +363,7 @@ export class TestServer {
// This avoids fragile command parsing with split(' ')
this.process = spawn(this.options.command, [], {
cwd: this.options.cwd,
- env,
+ env: runtimeEnv,
shell: true,
stdio: ['pipe', 'pipe', 'pipe'],
});
@@ -487,6 +547,149 @@ function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+/**
+ * Ensure spawned test servers can resolve the protocol workspace package.
+ *
+ * Some local installs miss the workspace symlink at `node_modules/@frontmcp/protocol`
+ * even though the built package exists under `libs/protocol/dist`. `tsx` does not
+ * reliably honor NODE_PATH in this path, so prefer creating the missing workspace
+ * link and only fall back to NODE_PATH aliasing when that is not possible.
+ */
+function withWorkspaceProtocolFallback(env: NodeJS.ProcessEnv, cwd: string): NodeJS.ProcessEnv {
+ if (findInstalledProtocolPackageDir(cwd)) {
+ return env;
+ }
+
+ try {
+ const workspacePackageDir = findWorkspaceProtocolDir(cwd);
+ if (!workspacePackageDir) {
+ return env;
+ }
+
+ ensureWorkspaceProtocolLink(cwd, workspacePackageDir);
+ if (findInstalledProtocolPackageDir(cwd)) {
+ return env;
+ }
+
+ return withProtocolNodePathAlias(env, cwd, workspacePackageDir);
+ } catch (err) {
+ if (DEBUG_SERVER) {
+ console.error(
+ `[TestServer] Workspace protocol fallback failed: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ return env;
+ }
+}
+
+function ensureWorkspaceProtocolLink(cwd: string, workspacePackageDir: string): void {
+ const fs = require('node:fs');
+ const path = require('node:path');
+
+ const nodeModulesDir = findWorkspaceNodeModulesDir(cwd);
+ if (!nodeModulesDir) {
+ return;
+ }
+ const scopeDir = path.join(nodeModulesDir, '@frontmcp');
+
+ const aliasPackageDir = path.join(scopeDir, 'protocol');
+ if (fs.existsSync(aliasPackageDir)) {
+ return;
+ }
+
+ fs.mkdirSync(scopeDir, { recursive: true });
+ try {
+ fs.symlinkSync(workspacePackageDir, aliasPackageDir, process.platform === 'win32' ? 'junction' : 'dir');
+ } catch (error) {
+ const err = error as NodeJS.ErrnoException;
+ if (err.code !== 'EEXIST') {
+ throw error;
+ }
+ }
+}
+
+// NOTE: The helpers below use synchronous node:fs/node:path/node:os because they
+// run in a synchronous code path. @frontmcp/utils only provides async FS wrappers,
+// so native APIs are required here for existsSync, mkdirSync, and symlinkSync.
+
+function withProtocolNodePathAlias(
+ env: NodeJS.ProcessEnv,
+ cwd: string,
+ workspacePackageDir: string,
+): NodeJS.ProcessEnv {
+ const fs = require('node:fs');
+ const os = require('node:os');
+ const path = require('node:path');
+
+ // Use a short deterministic hash to avoid Windows 260-char path limits
+ // (Buffer.from(cwd).toString('hex') would produce very long directory names).
+ const aliasRoot = path.join(os.tmpdir(), 'frontmcp-test-node-path', sha256Hex(cwd).slice(0, 12));
+ const scopeDir = path.join(aliasRoot, '@frontmcp');
+ const aliasPackageDir = path.join(scopeDir, 'protocol');
+
+ fs.mkdirSync(scopeDir, { recursive: true });
+ try {
+ if (!fs.existsSync(aliasPackageDir)) {
+ fs.symlinkSync(workspacePackageDir, aliasPackageDir, process.platform === 'win32' ? 'junction' : 'dir');
+ }
+ } catch (error) {
+ const err = error as NodeJS.ErrnoException;
+ if (err.code !== 'EEXIST') throw error;
+ }
+
+ const existingNodePath = env['NODE_PATH'];
+ const nodePathEntries = [aliasRoot, ...(existingNodePath ? existingNodePath.split(path.delimiter) : [])].filter(
+ Boolean,
+ );
+
+ return {
+ ...env,
+ NODE_PATH: [...new Set(nodePathEntries)].join(path.delimiter),
+ };
+}
+
+/**
+ * Walk up from startDir until testFn returns a truthy value, or reach the root.
+ */
+function findUp(startDir: string, testFn: (dir: string) => T | undefined): T | undefined {
+ const path = require('node:path');
+ let currentDir = startDir;
+ while (true) {
+ const result = testFn(currentDir);
+ if (result !== undefined) return result;
+ const parentDir = path.dirname(currentDir);
+ if (parentDir === currentDir) return undefined;
+ currentDir = parentDir;
+ }
+}
+
+function findWorkspaceProtocolDir(startDir: string): string | undefined {
+ const fs = require('node:fs');
+ const path = require('node:path');
+ return findUp(startDir, (dir) => {
+ const candidate = path.join(dir, 'libs', 'protocol');
+ return fs.existsSync(path.join(candidate, 'dist', 'index.js')) ? candidate : undefined;
+ });
+}
+
+function findInstalledProtocolPackageDir(startDir: string): string | undefined {
+ const fs = require('node:fs');
+ const path = require('node:path');
+ return findUp(startDir, (dir) => {
+ const candidate = path.join(dir, 'node_modules', '@frontmcp', 'protocol');
+ return fs.existsSync(path.join(candidate, 'package.json')) ? candidate : undefined;
+ });
+}
+
+function findWorkspaceNodeModulesDir(startDir: string): string | undefined {
+ const fs = require('node:fs');
+ const path = require('node:path');
+ return findUp(startDir, (dir) => {
+ const candidate = path.join(dir, 'node_modules');
+ return fs.existsSync(candidate) ? candidate : undefined;
+ });
+}
+
// Re-export port registry utilities
export {
reservePort,
diff --git a/yarn.lock b/yarn.lock
index d43010a0e..56442d267 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -13982,7 +13982,7 @@ semver@^6.0.0, semver@^6.3.1:
resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==
-semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4, semver@^7.6.3, semver@^7.7.2, semver@^7.7.3:
+semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3, semver@^7.7.2, semver@^7.7.3:
version "7.7.4"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a"
integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==