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==