From 1c72d48fc00833f87771d09ece0e6a58ac7f39f9 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 15:47:21 +0200 Subject: [PATCH 01/25] perf(plugins,edge): import @frontmcp/utils from plugin-cache, plugin-dashboard and edge instead of bundling it The three packages bundled @frontmcp/utils, so their builds imported utils' own dependencies (@noble/ciphers, @noble/hashes, @enclave-vm/ast, @upstash/redis, @vercel/kv, ioredis, zod, @frontmcp/lazy-zod) without declaring them, and loaded a second copy of utils next to the SDK's. - add @frontmcp/utils to the external list of the CommonJS and ESM builds - declare @frontmcp/utils at the internal version in plugin-dashboard and edge - drop the @enclave-vm/ast dependency added in #953; utils declares it - add demo-e2e-published-imports: packs each package, fails on bundled utils code or an undeclared bare import, and loads all three under require and import in a fresh npm project with @enclave-vm/ast 2.16.0 at the top level Closes #956 --- .../e2e/enclave-2-16-top-level.e2e.spec.ts | 58 ++++++ .../e2e/helpers/published-package.ts | 178 ++++++++++++++++++ .../e2e/published-imports.e2e.spec.ts | 63 +++++++ .../jest.e2e.config.ts | 23 +++ .../demo-e2e-published-imports/project.json | 19 ++ .../tsconfig.e2e.json | 10 + .../demo-e2e-published-imports/tsconfig.json | 9 + libs/edge/package.json | 4 +- libs/edge/project.json | 4 +- plugins/plugin-cache/package.json | 1 - plugins/plugin-cache/project.json | 20 +- plugins/plugin-dashboard/package.json | 2 +- plugins/plugin-dashboard/project.json | 4 +- yarn.lock | 5 +- 14 files changed, 387 insertions(+), 13 deletions(-) create mode 100644 apps/e2e/demo-e2e-published-imports/e2e/enclave-2-16-top-level.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-published-imports/e2e/helpers/published-package.ts create mode 100644 apps/e2e/demo-e2e-published-imports/e2e/published-imports.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-published-imports/jest.e2e.config.ts create mode 100644 apps/e2e/demo-e2e-published-imports/project.json create mode 100644 apps/e2e/demo-e2e-published-imports/tsconfig.e2e.json create mode 100644 apps/e2e/demo-e2e-published-imports/tsconfig.json diff --git a/apps/e2e/demo-e2e-published-imports/e2e/enclave-2-16-top-level.e2e.spec.ts b/apps/e2e/demo-e2e-published-imports/e2e/enclave-2-16-top-level.e2e.spec.ts new file mode 100644 index 000000000..1f89bffd8 --- /dev/null +++ b/apps/e2e/demo-e2e-published-imports/e2e/enclave-2-16-top-level.e2e.spec.ts @@ -0,0 +1,58 @@ +// 1.10.9 failed here with ERR_PACKAGE_PATH_NOT_EXPORTED: bundled utils code resolved the top-level @enclave-vm/ast, which has no ./redos (#956) +import { execFileSync, spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; + +import { mkdir, mkdtemp, readJSON, rm, writeJSON } from '@frontmcp/utils'; + +import { + builtDistDir, + internalClosure, + packPackage, + workspaceDistDirs, + type PackageManifest, +} from './helpers/published-package'; + +const PACKAGES = ['@frontmcp/plugin-cache', '@frontmcp/plugin-dashboard', '@frontmcp/edge']; +const TOP_LEVEL_ENCLAVE = '2.16.0'; + +function load(project: string, args: string[]): string { + const result = spawnSync(process.execPath, args, { cwd: project, encoding: 'utf8' }); + return result.status === 0 ? 'loaded' : result.stderr; +} + +describe(`a fresh npm project with @enclave-vm/ast ${TOP_LEVEL_ENCLAVE} at the top level`, () => { + let project: string; + + beforeAll(async () => { + project = await mkdtemp(path.join(tmpdir(), 'frontmcp-enclave-2-16-')); + const tarballs = path.join(project, 'tarballs'); + await mkdir(tarballs); + const distDirs = await workspaceDistDirs(); + const dependencies: Record = { '@enclave-vm/ast': TOP_LEVEL_ENCLAVE }; + for (const name of await internalClosure(PACKAGES, distDirs)) { + dependencies[name] = `file:${packPackage(await builtDistDir(distDirs, name), tarballs).tarball}`; + } + await writeJSON(path.join(project, 'package.json'), { name: 'enclave-2-16-project', private: true, dependencies }); + execFileSync('npm', ['install', '--no-audit', '--no-fund', '--ignore-scripts'], { cwd: project, stdio: 'pipe' }); + }, 300_000); + + afterAll(async () => { + if (project) await rm(project, { recursive: true, force: true }); + }); + + it(`keeps @enclave-vm/ast ${TOP_LEVEL_ENCLAVE} at the top level`, async () => { + const manifest = await readJSON( + path.join(project, 'node_modules', '@enclave-vm', 'ast', 'package.json'), + ); + expect(manifest?.version).toBe(TOP_LEVEL_ENCLAVE); + }); + + it.each(PACKAGES)('%s loads with require', (name) => { + expect(load(project, ['-e', `require(${JSON.stringify(name)})`])).toBe('loaded'); + }); + + it.each(PACKAGES)('%s loads with import', (name) => { + expect(load(project, ['--input-type=module', '-e', `await import(${JSON.stringify(name)})`])).toBe('loaded'); + }); +}); diff --git a/apps/e2e/demo-e2e-published-imports/e2e/helpers/published-package.ts b/apps/e2e/demo-e2e-published-imports/e2e/helpers/published-package.ts new file mode 100644 index 000000000..4777a4481 --- /dev/null +++ b/apps/e2e/demo-e2e-published-imports/e2e/helpers/published-package.ts @@ -0,0 +1,178 @@ +import { execFileSync } from 'node:child_process'; +import { isBuiltin } from 'node:module'; +import * as path from 'node:path'; + +import * as ts from 'typescript'; + +import { fileExists, mkdir, readdir, readFile, readJSON } from '@frontmcp/utils'; + +export const WORKSPACE = path.resolve(__dirname, '../../../../..'); + +export interface PackageManifest { + name: string; + version: string; + dependencies?: Record; + peerDependencies?: Record; + peerDependenciesMeta?: Record; + optionalDependencies?: Record; +} + +export interface PublishedScript { + path: string; + source: string; +} + +export interface PublishedPackage { + manifest: PackageManifest; + scripts: PublishedScript[]; +} + +export async function workspaceDistDirs(): Promise> { + const distDirs = new Map(); + for (const group of ['libs', 'plugins']) { + for (const entry of await readdir(path.join(WORKSPACE, group))) { + const manifest = await readJSON(path.join(WORKSPACE, group, entry, 'package.json')); + if (manifest) distDirs.set(manifest.name, path.join(WORKSPACE, group, entry, 'dist')); + } + } + return distDirs; +} + +export async function builtDistDir(distDirs: Map, name: string): Promise { + const distDir = distDirs.get(name); + if (!distDir || !(await fileExists(path.join(distDir, 'package.json')))) { + throw new Error(`${name} is not built: run its build target first`); + } + return distDir; +} + +export function packPackage(distDir: string, destination: string): { tarball: string; files: string[] } { + const output = execFileSync('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', destination], { + cwd: distDir, + encoding: 'utf8', + }); + const [result] = JSON.parse(output) as Array<{ filename: string; files: Array<{ path: string }> }>; + return { tarball: path.join(destination, result.filename), files: result.files.map((file) => file.path) }; +} + +export async function readPublishedPackage(distDir: string, workDir: string): Promise { + const { tarball, files } = packPackage(distDir, workDir); + const extracted = path.join(workDir, path.basename(tarball, '.tgz')); + await mkdir(extracted, { recursive: true }); + execFileSync('tar', ['-xzf', tarball, '-C', extracted]); + const root = path.join(extracted, 'package'); + const manifest = JSON.parse(await readFile(path.join(root, 'package.json'))) as PackageManifest; + const scripts: PublishedScript[] = []; + for (const file of files.filter((name) => /\.[cm]?js$/.test(name))) { + scripts.push({ path: file, source: await readFile(path.join(root, file)) }); + } + return { manifest, scripts }; +} + +// The @frontmcp packages npm installs with `names`: dependencies and non-optional peers, transitively +export async function internalClosure(names: readonly string[], distDirs: Map): Promise { + const closure = new Set(); + const pending = [...names]; + while (pending.length > 0) { + const name = pending.pop(); + if (!name || closure.has(name)) continue; + closure.add(name); + const manifest = await readJSON(path.join(await builtDistDir(distDirs, name), 'package.json')); + const requiredPeers = Object.keys(manifest?.peerDependencies ?? {}).filter( + (peer) => !manifest?.peerDependenciesMeta?.[peer]?.optional, + ); + const installed = [...Object.keys(manifest?.dependencies ?? {}), ...requiredPeers]; + pending.push(...installed.filter((dependency) => dependency.startsWith('@frontmcp/'))); + } + return [...closure].sort(); +} + +function parse(script: PublishedScript): ts.SourceFile { + return ts.createSourceFile(script.path, script.source, ts.ScriptTarget.Latest, false, ts.ScriptKind.JS); +} + +// esbuild's ESM output loads a CommonJS-only external through its `__require` shim +function isModuleLoad(callee: ts.Expression): boolean { + if (callee.kind === ts.SyntaxKind.ImportKeyword) return true; + return ts.isIdentifier(callee) && (callee.text === 'require' || callee.text === '__require'); +} + +export function loadedSpecifiers(script: PublishedScript): string[] { + const specifiers: string[] = []; + const visit = (node: ts.Node): void => { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + specifiers.push(node.moduleSpecifier.text); + } else if (ts.isCallExpression(node) && isModuleLoad(node.expression)) { + const [first] = node.arguments; + if (first && ts.isStringLiteralLike(first)) specifiers.push(first.text); + } + ts.forEachChild(node, visit); + }; + visit(parse(script)); + return specifiers; +} + +// undefined for relative paths, `#` imports, URLs and Node builtins +export function packageNameOf(specifier: string): string | undefined { + if (/^[./#]/.test(specifier) || specifier.includes(':') || isBuiltin(specifier)) return undefined; + const segments = specifier.split('/'); + return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]; +} + +export function undeclaredImports(published: PublishedPackage): string[] { + const { manifest } = published; + const declared = new Set([ + manifest.name, + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.peerDependencies ?? {}), + ...Object.keys(manifest.optionalDependencies ?? {}), + ]); + const undeclared = new Set(); + for (const script of published.scripts) { + for (const specifier of loadedSpecifiers(script)) { + const name = packageNameOf(specifier); + if (name && !declared.has(name)) undeclared.add(`${script.path}: ${specifier}`); + } + } + return [...undeclared].sort(); +} + +export function exportedNames(script: PublishedScript): Set { + const names = new Set(); + for (const statement of parse(script).statements) { + if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) { + for (const element of statement.exportClause.elements) names.add(element.name.text); + } + } + return names; +} + +function definedName(node: ts.Node): string | undefined { + if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) return node.name.text; + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + (ts.isFunctionExpression(node.initializer) || + ts.isArrowFunction(node.initializer) || + ts.isClassExpression(node.initializer)) + ) { + return node.name.text; + } + return undefined; +} + +export function definitionsNamed(script: PublishedScript, names: ReadonlySet): string[] { + const definitions: string[] = []; + const visit = (node: ts.Node): void => { + const name = definedName(node); + if (name && names.has(name)) definitions.push(`${script.path}: ${name}`); + ts.forEachChild(node, visit); + }; + visit(parse(script)); + return definitions; +} diff --git a/apps/e2e/demo-e2e-published-imports/e2e/published-imports.e2e.spec.ts b/apps/e2e/demo-e2e-published-imports/e2e/published-imports.e2e.spec.ts new file mode 100644 index 000000000..3c784e6d7 --- /dev/null +++ b/apps/e2e/demo-e2e-published-imports/e2e/published-imports.e2e.spec.ts @@ -0,0 +1,63 @@ +// The packed JavaScript of plugin-cache, plugin-dashboard and edge imports @frontmcp/utils instead of bundling it (#956) +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; + +import { mkdtemp, readFile, rm } from '@frontmcp/utils'; + +import { + builtDistDir, + definitionsNamed, + exportedNames, + loadedSpecifiers, + readPublishedPackage, + undeclaredImports, + workspaceDistDirs, + type PublishedPackage, +} from './helpers/published-package'; + +const PACKAGES = ['@frontmcp/plugin-cache', '@frontmcp/plugin-dashboard', '@frontmcp/edge']; + +describe('published JavaScript that uses @frontmcp/utils', () => { + let workDir: string; + let distDirs: Map; + let utilsExports: Set; + + beforeAll(async () => { + workDir = await mkdtemp(path.join(tmpdir(), 'frontmcp-published-imports-')); + distDirs = await workspaceDistDirs(); + const utilsEsm = path.join(await builtDistDir(distDirs, '@frontmcp/utils'), 'esm', 'index.mjs'); + utilsExports = exportedNames({ path: 'esm/index.mjs', source: await readFile(utilsEsm) }); + }); + + afterAll(async () => { + if (workDir) await rm(workDir, { recursive: true, force: true }); + }); + + describe.each(PACKAGES)('%s', (name) => { + let published: PublishedPackage; + + beforeAll(async () => { + published = await readPublishedPackage(await builtDistDir(distDirs, name), workDir); + }); + + it('declares @frontmcp/utils at its own version', () => { + expect(published.manifest.dependencies?.['@frontmcp/utils']).toBe(published.manifest.version); + }); + + it('imports @frontmcp/utils from its CommonJS and ESM builds', () => { + const importers = published.scripts + .filter((script) => loadedSpecifiers(script).includes('@frontmcp/utils')) + .map((script) => script.path); + expect(importers).toEqual(expect.arrayContaining(['index.js', 'esm/index.mjs'])); + }); + + it('defines none of the functions and classes @frontmcp/utils exports', () => { + expect(utilsExports.size).toBeGreaterThan(0); + expect(published.scripts.flatMap((script) => definitionsNamed(script, utilsExports))).toEqual([]); + }); + + it('imports only packages its package.json declares', () => { + expect(undeclaredImports(published)).toEqual([]); + }); + }); +}); diff --git a/apps/e2e/demo-e2e-published-imports/jest.e2e.config.ts b/apps/e2e/demo-e2e-published-imports/jest.e2e.config.ts new file mode 100644 index 000000000..37a086d5c --- /dev/null +++ b/apps/e2e/demo-e2e-published-imports/jest.e2e.config.ts @@ -0,0 +1,23 @@ +import type { Config } from '@jest/types'; + +const config: Config.InitialOptions = { + displayName: 'demo-e2e-published-imports', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + testMatch: ['/e2e/**/*.e2e.spec.ts'], + testTimeout: 120000, + maxWorkers: 1, + transform: { + '^.+\\.[tj]s$': [ + '@swc/jest', + { + jsc: { + parser: { syntax: 'typescript' }, + target: 'es2022', + }, + }, + ], + }, +}; + +export default config; diff --git a/apps/e2e/demo-e2e-published-imports/project.json b/apps/e2e/demo-e2e-published-imports/project.json new file mode 100644 index 000000000..f32087eb7 --- /dev/null +++ b/apps/e2e/demo-e2e-published-imports/project.json @@ -0,0 +1,19 @@ +{ + "name": "demo-e2e-published-imports", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/e2e/demo-e2e-published-imports/e2e", + "projectType": "application", + "tags": ["scope:demo", "type:e2e", "feature:published-imports"], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/apps/e2e/demo-e2e-published-imports"], + "dependsOn": [{ "projects": ["plugin-cache", "plugin-dashboard", "edge"], "target": "build" }], + "options": { + "jestConfig": "apps/e2e/demo-e2e-published-imports/jest.e2e.config.ts", + "runInBand": true, + "passWithNoTests": false + } + } + } +} diff --git a/apps/e2e/demo-e2e-published-imports/tsconfig.e2e.json b/apps/e2e/demo-e2e-published-imports/tsconfig.e2e.json new file mode 100644 index 000000000..6f65e2392 --- /dev/null +++ b/apps/e2e/demo-e2e-published-imports/tsconfig.e2e.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "noEmit": true, + "types": ["jest", "node"] + }, + "include": ["e2e/**/*.ts"] +} diff --git a/apps/e2e/demo-e2e-published-imports/tsconfig.json b/apps/e2e/demo-e2e-published-imports/tsconfig.json new file mode 100644 index 000000000..bc24af1db --- /dev/null +++ b/apps/e2e/demo-e2e-published-imports/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [{ "path": "./tsconfig.e2e.json" }], + "compilerOptions": { + "esModuleInterop": true + } +} diff --git a/libs/edge/package.json b/libs/edge/package.json index 6bd9405d2..f9ca40a09 100644 --- a/libs/edge/package.json +++ b/libs/edge/package.json @@ -49,8 +49,8 @@ "node": ">=24.0.0" }, "dependencies": { - "@enclave-vm/ast": "^2.17.0", - "@frontmcp/plugin-skilled-openapi": "1.10.16" + "@frontmcp/plugin-skilled-openapi": "1.10.16", + "@frontmcp/utils": "1.10.16" }, "peerDependencies": { "@frontmcp/sdk": "1.10.16" diff --git a/libs/edge/project.json b/libs/edge/project.json index 0a788a18b..577d231dd 100644 --- a/libs/edge/project.json +++ b/libs/edge/project.json @@ -24,7 +24,7 @@ "outExtension": { ".js": ".js" }, - "external": ["@frontmcp/sdk", "@frontmcp/plugin-skilled-openapi"] + "external": ["@frontmcp/sdk", "@frontmcp/utils", "@frontmcp/plugin-skilled-openapi"] } }, "dependsOn": ["^build"] @@ -47,7 +47,7 @@ "outExtension": { ".js": ".mjs" }, - "external": ["@frontmcp/sdk", "@frontmcp/plugin-skilled-openapi"] + "external": ["@frontmcp/sdk", "@frontmcp/utils", "@frontmcp/plugin-skilled-openapi"] } } }, diff --git a/plugins/plugin-cache/package.json b/plugins/plugin-cache/package.json index 9b8bdeacf..2b4dc4803 100644 --- a/plugins/plugin-cache/package.json +++ b/plugins/plugin-cache/package.json @@ -50,7 +50,6 @@ } }, "dependencies": { - "@enclave-vm/ast": "^2.17.0", "@frontmcp/sdk": "1.10.16", "@frontmcp/utils": "1.10.16", "ioredis": "^5.8.0" diff --git a/plugins/plugin-cache/project.json b/plugins/plugin-cache/project.json index 3c0715554..322300c43 100644 --- a/plugins/plugin-cache/project.json +++ b/plugins/plugin-cache/project.json @@ -30,7 +30,15 @@ "outExtension": { ".js": ".js" }, - "external": ["@frontmcp/lazy-zod", "ioredis", "@frontmcp/sdk", "@vercel/kv", "zod", "reflect-metadata"] + "external": [ + "@frontmcp/lazy-zod", + "@frontmcp/utils", + "ioredis", + "@frontmcp/sdk", + "@vercel/kv", + "zod", + "reflect-metadata" + ] } } }, @@ -52,7 +60,15 @@ "outExtension": { ".js": ".mjs" }, - "external": ["@frontmcp/lazy-zod", "ioredis", "@frontmcp/sdk", "@vercel/kv", "zod", "reflect-metadata"] + "external": [ + "@frontmcp/lazy-zod", + "@frontmcp/utils", + "ioredis", + "@frontmcp/sdk", + "@vercel/kv", + "zod", + "reflect-metadata" + ] } } }, diff --git a/plugins/plugin-dashboard/package.json b/plugins/plugin-dashboard/package.json index a7ff0851b..b6199b510 100644 --- a/plugins/plugin-dashboard/package.json +++ b/plugins/plugin-dashboard/package.json @@ -54,7 +54,7 @@ "reflect-metadata": "^0.2.2" }, "dependencies": { - "@enclave-vm/ast": "^2.17.0" + "@frontmcp/utils": "1.10.16" }, "peerDependencies": { "@frontmcp/lazy-zod": "1.10.16", diff --git a/plugins/plugin-dashboard/project.json b/plugins/plugin-dashboard/project.json index fe35942a2..0fe2c926f 100644 --- a/plugins/plugin-dashboard/project.json +++ b/plugins/plugin-dashboard/project.json @@ -30,7 +30,7 @@ "outExtension": { ".js": ".js" }, - "external": ["@frontmcp/lazy-zod", "@frontmcp/sdk", "zod", "reflect-metadata"] + "external": ["@frontmcp/lazy-zod", "@frontmcp/utils", "@frontmcp/sdk", "zod", "reflect-metadata"] } } }, @@ -52,7 +52,7 @@ "outExtension": { ".js": ".mjs" }, - "external": ["@frontmcp/lazy-zod", "@frontmcp/sdk", "zod", "reflect-metadata"] + "external": ["@frontmcp/lazy-zod", "@frontmcp/utils", "@frontmcp/sdk", "zod", "reflect-metadata"] } } }, diff --git a/yarn.lock b/yarn.lock index 22f820f5c..961a190a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2443,8 +2443,8 @@ __metadata: version: 0.0.0-use.local resolution: "@frontmcp/edge@workspace:libs/edge" dependencies: - "@enclave-vm/ast": "npm:^2.17.0" "@frontmcp/plugin-skilled-openapi": "npm:1.10.16" + "@frontmcp/utils": "npm:1.10.16" "@types/node": "npm:^24.0.0" typescript: "npm:^5.0.0" peerDependencies: @@ -2541,7 +2541,6 @@ __metadata: version: 0.0.0-use.local resolution: "@frontmcp/plugin-cache@workspace:plugins/plugin-cache" dependencies: - "@enclave-vm/ast": "npm:^2.17.0" "@frontmcp/sdk": "npm:1.10.16" "@frontmcp/utils": "npm:1.10.16" ioredis: "npm:^5.8.0" @@ -2576,8 +2575,8 @@ __metadata: version: 0.0.0-use.local resolution: "@frontmcp/plugin-dashboard@workspace:plugins/plugin-dashboard" dependencies: - "@enclave-vm/ast": "npm:^2.17.0" "@frontmcp/sdk": "npm:1.10.16" + "@frontmcp/utils": "npm:1.10.16" reflect-metadata: "npm:^0.2.2" peerDependencies: "@frontmcp/lazy-zod": 1.10.16 From 5fb2e07ca1872719e18d0016db20fb5002f8ae1e Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 16:01:09 +0200 Subject: [PATCH 02/25] test(sdk): pin how the Express host answers request bodies Status, content type and body of the answers to JSON, form and other bodies (charsets, encodings, malformed JSON, limits), and the error a later error handler receives, before the JSON parser changes. --- .../express.host.adapter.body.spec.ts | 309 ++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 libs/sdk/src/server/adapters/__tests__/express.host.adapter.body.spec.ts diff --git a/libs/sdk/src/server/adapters/__tests__/express.host.adapter.body.spec.ts b/libs/sdk/src/server/adapters/__tests__/express.host.adapter.body.spec.ts new file mode 100644 index 000000000..75f5afd4e --- /dev/null +++ b/libs/sdk/src/server/adapters/__tests__/express.host.adapter.body.spec.ts @@ -0,0 +1,309 @@ +import * as http from 'node:http'; +import { deflateSync, gzipSync } from 'node:zlib'; + +import type express from 'express'; + +import { ExpressHostAdapter } from '../express.host.adapter'; + +// Mock base.host.adapter to break the import chain into ../../common (which pulls @frontmcp/auth) +jest.mock('../base.host.adapter', () => { + class HostServerAdapter {} + return { HostServerAdapter }; +}); + +type BodyRequest = { + contentType?: string; + contentEncoding?: string; + body: Buffer; + chunked?: boolean; +}; + +type Answer = { status: number; contentType?: string; body: string }; + +const BOM = Buffer.from([0xef, 0xbb, 0xbf]); +const ERROR_PAGE = (message: string) => + `\n\n\n\nError\n\n\n
${message}
\n\n\n`; +const TOO_LARGE = (length?: number) => + JSON.stringify({ + jsonrpc: '2.0', + id: null, + error: { code: -32600, message: 'Payload Too Large', data: { limit: 1024, length } }, + }); + +const send = (url: string, request: BodyRequest): Promise => { + const { hostname, port, pathname } = new URL(url); + const headers: Record = {}; + if (request.contentType) headers['content-type'] = request.contentType; + if (request.contentEncoding) headers['content-encoding'] = request.contentEncoding; + if (!request.chunked) headers['content-length'] = request.body.length; + return new Promise((resolve, reject) => { + const req = http.request({ agent: false, method: 'POST', hostname, port, path: pathname, headers }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => + resolve({ + status: res.statusCode ?? 0, + contentType: res.headers['content-type'], + body: Buffer.concat(chunks).toString('utf8'), + }), + ); + }); + req.on('error', reject); + if (request.chunked) { + for (const byte of request.body) req.write(Buffer.from([byte])); + } else { + req.write(request.body); + } + req.end(); + }); +}; + +const startServer = async ( + extend?: (app: express.Application) => void, +): Promise<{ url: string; close: () => Promise }> => { + const adapter = new ExpressHostAdapter({ bodyLimit: '1kb' }); + adapter.registerRoute('POST', '/echo', (req: unknown, res: unknown) => { + const body = (req as { body: unknown }).body; + (res as express.Response).json({ body: body === undefined ? '' : body }); + }); + const app = adapter.getHandler(); + app.set('env', 'production'); + extend?.(app); + const server = http.createServer(app as http.RequestListener); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + const { port } = server.address() as { port: number }; + return { + url: `http://127.0.0.1:${port}/echo`, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +}; + +const text = (body: string, contentType = 'application/json'): BodyRequest => ({ + contentType, + body: Buffer.from(body), +}); + +const REQUESTS: Record = { + 'a JSON object': text('{"a":1,"b":[true,null,"x"]}'), + 'charset=utf-8': text('{"word":"caf\u00e9 \u{1f600}"}', 'application/json; charset=utf-8'), + 'charset=UTF-8 without a space': text('{"a":1}', 'application/json;charset=UTF-8'), + 'a quoted charset': text('{"a":1}', 'application/json; charset="utf-8"'), + 'an upper-case type': text('{"a":1}', 'APPLICATION/JSON'), + 'a byte order mark': { contentType: 'application/json', body: Buffer.concat([BOM, Buffer.from('{"a":1}')]) }, + 'whitespace before an array': text(' \n\t[1,2]'), + 'an empty body': text(''), + 'only whitespace': text(' \n'), + 'a string literal': text('"text"'), + 'a number literal': text('42'), + 'a true literal': text('true'), + 'a bare word': text(' x{}'), + 'a truncated object': text('{"a":'), + 'an unquoted key': text('{a:1}'), + 'trailing garbage': text('{"a":1}x'), + 'a __proto__ key': text('{"__proto__":{"polluted":true}}'), + 'invalid UTF-8 inside a string': { + contentType: 'application/json', + body: Buffer.from([0x7b, 0x22, 0x61, 0x22, 0x3a, 0x22, 0xff, 0xe2, 0x82, 0x22, 0x7d]), + }, + 'a continuation byte first': { contentType: 'application/json', body: Buffer.from([0x80, 0x7b, 0x7d]) }, + 'multibyte characters one byte per chunk': { + contentType: 'application/json', + body: Buffer.from('{"word":"\u00e9\u20ac\u{1f600}"}'), + chunked: true, + }, + 'charset=utf-16le': { + contentType: 'application/json; charset=utf-16le', + body: Buffer.from('{"word":"caf\u00e9"}', 'utf16le'), + }, + 'charset=latin1': text('{"a":1}', 'application/json; charset=latin1'), + 'charset=utf8': text('{"a":1}', 'application/json; charset=utf8'), + 'charset=utf-9': text('{"a":1}', 'application/json; charset=utf-9'), + 'a +json suffix type': text('[1]', 'application/json-patch+json'), + 'JSON listed with text': text('{"a":1}', 'application/json, text/plain'), + 'JSON listed with a form': text('a=1', 'application/json, application/x-www-form-urlencoded'), + 'a form': text('a=1&b[c]=2', 'application/x-www-form-urlencoded'), + 'text/plain': text('{"a":1}', 'text/plain'), + 'no content type': { body: Buffer.from('{"a":1}') }, + gzip: { contentType: 'application/json', contentEncoding: 'gzip', body: gzipSync('{"a":1}') }, + deflate: { contentType: 'application/json', contentEncoding: 'deflate', body: deflateSync('{"a":1}') }, + 'corrupt gzip': { contentType: 'application/json', contentEncoding: 'gzip', body: Buffer.from('{"a":1}') }, + 'an unsupported encoding': { contentType: 'application/json', contentEncoding: 'compress', body: Buffer.from('{}') }, + 'a body at the limit': text(`{"a":"${'x'.repeat(1016)}"}`), + 'a body over the limit': text(`{"a":"${'x'.repeat(1017)}"}`), + 'a chunked body over the limit': { + contentType: 'application/json', + body: Buffer.from(`{"a":"${'x'.repeat(1017)}"}`), + chunked: true, + }, + 'gzip that inflates over the limit': { + contentType: 'application/json', + contentEncoding: 'gzip', + body: gzipSync(`{"a":"${'x'.repeat(4096)}"}`), + }, +}; + +const echo = (body: unknown): Answer => ({ + status: 200, + contentType: 'application/json; charset=utf-8', + body: JSON.stringify({ body }), +}); +const errorPage = (status: number, message: string): Answer => ({ + status, + contentType: 'text/html; charset=utf-8', + body: ERROR_PAGE(message), +}); +const tooLarge = (length?: number): Answer => ({ + status: 413, + contentType: 'application/json; charset=utf-8', + body: TOO_LARGE(length), +}); + +const ANSWERS: Record = { + 'a JSON object': echo({ a: 1, b: [true, null, 'x'] }), + 'charset=utf-8': echo({ word: 'caf\u00e9 \u{1f600}' }), + 'charset=UTF-8 without a space': echo({ a: 1 }), + 'a quoted charset': echo({ a: 1 }), + 'an upper-case type': echo({ a: 1 }), + 'a byte order mark': echo({ a: 1 }), + 'whitespace before an array': echo([1, 2]), + 'an empty body': echo({}), + 'only whitespace': errorPage(400, 'Bad Request'), + 'a string literal': errorPage(400, 'Bad Request'), + 'a number literal': errorPage(400, 'Bad Request'), + 'a true literal': errorPage(400, 'Bad Request'), + 'a bare word': errorPage(400, 'Bad Request'), + 'a truncated object': errorPage(400, 'Bad Request'), + 'an unquoted key': errorPage(400, 'Bad Request'), + 'trailing garbage': errorPage(400, 'Bad Request'), + 'a __proto__ key': { ...echo({}), body: '{"body":{"__proto__":{"polluted":true}}}' }, + 'invalid UTF-8 inside a string': echo({ a: '\ufffd\ufffd' }), + 'a continuation byte first': errorPage(400, 'Bad Request'), + 'multibyte characters one byte per chunk': echo({ word: '\u00e9\u20ac\u{1f600}' }), + 'charset=utf-16le': echo({ word: 'caf\u00e9' }), + 'charset=latin1': errorPage(415, 'Unsupported Media Type'), + 'charset=utf8': errorPage(415, 'Unsupported Media Type'), + 'charset=utf-9': errorPage(415, 'Unsupported Media Type'), + 'a +json suffix type': echo([1]), + 'JSON listed with text': echo({ a: 1 }), + 'JSON listed with a form': echo({ a: '1' }), + 'a form': echo({ a: '1', b: { c: '2' } }), + 'text/plain': echo(''), + 'no content type': echo(''), + gzip: echo({ a: 1 }), + deflate: echo({ a: 1 }), + 'corrupt gzip': errorPage(400, 'Bad Request'), + 'an unsupported encoding': errorPage(415, 'Unsupported Media Type'), + 'a body at the limit': echo({ a: 'x'.repeat(1016) }), + 'a body over the limit': tooLarge(1025), + 'a chunked body over the limit': tooLarge(), + 'gzip that inflates over the limit': tooLarge(), +}; + +type ErrorShape = Record; + +const describeError = (error: unknown): ErrorShape => { + const shape: ErrorShape = { ...(error as Record) }; + const { name, message, stack } = error as Error; + return { name, message, stackHead: stack?.split('\n')[0], keys: Object.keys(error as object), ...shape }; +}; + +const parseFailed = (name: string, message: string, body: string): ErrorShape => ({ + name, + message, + stackHead: `${name}: ${message}`, + keys: ['expose', 'statusCode', 'status', 'body', 'type'], + expose: true, + statusCode: 400, + status: 400, + body, + type: 'entity.parse.failed', +}); + +const unsupported = (key: string, value: string, message: string, type: string): ErrorShape => ({ + name: 'UnsupportedMediaTypeError', + message, + stackHead: `UnsupportedMediaTypeError: ${message}`, + keys: ['message', key, 'type'], + [key]: value, + type, +}); + +const ERRORS: Record = { + 'only whitespace': parseFailed('SyntaxError', 'Unexpected end of JSON input', ' \n'), + 'a string literal': parseFailed('SyntaxError', 'Unexpected token \'"\', ""text"" is not valid JSON', '"text"'), + 'a number literal': parseFailed('SyntaxError', 'Unexpected token \'4\', "42" is not valid JSON', '42'), + 'a true literal': parseFailed('SyntaxError', 'Unexpected token \'t\', "true" is not valid JSON', 'true'), + 'a bare word': parseFailed('SyntaxError', 'Unexpected token \'x\', " x{}" is not valid JSON', ' x{}'), + 'a truncated object': parseFailed('SyntaxError', 'Unexpected end of JSON input', '{"a":'), + 'an unquoted key': parseFailed( + 'SyntaxError', + "Expected property name or '}' in JSON at position 1 (line 1 column 2)", + '{a:1}', + ), + 'trailing garbage': parseFailed( + 'SyntaxError', + 'Unexpected non-whitespace character after JSON at position 7 (line 1 column 8)', + '{"a":1}x', + ), + 'a continuation byte first': parseFailed( + 'SyntaxError', + 'Unexpected token \'\ufffd\', "\ufffd{}" is not valid JSON', + '\ufffd{}', + ), + 'charset=latin1': unsupported('charset', 'latin1', 'unsupported charset "LATIN1"', 'charset.unsupported'), + 'charset=utf8': unsupported('charset', 'utf8', 'unsupported charset "UTF8"', 'charset.unsupported'), + 'charset=utf-9': unsupported('charset', 'utf-9', 'unsupported charset "UTF-9"', 'charset.unsupported'), + 'an unsupported encoding': unsupported( + 'encoding', + 'compress', + 'unsupported content encoding "compress"', + 'encoding.unsupported', + ), + 'corrupt gzip': { + name: 'BadRequestError', + message: 'Bad Request', + stackHead: 'BadRequestError: Bad Request', + keys: ['message', 'errno', 'code'], + errno: -3, + code: 'Z_DATA_ERROR', + }, +}; + +describe('ExpressHostAdapter request bodies', () => { + let server: { url: string; close: () => Promise }; + let errorLog: jest.SpyInstance; + + beforeAll(async () => { + errorLog = jest.spyOn(console, 'error').mockImplementation(() => undefined); + server = await startServer(); + }); + + afterAll(async () => { + await server.close(); + errorLog.mockRestore(); + }); + + it.each(Object.keys(REQUESTS))('answers %s as before', async (name) => { + expect(await send(server.url, REQUESTS[name])).toEqual(ANSWERS[name]); + }); +}); + +describe('ExpressHostAdapter request body errors', () => { + let server: { url: string; close: () => Promise }; + + beforeAll(async () => { + server = await startServer((app) => { + app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.status(299).json(describeError(error)); + }); + }); + }); + + afterAll(() => server.close()); + + it.each(Object.keys(ERRORS))('passes on the same error for %s', async (name) => { + const answer = await send(server.url, REQUESTS[name]); + expect(answer.status).toBe(299); + expect(JSON.parse(answer.body)).toEqual(ERRORS[name]); + }); +}); From a7e0d1af66fcccdb5bc1aadfcee92c4de67ad1ab Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 16:03:48 +0200 Subject: [PATCH 03/25] perf(sdk): parse UTF-8 JSON bodies without loading every iconv-lite encoding body-parser's json() decodes UTF-8 through iconv-lite, whose first decoder loads all of its encodings, about 400 of them, on a server's first JSON body. The Express host now reads application/json bodies with no charset or charset=utf-8 through body-parser's raw(), so limits, inflation and read errors stay its own, then decodes and parses them as json() does: byte order mark dropped, empty body as {}, the same strict-mode and syntax errors. Bodies in any other charset still go through json(). Refs #951 --- .../express.host.adapter.utf8-json.spec.ts | 66 +++++++++ .../__tests__/utf8-json.parser.spec.ts | 135 ++++++++++++++++++ .../server/adapters/express.host.adapter.ts | 2 + .../src/server/adapters/utf8-json.parser.ts | 73 ++++++++++ 4 files changed, 276 insertions(+) create mode 100644 libs/sdk/src/server/adapters/__tests__/express.host.adapter.utf8-json.spec.ts create mode 100644 libs/sdk/src/server/adapters/__tests__/utf8-json.parser.spec.ts create mode 100644 libs/sdk/src/server/adapters/utf8-json.parser.ts diff --git a/libs/sdk/src/server/adapters/__tests__/express.host.adapter.utf8-json.spec.ts b/libs/sdk/src/server/adapters/__tests__/express.host.adapter.utf8-json.spec.ts new file mode 100644 index 000000000..e3c1e90c2 --- /dev/null +++ b/libs/sdk/src/server/adapters/__tests__/express.host.adapter.utf8-json.spec.ts @@ -0,0 +1,66 @@ +import * as http from 'node:http'; +import * as path from 'node:path'; + +import type express from 'express'; + +import { ExpressHostAdapter } from '../express.host.adapter'; + +// Mock base.host.adapter to break the import chain into ../../common (which pulls @frontmcp/auth) +jest.mock('../base.host.adapter', () => { + class HostServerAdapter {} + return { HostServerAdapter }; +}); + +const resolveFrom = (name: string, from: string): string => require.resolve(name, { paths: [path.dirname(from)] }); + +// The iconv-lite that body-parser's raw-body decodes with. +const iconvLite = (): { encodings: object | null } => { + const bodyParser = resolveFrom('body-parser', require.resolve('express')); + return require(resolveFrom('iconv-lite', resolveFrom('raw-body', bodyParser))); +}; + +const post = (url: string, contentType: string, body: Buffer): Promise<{ status: number; body: string }> => + new Promise((resolve, reject) => { + const req = http.request( + url, + { agent: false, method: 'POST', headers: { 'content-type': contentType, 'content-length': body.length } }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })); + }, + ); + req.on('error', reject); + req.end(body); + }); + +describe('ExpressHostAdapter JSON bodies and iconv-lite', () => { + let server: http.Server; + let url: string; + + beforeAll(async () => { + const adapter = new ExpressHostAdapter(); + adapter.registerRoute('POST', '/echo', (req: unknown, res: unknown) => { + (res as express.Response).json((req as { body: unknown }).body); + }); + server = http.createServer(adapter.getHandler() as http.RequestListener); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + url = `http://127.0.0.1:${(server.address() as { port: number }).port}/echo`; + }); + + afterAll(() => new Promise((resolve) => server.close(() => resolve()))); + + it('parses a UTF-8 JSON body without loading the encodings of iconv-lite', async () => { + expect(await post(url, 'application/json', Buffer.from('{"a":"caf\u00e9"}'))).toEqual({ + status: 200, + body: '{"a":"caf\u00e9"}', + }); + expect(iconvLite().encodings).toBeNull(); + }); + + it('decodes a UTF-16 JSON body with iconv-lite', async () => { + const body = Buffer.from('{"a":1}', 'utf16le'); + expect(await post(url, 'application/json; charset=utf-16le', body)).toEqual({ status: 200, body: '{"a":1}' }); + expect(iconvLite().encodings).not.toBeNull(); + }); +}); diff --git a/libs/sdk/src/server/adapters/__tests__/utf8-json.parser.spec.ts b/libs/sdk/src/server/adapters/__tests__/utf8-json.parser.spec.ts new file mode 100644 index 000000000..c9cf8893f --- /dev/null +++ b/libs/sdk/src/server/adapters/__tests__/utf8-json.parser.spec.ts @@ -0,0 +1,135 @@ +import * as http from 'node:http'; + +import express from 'express'; + +import { utf8JsonParser } from '../utf8-json.parser'; + +type Outcome = Record; + +const describeError = (error: unknown): Outcome => { + const { name, message, stack } = error as Error; + return { name, message, stackHead: stack?.split('\n')[0], keys: Object.keys(error as object), ...(error as object) }; +}; + +const listen = async (parsers: express.RequestHandler[]): Promise<{ url: string; server: http.Server }> => { + const app = express(); + app.use(...parsers); + app.post('/', (req, res) => { + res.json({ parsed: req.body === undefined ? '' : req.body }); + }); + app.use((error: unknown, req: express.Request, res: express.Response, _next: express.NextFunction) => { + res.json({ error: describeError(error), body: req.body === undefined ? '' : req.body }); + }); + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve())); + return { url: `http://127.0.0.1:${(server.address() as { port: number }).port}/`, server }; +}; + +const post = (url: string, contentType: string, body: Buffer, chunked: boolean): Promise => + new Promise((resolve, reject) => { + const headers: Record = { 'content-type': contentType }; + if (!chunked) headers['content-length'] = body.length; + const req = http.request(url, { agent: false, method: 'POST', headers }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => resolve({ status: res.statusCode, ...JSON.parse(Buffer.concat(chunks).toString('utf8')) })); + }); + req.on('error', reject); + if (chunked) { + for (let offset = 0; offset < body.length; offset += 3) req.write(body.subarray(offset, offset + 3)); + } else { + req.write(body); + } + req.end(); + }); + +const random = (seed: number) => () => { + seed = (seed * 1103515245 + 12345) >>> 0; + return seed / 4294967296; +}; + +const FRAGMENTS = [ + '{', + '}', + '[', + ']', + '"', + ':', + ',', + ' ', + '\n', + '\t', + '\r', + 'a', + '1', + '-', + '.', + 'e', + 'true', + 'null', + '\\', + 'u00', + 'é', + '€', + '\u{1f600}', + '', + '#', +].map((fragment) => Buffer.from(fragment)); +const BYTES = [0xff, 0xc3, 0x80, 0xe2, 0xef, 0xbb, 0xbf].map((byte) => Buffer.from([byte])); + +const generatedBodies = (count: number): Buffer[] => { + const next = random(951); + const pick = (items: T[]): T => items[Math.floor(next() * items.length)]; + const bodies: Buffer[] = []; + for (let i = 0; i < count; i++) { + if (i % 3 === 0) { + const value = { text: pick(['', 'café', '\u{1f600}', 'x'.repeat(Math.floor(next() * 40))]), list: [i, null] }; + const prefix = pick(['', ' ', '', ' \n']); + bodies.push(Buffer.from(prefix + JSON.stringify(i % 2 ? value : [value]))); + } else { + const length = Math.floor(next() * 12); + bodies.push(Buffer.concat(Array.from({ length }, () => (next() < 0.8 ? pick(FRAGMENTS) : pick(BYTES))))); + } + } + return bodies; +}; + +describe('utf8JsonParser', () => { + let stock: { url: string; server: http.Server }; + let utf8: { url: string; server: http.Server }; + + beforeAll(async () => { + stock = await listen([express.json({ limit: '1kb' })]); + utf8 = await listen([utf8JsonParser('1kb'), express.json({ limit: '1kb' })]); + }); + + afterAll(async () => { + for (const { server } of [stock, utf8]) await new Promise((resolve) => server.close(() => resolve())); + }); + + it.each([ + ['application/json', false], + ['application/json; charset=utf-8', true], + ])('parses %s bodies (chunked: %s) as express.json() does', async (contentType, chunked) => { + for (const body of generatedBodies(150)) { + const expected = await post(stock.url, contentType, body, chunked); + expect({ body: body.toString('hex'), outcome: await post(utf8.url, contentType, body, chunked) }).toEqual({ + body: body.toString('hex'), + outcome: expected, + }); + } + }); + + it('leaves a body in another charset to express.json()', async () => { + const body = Buffer.from('{"a":"café"}', 'utf16le'); + expect(await post(utf8.url, 'application/json; charset=utf-16le', body, false)).toEqual({ + status: 200, + parsed: { a: 'café' }, + }); + }); + + it('passes on a read error such as a body over the limit', async () => { + const outcome = await post(utf8.url, 'application/json', Buffer.from(`["${'x'.repeat(1100)}"]`), false); + expect(outcome).toMatchObject({ error: { type: 'entity.too.large', limit: 1024, length: 1104 } }); + }); +}); diff --git a/libs/sdk/src/server/adapters/express.host.adapter.ts b/libs/sdk/src/server/adapters/express.host.adapter.ts index 1dbb10a49..831c6e1bb 100644 --- a/libs/sdk/src/server/adapters/express.host.adapter.ts +++ b/libs/sdk/src/server/adapters/express.host.adapter.ts @@ -32,6 +32,7 @@ import { listenOwnerOnly, } from '../security/unix-socket-guard'; import { HostServerAdapter } from './base.host.adapter'; +import { utf8JsonParser } from './utf8-json.parser'; const SERVER_DRAIN_MS = 5_000; @@ -169,6 +170,7 @@ export class ExpressHostAdapter extends HostServerAdapter { const isJson = (req: http.IncomingMessage): boolean => headerContains(req, 'application/json') && !isUrlencoded(req); this.app.use(express.urlencoded({ extended: true, limit: formLimit, type: isUrlencoded })); + this.app.use(utf8JsonParser(jsonLimit)); this.app.use(express.json({ limit: jsonLimit, type: isJson })); // Translate body-parser's `entity.too.large` (raised when a request body diff --git a/libs/sdk/src/server/adapters/utf8-json.parser.ts b/libs/sdk/src/server/adapters/utf8-json.parser.ts new file mode 100644 index 000000000..6d94c50c7 --- /dev/null +++ b/libs/sdk/src/server/adapters/utf8-json.parser.ts @@ -0,0 +1,73 @@ +import type * as http from 'node:http'; + +import express from 'express'; + +// The `application/json` bodies body-parser's `json()` reads as UTF-8: no charset, or `charset=utf-8`. +const UTF8_JSON_TYPE = /^application\/json(?:; ?charset=utf-8)?$/i; +const FIRST_NON_WHITESPACE = /^[ \t\n\r]*([^ \t\n\r])/; + +const isUtf8Json = (req: http.IncomingMessage): boolean => UTF8_JSON_TYPE.test(req.headers['content-type'] ?? ''); + +// body-parser's `json()` for UTF-8 bodies, read by its `raw()` but decoded without iconv-lite, which loads all its encodings. +export function utf8JsonParser(limit: number | string): express.RequestHandler { + const readBody = express.raw({ limit, type: isUtf8Json }); + return (req, res, next) => { + const unparsedBody: unknown = req.body; + readBody(req, res, (readError?: unknown) => { + if (readError || !Buffer.isBuffer(req.body)) { + next(readError); + return; + } + const text = decodeUtf8(req.body); + try { + req.body = parseStrictJson(text); + } catch (error) { + req.body = unparsedBody; + next(Object.assign(error as SyntaxError, parseFailure(text))); + return; + } + next(); + }); + }; +} + +// What http-errors' `createError(400, error, { body, type })` adds, in its order. +const parseFailure = (body: string) => ({ + expose: true, + statusCode: 400, + status: 400, + body, + type: 'entity.parse.failed', +}); + +// iconv-lite decodes UTF-8 with Node's StringDecoder and drops a leading byte order mark. +function decodeUtf8(body: Buffer): string { + const text = body.toString('utf8'); + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; +} + +function parseStrictJson(text: string): unknown { + if (text.length === 0) return {}; + const first = FIRST_NON_WHITESPACE.exec(text)?.[1]; + if (first !== '{' && first !== '[') throw strictSyntaxError(text, first); + return JSON.parse(text); +} + +// body-parser's strict-mode error: what JSON.parse says about the text with everything from its first character masked. +function strictSyntaxError(text: string, first: string | undefined): SyntaxError { + const index = first === undefined ? -1 : text.indexOf(first); + const error = jsonParseError(index === -1 ? '' : text.slice(0, index).padEnd(text.length, '#')); + const message = error.message.replace(/#+/g, (mask) => text.substring(index, index + mask.length)); + error.stack = error.stack?.replace(error.message, message); + error.message = message; + return error; +} + +function jsonParseError(invalidJson: string): SyntaxError { + try { + JSON.parse(invalidJson); + } catch (error) { + return error as SyntaxError; + } + return new SyntaxError(`Expected invalid JSON: ${invalidJson}`); +} From de56afbaa43c1ea65dd90dc851297cb00900150d Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 16:11:48 +0200 Subject: [PATCH 04/25] perf(sdk): load the MCP client on first connect under ESM The SDK's ESM bundle imported @frontmcp/protocol/client for the direct client and remote apps, so an ESM process loaded the MCP SDK's client and its transports at startup. Under require() that subpath was already lazy. Both now take the client from #protocol-client. Its import variant, which the ESM build resolves, is a dynamic import() of @frontmcp/protocol/client; everywhere else it is the static module, so the CommonJS (kept external, lazy under require()), browser and Worker builds load as before. Refs #951 --- .../e2e/helpers/connect-direct-client.mjs | 36 +++++++++++++++++++ .../e2e/node-esm-client.e2e.spec.ts | 33 +++++++++++++++++ eslint.config.mjs | 2 +- jest.imports-mapper.js | 1 + libs/sdk/package.json | 7 ++++ libs/sdk/project.json | 5 ++- libs/sdk/src/direct/direct-client.ts | 4 ++- libs/sdk/src/remote-mcp/mcp-client.service.ts | 17 ++++++--- .../__tests__/protocol-client.spec.ts | 15 ++++++++ .../src/third-party/import-protocol-client.ts | 4 +++ libs/sdk/src/third-party/protocol-client.ts | 6 ++++ tsconfig.base.json | 3 +- 12 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 apps/e2e/demo-e2e-bundle-size/e2e/helpers/connect-direct-client.mjs create mode 100644 apps/e2e/demo-e2e-bundle-size/e2e/node-esm-client.e2e.spec.ts create mode 100644 libs/sdk/src/third-party/__tests__/protocol-client.spec.ts create mode 100644 libs/sdk/src/third-party/import-protocol-client.ts create mode 100644 libs/sdk/src/third-party/protocol-client.ts diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/helpers/connect-direct-client.mjs b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/connect-direct-client.mjs new file mode 100644 index 000000000..204a3bf20 --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/connect-direct-client.mjs @@ -0,0 +1,36 @@ +// Imports an SDK entry, connects a direct client, calls a tool and prints the MCP client modules loaded before and after connecting: node connect-direct-client.mjs +import { registerHooks } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const CLIENT_MODULE = + /\/@modelcontextprotocol\/sdk\/dist\/(esm|cjs)\/client\/|\/protocol\/dist\/(esm\/)?(clients?|lazy-client)\.m?js$/; +const loadedClientModules = []; +registerHooks({ + load(url, context, nextLoad) { + if (CLIENT_MODULE.test(url)) loadedClientModules.push(url); + return nextLoad(url, context); + }, +}); + +const [sdkEntry] = process.argv.slice(2); +const { app, connect, LogLevel, Tool, ToolContext, z } = await import(pathToFileURL(sdkEntry).href); +const beforeConnect = [...loadedClientModules]; + +class GreetTool extends ToolContext { + async execute({ name }) { + return `Hello, ${name}`; + } +} +Tool({ name: 'greet', description: 'Greet someone', inputSchema: { name: z.string() } })(GreetTool); + +const client = await connect({ + info: { name: 'esm-client', version: '1.0.0' }, + apps: [app({ id: 'greetings', name: 'Greetings', tools: [GreetTool] })], + logging: { level: LogLevel.Off }, +}); +try { + const result = await client.callTool('greet', { name: 'FrontMCP' }); + process.stdout.write(JSON.stringify({ beforeConnect, afterConnect: loadedClientModules, result })); +} finally { + await client.close(); +} diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/node-esm-client.e2e.spec.ts b/apps/e2e/demo-e2e-bundle-size/e2e/node-esm-client.e2e.spec.ts new file mode 100644 index 000000000..25de25b81 --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/node-esm-client.e2e.spec.ts @@ -0,0 +1,33 @@ +/** + * An ESM process loads the MCP client the first time a direct client or a remote app connects, as a CommonJS one + * does (#951): importing the SDK's ESM build loads none of it. + * + * Needs the packages built: `nx run-many -t build -p sdk plugin-webmcp`. + */ +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +import { workspaceRoot } from './helpers/browser-bundle'; + +type ClientLoad = { beforeConnect: string[]; afterConnect: string[]; result: unknown }; + +function connectDirectClient(sdkEntry: string): ClientLoad { + const runner = resolve(__dirname, 'helpers', 'connect-direct-client.mjs'); + return JSON.parse( + execFileSync(process.execPath, [runner, sdkEntry], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }), + ); +} + +describe('the MCP client of the SDK under ESM', () => { + it('loads with the first connect(), not with the SDK', () => { + const { beforeConnect, afterConnect, result } = connectDirectClient( + resolve(workspaceRoot, 'libs/sdk/dist/esm/index.mjs'), + ); + + expect(beforeConnect).toEqual([]); + expect(afterConnect).toEqual( + expect.arrayContaining([expect.stringMatching(/@modelcontextprotocol\/sdk\/dist\/esm\/client\/index\.js$/)]), + ); + expect(result).toMatchObject({ structuredContent: { value: 'Hello, FrontMCP' } }); + }); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 59ef400da..6e3660ba0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -66,7 +66,7 @@ export default [ '@frontmcp/protocol/client', '@frontmcp/testing', '@frontmcp/observability', - '^#(mcp-.+|crypto-.+|async-.+|event-.+|langchain-.+|sse-.+|express-.+|server-.+|stdio-.+|env|runtime-context|path|client|import-computed|js-yaml|semver|uipack-render)$', + '^#(mcp-.+|crypto-.+|async-.+|event-.+|langchain-.+|sse-.+|express-.+|server-.+|stdio-.+|env|runtime-context|path|client|import-computed|js-yaml|semver|uipack-render|protocol-client)$', ], depConstraints: [ { diff --git a/jest.imports-mapper.js b/jest.imports-mapper.js index 5b61f2e83..e514afab5 100644 --- a/jest.imports-mapper.js +++ b/jest.imports-mapper.js @@ -29,4 +29,5 @@ module.exports = { '^#js-yaml$': path.resolve(root, 'libs/sdk/src/third-party/js-yaml.ts'), '^#semver$': path.resolve(root, 'libs/sdk/src/third-party/semver.ts'), '^#uipack-render$': path.resolve(root, 'libs/sdk/src/third-party/uipack-render.ts'), + '^#protocol-client$': path.resolve(root, 'libs/sdk/src/third-party/protocol-client.ts'), }; diff --git a/libs/sdk/package.json b/libs/sdk/package.json index 14d660015..3b6c6b0d6 100644 --- a/libs/sdk/package.json +++ b/libs/sdk/package.json @@ -54,6 +54,13 @@ "types": "./src/third-party/uipack-render.ts", "require": "./src/third-party/lazy-uipack-render.ts", "default": "./src/third-party/uipack-render.ts" + }, + "#protocol-client": { + "types": "./src/third-party/protocol-client.ts", + "worker": "./src/third-party/protocol-client.ts", + "browser": "./src/third-party/protocol-client.ts", + "import": "./src/third-party/import-protocol-client.ts", + "default": "./src/third-party/protocol-client.ts" } }, "type": "commonjs", diff --git a/libs/sdk/project.json b/libs/sdk/project.json index 2f6e4c2a8..1cc00470f 100644 --- a/libs/sdk/project.json +++ b/libs/sdk/project.json @@ -26,7 +26,9 @@ "libs/sdk/src/third-party/semver.ts", "libs/sdk/src/third-party/lazy-semver.ts", "libs/sdk/src/third-party/uipack-render.ts", - "libs/sdk/src/third-party/lazy-uipack-render.ts" + "libs/sdk/src/third-party/lazy-uipack-render.ts", + "libs/sdk/src/third-party/protocol-client.ts", + "libs/sdk/src/third-party/import-protocol-client.ts" ], "assets": ["libs/sdk/README.md", "LICENSE", "libs/sdk/package.json"], "esbuildOptions": { @@ -36,6 +38,7 @@ "#js-yaml", "#semver", "#uipack-render", + "#protocol-client", "@frontmcp/sdk", "@frontmcp/utils", "@frontmcp/di", diff --git a/libs/sdk/src/direct/direct-client.ts b/libs/sdk/src/direct/direct-client.ts index c96058fcc..63b1df5c7 100644 --- a/libs/sdk/src/direct/direct-client.ts +++ b/libs/sdk/src/direct/direct-client.ts @@ -19,9 +19,10 @@ import { type ReadResourceResult, type ServerCapabilities, } from '@frontmcp/protocol'; -import { Client } from '@frontmcp/protocol/client'; import { fileExists, pathResolve, randomUUID } from '@frontmcp/utils'; +import { loadProtocolClient } from '#protocol-client'; + import { listAllPages } from '../common/utils/list-all-pages.utils'; import { ELICITATION_META_KEY } from '../elicitation/elicitation-meta'; import { PublicMcpError, ToolCallError } from '../errors'; @@ -173,6 +174,7 @@ export class DirectClientImpl implements DirectClient { }, }; + const { Client } = await loadProtocolClient(); const mcpClient = new Client(clientInfo, clientCapabilities); await mcpClient.connect(clientTransport); diff --git a/libs/sdk/src/remote-mcp/mcp-client.service.ts b/libs/sdk/src/remote-mcp/mcp-client.service.ts index b105e0164..64125a585 100644 --- a/libs/sdk/src/remote-mcp/mcp-client.service.ts +++ b/libs/sdk/src/remote-mcp/mcp-client.service.ts @@ -15,7 +15,8 @@ import { type Tool, type Transport, } from '@frontmcp/protocol'; -import { Client, SSEClientTransport, StreamableHTTPClientTransport } from '@frontmcp/protocol/client'; + +import { loadProtocolClient, type ProtocolClientModule } from '#protocol-client'; import { type FrontMcpLogger } from '../common'; import { listAllPages } from '../common/utils/list-all-pages.utils'; @@ -214,10 +215,11 @@ export class McpClientService { } // Create transport based on type - let transport = this.createTransport(request, headers); + const protocolClient = await loadProtocolClient(); + let transport = this.createTransport(protocolClient, request, headers); // Create MCP client with configurable name/version - const client = new Client( + const client = new protocolClient.Client( { name: this.options.clientName, version: this.options.clientVersion, @@ -239,7 +241,7 @@ export class McpClientService { `Streamable HTTP connection failed for ${appId}: ${(connectError as Error).message}. ` + `Falling back to SSE transport.`, ); - transport = this.createFallbackSSETransport(request, headers); + transport = this.createFallbackSSETransport(protocolClient, request, headers); await client.connect(transport); } else { throw connectError; @@ -879,7 +881,11 @@ export class McpClientService { }; } - private createTransport(request: McpConnectRequest, headers: Record | undefined): Transport { + private createTransport( + { StreamableHTTPClientTransport, SSEClientTransport }: ProtocolClientModule, + request: McpConnectRequest, + headers: Record | undefined, + ): Transport { const { transportType, url } = request; const options = { requestInit: headers ? { headers } : undefined, @@ -909,6 +915,7 @@ export class McpClientService { * Create fallback SSE transport when Streamable HTTP fails */ private createFallbackSSETransport( + { SSEClientTransport }: ProtocolClientModule, request: McpConnectRequest, headers: Record | undefined, ): Transport { diff --git a/libs/sdk/src/third-party/__tests__/protocol-client.spec.ts b/libs/sdk/src/third-party/__tests__/protocol-client.spec.ts new file mode 100644 index 000000000..889c27635 --- /dev/null +++ b/libs/sdk/src/third-party/__tests__/protocol-client.spec.ts @@ -0,0 +1,15 @@ +import * as client from '@frontmcp/protocol/client'; + +describe('#protocol-client', () => { + it.each([ + ['the static variant', '../protocol-client'], + ['the import variant', '../import-protocol-client'], + ])('%s loads the MCP client and its transports from @frontmcp/protocol/client', async (_variant, path) => { + const { loadProtocolClient } = await import(path); + const loaded = await loadProtocolClient(); + + expect(loaded.Client).toBe(client.Client); + expect(loaded.StreamableHTTPClientTransport).toBe(client.StreamableHTTPClientTransport); + expect(loaded.SSEClientTransport).toBe(client.SSEClientTransport); + }); +}); diff --git a/libs/sdk/src/third-party/import-protocol-client.ts b/libs/sdk/src/third-party/import-protocol-client.ts new file mode 100644 index 000000000..291216410 --- /dev/null +++ b/libs/sdk/src/third-party/import-protocol-client.ts @@ -0,0 +1,4 @@ +// The `import` variant of `#protocol-client`: an ESM build loads the MCP client the first time it connects. +import type { ProtocolClientModule } from './protocol-client'; + +export const loadProtocolClient = (): Promise => import('@frontmcp/protocol/client'); diff --git a/libs/sdk/src/third-party/protocol-client.ts b/libs/sdk/src/third-party/protocol-client.ts new file mode 100644 index 000000000..9e2103442 --- /dev/null +++ b/libs/sdk/src/third-party/protocol-client.ts @@ -0,0 +1,6 @@ +// `#protocol-client` everywhere but an ESM import: `@frontmcp/protocol/client`, whose require() variant loads on first read. +import * as protocolClient from '@frontmcp/protocol/client'; + +export type ProtocolClientModule = typeof protocolClient; + +export const loadProtocolClient = async (): Promise => protocolClient; diff --git a/tsconfig.base.json b/tsconfig.base.json index 4a0b90c56..061575153 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -122,7 +122,8 @@ "#import-computed": ["libs/sdk/src/esm-loader/import-computed.ts"], "#js-yaml": ["libs/sdk/src/third-party/js-yaml.ts"], "#semver": ["libs/sdk/src/third-party/semver.ts"], - "#uipack-render": ["libs/sdk/src/third-party/uipack-render.ts"] + "#uipack-render": ["libs/sdk/src/third-party/uipack-render.ts"], + "#protocol-client": ["libs/sdk/src/third-party/protocol-client.ts"] } }, "exclude": ["node_modules", "tmp"] From f9992d1c7517b56831e695cbe62e4c6d4d4898d9 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 16:16:32 +0200 Subject: [PATCH 05/25] perf(sdk): build the MCP server's Ajv the first time it validates The MCP SDK's Server builds an Ajv instance with ajv-formats in its constructor, to check the results of the elicitations it requests itself. FrontMCP never calls it for that, yet built one per session (per request on a stateless web-standard server), about 2.5 ms on a server's first request. Every MCP server FrontMCP creates now comes from createMcpServer(), whose validator builds the same AjvJsonSchemaValidator on its first use, so a server that does validate gets the same answers. @frontmcp/protocol exports AjvJsonSchemaValidator for it. Refs #951 --- libs/protocol/src/__tests__/exports.spec.ts | 2 + libs/protocol/src/index.ts | 1 + libs/sdk/src/front-mcp/front-mcp.ts | 5 +- .../__tests__/mcp-server.factory.spec.ts | 79 +++++++++++++++++++ .../adapters/transport.local.adapter.ts | 5 +- libs/sdk/src/transport/in-memory-server.ts | 4 +- libs/sdk/src/transport/mcp-server.factory.ts | 12 +++ libs/sdk/src/transport/web-standard-mcp.ts | 5 +- 8 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 libs/sdk/src/transport/__tests__/mcp-server.factory.spec.ts create mode 100644 libs/sdk/src/transport/mcp-server.factory.ts diff --git a/libs/protocol/src/__tests__/exports.spec.ts b/libs/protocol/src/__tests__/exports.spec.ts index b8ccdc67d..400b573b2 100644 --- a/libs/protocol/src/__tests__/exports.spec.ts +++ b/libs/protocol/src/__tests__/exports.spec.ts @@ -6,6 +6,7 @@ import { StdioServerTransport as SdkStdioServerTransport } from '@modelcontextpr import { StreamableHTTPServerTransport as SdkStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { WebStandardStreamableHTTPServerTransport as SdkWebStandardTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js'; +import { AjvJsonSchemaValidator as SdkAjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv-provider.js'; import { Server as BrowserServer } from '../browser-mcp-server'; import * as browserStreamableHttp from '../browser-mcp-streamable-http'; @@ -37,6 +38,7 @@ describe('@frontmcp/protocol exports', () => { it('exports the protocol surface from the package root', () => { expect(protocol.LATEST_PROTOCOL_VERSION).toBe(LATEST_PROTOCOL_VERSION); + expect(protocol.AjvJsonSchemaValidator).toBe(SdkAjvJsonSchemaValidator); expect(protocol.PROTOCOL_2026_07_28).toBe('2026-07-28'); expect(protocol.StdioServerTransport).toBe(SdkStdioServerTransport); }); diff --git a/libs/protocol/src/index.ts b/libs/protocol/src/index.ts index 821280807..7f5c72873 100644 --- a/libs/protocol/src/index.ts +++ b/libs/protocol/src/index.ts @@ -27,6 +27,7 @@ export * from './in-memory'; // Server (browser/node conditional via #imports) export { Server as McpServer, type ServerOptions as McpServerOptions } from '#mcp-server'; +export { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv-provider.js'; export { StreamableHTTPServerTransport, WebStandardStreamableHTTPServerTransport, diff --git a/libs/sdk/src/front-mcp/front-mcp.ts b/libs/sdk/src/front-mcp/front-mcp.ts index db4e7753c..24cde83ec 100644 --- a/libs/sdk/src/front-mcp/front-mcp.ts +++ b/libs/sdk/src/front-mcp/front-mcp.ts @@ -1,4 +1,4 @@ -import { McpServer, StdioServerTransport } from '@frontmcp/protocol'; +import { StdioServerTransport } from '@frontmcp/protocol'; import { getEnv, isEdgeRuntime, randomUUID } from '@frontmcp/utils'; import { @@ -25,6 +25,7 @@ import { ScopeRegistry } from '../scope/scope.registry'; import { type FrontMcpServerInstance } from '../server/server.instance'; import { composeCallerInstructions } from '../skill/skill-instructions.helper'; import { computeTaskCapabilities } from '../task/task-scope.helper'; +import { createMcpServer } from '../transport/mcp-server.factory'; import type { FetchHandlerCtx, WebFetchHandler } from '../transport/web-fetch-handler'; import { markDirectOnly } from './direct-only.flag'; import { createMcpGlobalProviders } from './front-mcp.providers'; @@ -864,7 +865,7 @@ export class FrontMcpInstance implements FrontMcpInterface { }; // Create MCP server - const mcpServer = new McpServer(scope.metadata.info, serverOptions); + const mcpServer = createMcpServer(scope.metadata.info, serverOptions); startupCleanups.push(() => mcpServer.close()); // Generate session ID for stdio connection diff --git a/libs/sdk/src/transport/__tests__/mcp-server.factory.spec.ts b/libs/sdk/src/transport/__tests__/mcp-server.factory.spec.ts new file mode 100644 index 000000000..255469665 --- /dev/null +++ b/libs/sdk/src/transport/__tests__/mcp-server.factory.spec.ts @@ -0,0 +1,79 @@ +import { ElicitRequestSchema, InMemoryTransport, McpServer } from '@frontmcp/protocol'; +import { Client } from '@frontmcp/protocol/client'; + +import { createMcpServer } from '../mcp-server.factory'; + +type AjvConstructor = new (...args: unknown[]) => unknown; + +// The Ajv module the MCP SDK's default JSON Schema validator builds its instance from. +const ajv: { default: AjvConstructor } = require('ajv'); + +const info = { name: 'factory-test', version: '1.0.0' }; +const requestedSchema = { + type: 'object' as const, + properties: { age: { type: 'number' as const } }, + required: ['age'], +}; + +async function connectElicitingClient(server: McpServer, content: Record): Promise { + const client = new Client({ name: 'client', version: '1.0.0' }, { capabilities: { elicitation: { form: {} } } }); + client.setRequestHandler(ElicitRequestSchema, async () => ({ action: 'accept', content })); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + return client; +} + +describe('createMcpServer', () => { + let ajvConstructed: jest.SpyInstance; + + beforeEach(() => { + const OriginalAjv = ajv.default; + ajvConstructed = jest.spyOn(ajv, 'default').mockImplementation(function (...args: unknown[]) { + return new OriginalAjv(...args); + }); + }); + + afterEach(() => ajvConstructed.mockRestore()); + + it('builds no Ajv instance until it validates an elicitation result', async () => { + new McpServer(info, { capabilities: {} }); + expect(ajvConstructed).toHaveBeenCalledTimes(1); + ajvConstructed.mockClear(); + + const server = createMcpServer(info, { capabilities: {} }); + expect(ajvConstructed).not.toHaveBeenCalled(); + const client = await connectElicitingClient(server, { age: 30 }); + ajvConstructed.mockClear(); + + await server.elicitInput({ message: 'Age?', requestedSchema }); + await server.elicitInput({ message: 'Age?', requestedSchema }); + expect(ajvConstructed).toHaveBeenCalledTimes(1); + await client.close(); + }); + + it('accepts an elicitation result that matches the requested schema', async () => { + const server = createMcpServer(info, { capabilities: {} }); + const client = await connectElicitingClient(server, { age: 30 }); + + await expect(server.elicitInput({ message: 'Age?', requestedSchema })).resolves.toEqual({ + action: 'accept', + content: { age: 30 }, + }); + await client.close(); + }); + + it('rejects an elicitation result that does not match the requested schema, as the MCP server does', async () => { + const results = await Promise.all( + [createMcpServer(info, { capabilities: {} }), new McpServer(info, { capabilities: {} })].map(async (server) => { + const client = await connectElicitingClient(server, { age: 'old' }); + const result = await server.elicitInput({ message: 'Age?', requestedSchema }).catch((error: Error) => error); + await client.close(); + return result; + }), + ); + + expect(results[0]).toBeInstanceOf(Error); + expect(results[0]).toEqual(results[1]); + expect((results[0] as Error).message).toContain('Elicitation response content does not match requested schema'); + }); +}); diff --git a/libs/sdk/src/transport/adapters/transport.local.adapter.ts b/libs/sdk/src/transport/adapters/transport.local.adapter.ts index cd908fef1..4225a9169 100644 --- a/libs/sdk/src/transport/adapters/transport.local.adapter.ts +++ b/libs/sdk/src/transport/adapters/transport.local.adapter.ts @@ -2,7 +2,7 @@ import { type ZodType } from '@frontmcp/lazy-zod'; import { ElicitResultSchema, EmptyResultSchema, - McpServer, + type McpServer, type RequestId, type StreamableHTTPServerTransport, } from '@frontmcp/protocol'; @@ -23,6 +23,7 @@ import { type AuthenticatedServerRequest, type SdkAuthInfo } from '../../server/ import { composeCallerInstructions } from '../../skill/skill-instructions.helper'; import { computeTaskCapabilities } from '../../task/task-scope.helper'; import { createMcpHandlers } from '../mcp-handlers'; +import { createMcpServer } from '../mcp-server.factory'; import { type TransportKey, type TransportType } from '../transport.types'; import { type RecreateableSSEServerTransport } from './sse-transport'; import { type RecreateableStreamableHTTPServerTransport } from './streamable-http-transport'; @@ -249,7 +250,7 @@ export abstract class LocalTransportAdapter { serverInfo: JSON.stringify(serverOptions.serverInfo), }); - this.server = new McpServer(info, serverOptions); + this.server = createMcpServer(info, serverOptions); const handlers = createMcpHandlers({ scope: this.scope, serverOptions, diff --git a/libs/sdk/src/transport/in-memory-server.ts b/libs/sdk/src/transport/in-memory-server.ts index d17231a4a..9c2ed13cb 100644 --- a/libs/sdk/src/transport/in-memory-server.ts +++ b/libs/sdk/src/transport/in-memory-server.ts @@ -11,7 +11,6 @@ import { ErrorCode, InMemoryTransport, McpError, - McpServer, type AuthInfo, type Transport, } from '@frontmcp/protocol'; @@ -23,6 +22,7 @@ import { ElicitationTimeoutError } from '../errors'; import { type Scope } from '../scope/scope.instance'; import { composeCallerInstructions } from '../skill/skill-instructions.helper'; import { buildScopedServerOptions } from './build-scoped-server-options'; +import { createMcpServer } from './mcp-server.factory'; /** * Options for creating an in-memory MCP server. @@ -166,7 +166,7 @@ export async function createInMemoryServer( const serverOptions = buildScopedServerOptions(scope); // Create MCP server - const mcpServer = new McpServer(scope.metadata.info, serverOptions); + const mcpServer = createMcpServer(scope.metadata.info, serverOptions); // Register handlers with auth context injection const composeInstructions = (caller?: { authInfo?: unknown }) => diff --git a/libs/sdk/src/transport/mcp-server.factory.ts b/libs/sdk/src/transport/mcp-server.factory.ts new file mode 100644 index 000000000..27cd95578 --- /dev/null +++ b/libs/sdk/src/transport/mcp-server.factory.ts @@ -0,0 +1,12 @@ +import { AjvJsonSchemaValidator, McpServer, type Implementation, type McpServerOptions } from '@frontmcp/protocol'; + +// The MCP server builds Ajv to check the elicitation results it requests itself, which FrontMCP never does; build it on first use. +export function createMcpServer(info: Implementation, options: McpServerOptions): McpServer { + let validator: AjvJsonSchemaValidator | undefined; + return new McpServer(info, { + ...options, + jsonSchemaValidator: { + getValidator: (schema) => (validator ??= new AjvJsonSchemaValidator()).getValidator(schema), + }, + }); +} diff --git a/libs/sdk/src/transport/web-standard-mcp.ts b/libs/sdk/src/transport/web-standard-mcp.ts index 5f4b6adc4..bf096b97c 100644 --- a/libs/sdk/src/transport/web-standard-mcp.ts +++ b/libs/sdk/src/transport/web-standard-mcp.ts @@ -16,13 +16,14 @@ * Auth / quota / router / audit / metrics + hooks have already run in the flow * around this call; this only translates the MCP request↔response. */ -import { McpServer, WebStandardStreamableHTTPServerTransport, type AuthInfo } from '@frontmcp/protocol'; +import { WebStandardStreamableHTTPServerTransport, type AuthInfo } from '@frontmcp/protocol'; import { randomUUID } from '@frontmcp/utils'; import { type Scope } from '../scope/scope.instance'; import { composeCallerInstructions } from '../skill/skill-instructions.helper'; import { mcpTransportBodyLimit } from './body-limit'; import { buildScopedServerOptions, type ScopedServerOptions } from './build-scoped-server-options'; +import { createMcpServer } from './mcp-server.factory'; import { type PersistentSessionOwnerStore } from './persistent-session-owner'; const STATELESS_METHODS = ['POST', 'DELETE']; @@ -65,7 +66,7 @@ async function wireServer( ): Promise { const { createMcpHandlers } = await import('./mcp-handlers/index.js'); - const mcpServer = new McpServer(scope.metadata.info, serverOptions); + const mcpServer = createMcpServer(scope.metadata.info, serverOptions); const composeInstructions = (caller?: { authInfo?: unknown }) => composeCallerInstructions(scope, { ctx: caller, skillUriHints: true }); for (const handler of createMcpHandlers({ scope, serverOptions, composeInstructions })) { From 00dcb4c09ffe7718c120b8e3d2889648688a50d1 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 16:23:13 +0200 Subject: [PATCH 06/25] test(e2e): ignore a package's own function names in the bundled-utils scan and time out npm child processes The scan reported any function or class named like a @frontmcp/utils export, so a local helper such as escapeHtml failed it. Names the package's own sources define are now left out, and a positive control checks that the remaining names still identify definitions in utils' build. npm install and the load checks run synchronously, which blocks Jest's own timeouts; they now time out on their own. --- .../e2e/enclave-2-16-top-level.e2e.spec.ts | 11 +++++--- .../e2e/helpers/published-package.ts | 27 +++++++++++++++---- .../e2e/published-imports.e2e.spec.ts | 19 +++++++++---- 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/apps/e2e/demo-e2e-published-imports/e2e/enclave-2-16-top-level.e2e.spec.ts b/apps/e2e/demo-e2e-published-imports/e2e/enclave-2-16-top-level.e2e.spec.ts index 1f89bffd8..66d95ca86 100644 --- a/apps/e2e/demo-e2e-published-imports/e2e/enclave-2-16-top-level.e2e.spec.ts +++ b/apps/e2e/demo-e2e-published-imports/e2e/enclave-2-16-top-level.e2e.spec.ts @@ -17,8 +17,9 @@ const PACKAGES = ['@frontmcp/plugin-cache', '@frontmcp/plugin-dashboard', '@fron const TOP_LEVEL_ENCLAVE = '2.16.0'; function load(project: string, args: string[]): string { - const result = spawnSync(process.execPath, args, { cwd: project, encoding: 'utf8' }); - return result.status === 0 ? 'loaded' : result.stderr; + const result = spawnSync(process.execPath, args, { cwd: project, encoding: 'utf8', timeout: 60_000 }); + if (result.status === 0) return 'loaded'; + return result.stderr || `exited with ${result.signal ?? result.status}`; } describe(`a fresh npm project with @enclave-vm/ast ${TOP_LEVEL_ENCLAVE} at the top level`, () => { @@ -34,7 +35,11 @@ describe(`a fresh npm project with @enclave-vm/ast ${TOP_LEVEL_ENCLAVE} at the t dependencies[name] = `file:${packPackage(await builtDistDir(distDirs, name), tarballs).tarball}`; } await writeJSON(path.join(project, 'package.json'), { name: 'enclave-2-16-project', private: true, dependencies }); - execFileSync('npm', ['install', '--no-audit', '--no-fund', '--ignore-scripts'], { cwd: project, stdio: 'pipe' }); + execFileSync('npm', ['install', '--no-audit', '--no-fund', '--ignore-scripts'], { + cwd: project, + stdio: 'pipe', + timeout: 240_000, + }); }, 300_000); afterAll(async () => { diff --git a/apps/e2e/demo-e2e-published-imports/e2e/helpers/published-package.ts b/apps/e2e/demo-e2e-published-imports/e2e/helpers/published-package.ts index 4777a4481..999953b03 100644 --- a/apps/e2e/demo-e2e-published-imports/e2e/helpers/published-package.ts +++ b/apps/e2e/demo-e2e-published-imports/e2e/helpers/published-package.ts @@ -166,13 +166,30 @@ function definedName(node: ts.Node): string | undefined { return undefined; } -export function definitionsNamed(script: PublishedScript, names: ReadonlySet): string[] { - const definitions: string[] = []; +function definedNames(sourceFile: ts.SourceFile): string[] { + const names: string[] = []; const visit = (node: ts.Node): void => { const name = definedName(node); - if (name && names.has(name)) definitions.push(`${script.path}: ${name}`); + if (name) names.push(name); ts.forEachChild(node, visit); }; - visit(parse(script)); - return definitions; + visit(sourceFile); + return names; +} + +export function definitionsNamed(script: PublishedScript, names: ReadonlySet): string[] { + return definedNames(parse(script)) + .filter((name) => names.has(name)) + .map((name) => `${script.path}: ${name}`); +} + +export async function sourceDefinitions(packageRoot: string): Promise> { + const { config } = ts.readConfigFile(path.join(packageRoot, 'tsconfig.lib.json'), ts.sys.readFile); + const { fileNames } = ts.parseJsonConfigFileContent(config, ts.sys, packageRoot); + const names = new Set(); + for (const fileName of fileNames) { + const sourceFile = ts.createSourceFile(fileName, await readFile(fileName), ts.ScriptTarget.Latest); + for (const name of definedNames(sourceFile)) names.add(name); + } + return names; } diff --git a/apps/e2e/demo-e2e-published-imports/e2e/published-imports.e2e.spec.ts b/apps/e2e/demo-e2e-published-imports/e2e/published-imports.e2e.spec.ts index 3c784e6d7..602e46d54 100644 --- a/apps/e2e/demo-e2e-published-imports/e2e/published-imports.e2e.spec.ts +++ b/apps/e2e/demo-e2e-published-imports/e2e/published-imports.e2e.spec.ts @@ -10,9 +10,11 @@ import { exportedNames, loadedSpecifiers, readPublishedPackage, + sourceDefinitions, undeclaredImports, workspaceDistDirs, type PublishedPackage, + type PublishedScript, } from './helpers/published-package'; const PACKAGES = ['@frontmcp/plugin-cache', '@frontmcp/plugin-dashboard', '@frontmcp/edge']; @@ -20,13 +22,15 @@ const PACKAGES = ['@frontmcp/plugin-cache', '@frontmcp/plugin-dashboard', '@fron describe('published JavaScript that uses @frontmcp/utils', () => { let workDir: string; let distDirs: Map; + let utilsScript: PublishedScript; let utilsExports: Set; beforeAll(async () => { workDir = await mkdtemp(path.join(tmpdir(), 'frontmcp-published-imports-')); distDirs = await workspaceDistDirs(); const utilsEsm = path.join(await builtDistDir(distDirs, '@frontmcp/utils'), 'esm', 'index.mjs'); - utilsExports = exportedNames({ path: 'esm/index.mjs', source: await readFile(utilsEsm) }); + utilsScript = { path: 'esm/index.mjs', source: await readFile(utilsEsm) }; + utilsExports = exportedNames(utilsScript); }); afterAll(async () => { @@ -34,10 +38,13 @@ describe('published JavaScript that uses @frontmcp/utils', () => { }); describe.each(PACKAGES)('%s', (name) => { + let packageRoot: string; let published: PublishedPackage; beforeAll(async () => { - published = await readPublishedPackage(await builtDistDir(distDirs, name), workDir); + const distDir = await builtDistDir(distDirs, name); + packageRoot = path.dirname(distDir); + published = await readPublishedPackage(distDir, workDir); }); it('declares @frontmcp/utils at its own version', () => { @@ -51,9 +58,11 @@ describe('published JavaScript that uses @frontmcp/utils', () => { expect(importers).toEqual(expect.arrayContaining(['index.js', 'esm/index.mjs'])); }); - it('defines none of the functions and classes @frontmcp/utils exports', () => { - expect(utilsExports.size).toBeGreaterThan(0); - expect(published.scripts.flatMap((script) => definitionsNamed(script, utilsExports))).toEqual([]); + it('defines none of the functions and classes @frontmcp/utils exports, apart from its own', async () => { + const ownNames = await sourceDefinitions(packageRoot); + const utilsOnlyNames = new Set([...utilsExports].filter((utilsName) => !ownNames.has(utilsName))); + expect(definitionsNamed(utilsScript, utilsOnlyNames).length).toBeGreaterThan(0); + expect(published.scripts.flatMap((script) => definitionsNamed(script, utilsOnlyNames))).toEqual([]); }); it('imports only packages its package.json declares', () => { From 95f5e0813af986e94a3e1e6eb4bbc94d35be7d91 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 16:42:27 +0200 Subject: [PATCH 07/25] fix(sdk): leave alone a JSON body read before the UTF-8 parser When a host hands over a request whose body was already read into a Buffer, body-parser's raw() skips it and json() leaves it as it is. The UTF-8 parser parsed that Buffer anyway; it now parses only a body its own raw() read. Refs #951 --- .../__tests__/utf8-json.parser.spec.ts | 22 +++++++++++++++++++ .../src/server/adapters/utf8-json.parser.ts | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/libs/sdk/src/server/adapters/__tests__/utf8-json.parser.spec.ts b/libs/sdk/src/server/adapters/__tests__/utf8-json.parser.spec.ts index c9cf8893f..518fa3989 100644 --- a/libs/sdk/src/server/adapters/__tests__/utf8-json.parser.spec.ts +++ b/libs/sdk/src/server/adapters/__tests__/utf8-json.parser.spec.ts @@ -120,6 +120,28 @@ describe('utf8JsonParser', () => { } }); + it('leaves alone a body an earlier middleware read, as express.json() does', async () => { + const readEarlier: express.RequestHandler = (req, _res, next) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + req.body = Buffer.concat(chunks); + next(); + }); + }; + const earlier = await listen([readEarlier, utf8JsonParser('1kb'), express.json({ limit: '1kb' })]); + const stockEarlier = await listen([readEarlier, express.json({ limit: '1kb' })]); + try { + const body = Buffer.from('{"a":1}'); + const expected = await post(stockEarlier.url, 'application/json', body, false); + expect(expected).toEqual({ status: 200, parsed: { type: 'Buffer', data: [...body] } }); + expect(await post(earlier.url, 'application/json', body, false)).toEqual(expected); + } finally { + for (const { server } of [earlier, stockEarlier]) + await new Promise((resolve) => server.close(() => resolve())); + } + }); + it('leaves a body in another charset to express.json()', async () => { const body = Buffer.from('{"a":"café"}', 'utf16le'); expect(await post(utf8.url, 'application/json; charset=utf-16le', body, false)).toEqual({ diff --git a/libs/sdk/src/server/adapters/utf8-json.parser.ts b/libs/sdk/src/server/adapters/utf8-json.parser.ts index 6d94c50c7..ffbe5bc98 100644 --- a/libs/sdk/src/server/adapters/utf8-json.parser.ts +++ b/libs/sdk/src/server/adapters/utf8-json.parser.ts @@ -14,7 +14,7 @@ export function utf8JsonParser(limit: number | string): express.RequestHandler { return (req, res, next) => { const unparsedBody: unknown = req.body; readBody(req, res, (readError?: unknown) => { - if (readError || !Buffer.isBuffer(req.body)) { + if (readError || req.body === unparsedBody || !Buffer.isBuffer(req.body)) { next(readError); return; } From e9f66fd4bf9b95ed8388e8ada2167abd1ae3fe69 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 16:43:19 +0200 Subject: [PATCH 08/25] perf(sdk): load the Streamable HTTP transport once an HTTP server listens The transport, and through it @hono/node-server and Node's bundled undici, loaded with the first Streamable HTTP session, so a server's first initialize waited for it. A scope that serves Streamable HTTP (streamable, stateless or JSON responses) now loads it right after the server starts listening, which is not on the path to listening and is usually before a client arrives. A stdio server, a serverless handler and a Worker never start listening and load it as before; an SSE-only server still never loads it. Refs #951 --- libs/sdk/src/scope/http-scope.helper.ts | 1 + libs/sdk/src/scope/scope.instance.ts | 1 + .../__tests__/streamable-http-preload.spec.ts | 64 +++++++++++++++++++ .../src/transport/streamable-http-preload.ts | 18 ++++++ 4 files changed, 84 insertions(+) create mode 100644 libs/sdk/src/transport/__tests__/streamable-http-preload.spec.ts create mode 100644 libs/sdk/src/transport/streamable-http-preload.ts diff --git a/libs/sdk/src/scope/http-scope.helper.ts b/libs/sdk/src/scope/http-scope.helper.ts index ba4e40393..b9cc1b349 100644 --- a/libs/sdk/src/scope/http-scope.helper.ts +++ b/libs/sdk/src/scope/http-scope.helper.ts @@ -2,5 +2,6 @@ export { default as HttpRequestFlow } from './flows/http.request.flow'; export { default as HttpIpFilterFlow } from './flows/http.ip-filter.flow'; export { TransportService } from '../transport/transport.registry'; +export { loadStreamableHttpTransportOnStart } from '../transport/streamable-http-preload'; export { registerCustomHttpRoutes } from '../server/custom-routes.helper'; export { HealthService, servedHealthPaths } from '../health'; diff --git a/libs/sdk/src/scope/scope.instance.ts b/libs/sdk/src/scope/scope.instance.ts index e9e8533ff..0f4f3d71d 100644 --- a/libs/sdk/src/scope/scope.instance.ts +++ b/libs/sdk/src/scope/scope.instance.ts @@ -319,6 +319,7 @@ export class Scope extends ScopeEntry { } if (httpServing) { this.transportService = new httpServing.TransportService(this, effectivePersistence, transportBus); + httpServing.loadStreamableHttpTransportOnStart(this); } warnIfRequestStateKeyNotShared({ logger: this.logger, metadata: this.metadata }); warnIfSessionModeIgnored({ logger: this.logger, transport: transportConfig, session: this.metadata.session }); diff --git a/libs/sdk/src/transport/__tests__/streamable-http-preload.spec.ts b/libs/sdk/src/transport/__tests__/streamable-http-preload.spec.ts new file mode 100644 index 000000000..72ea8c274 --- /dev/null +++ b/libs/sdk/src/transport/__tests__/streamable-http-preload.spec.ts @@ -0,0 +1,64 @@ +import { loadStreamableHttpTransport } from '../adapters/transport.streamable-http.adapter'; +import { loadStreamableHttpTransportOnStart } from '../streamable-http-preload'; + +jest.mock('../adapters/transport.streamable-http.adapter', () => ({ + loadStreamableHttpTransport: jest.fn().mockResolvedValue(undefined), +})); + +const loadTransport = loadStreamableHttpTransport as jest.MockedFunction; +const flushImmediates = () => new Promise((resolve) => setImmediate(resolve)); + +function serverWith(protocol: unknown) { + const onStarted: Array<() => void | Promise> = []; + const scope = { + metadata: { transport: { protocol } }, + onServerStarted: (callback: () => void | Promise) => onStarted.push(callback), + logger: { verbose: jest.fn() }, + }; + loadStreamableHttpTransportOnStart(scope as never); + const listen = async () => { + for (const callback of onStarted) await callback(); + await flushImmediates(); + }; + return { scope, listen }; +} + +const ONLY = { sse: false, streamable: false, json: false, stateless: false, legacy: false }; + +describe('loadStreamableHttpTransportOnStart', () => { + beforeEach(() => loadTransport.mockReset().mockResolvedValue(undefined)); + + it.each([ + ['the default', undefined], + ['legacy', 'legacy'], + ['modern', 'modern'], + ['stateless-api', 'stateless-api'], + ['full', 'full'], + ['streamable', { ...ONLY, streamable: true }], + ['stateless', { ...ONLY, stateless: true }], + ['json', { ...ONLY, json: true }], + ])('loads the Streamable HTTP transport once a server with %s protocol listens', async (_name, protocol) => { + const server = serverWith(protocol); + expect(loadTransport).not.toHaveBeenCalled(); + + await server.listen(); + expect(loadTransport).toHaveBeenCalledTimes(1); + }); + + it('leaves it to the first session on a server that serves only legacy SSE', async () => { + await serverWith({ ...ONLY, legacy: true, sse: true }).listen(); + expect(loadTransport).not.toHaveBeenCalled(); + }); + + it('keeps serving when the transport fails to load, which the first session then reports', async () => { + loadTransport.mockRejectedValue(new Error('missing module')); + const server = serverWith('modern'); + + await server.listen(); + await flushImmediates(); + expect(server.scope.logger.verbose).toHaveBeenCalledWith( + 'Streamable HTTP transport preload failed', + expect.objectContaining({ error: expect.any(Error) }), + ); + }); +}); diff --git a/libs/sdk/src/transport/streamable-http-preload.ts b/libs/sdk/src/transport/streamable-http-preload.ts new file mode 100644 index 000000000..ece831d18 --- /dev/null +++ b/libs/sdk/src/transport/streamable-http-preload.ts @@ -0,0 +1,18 @@ +import { toLegacyProtocolFlags } from '../common/types/options/transport/schema'; +import type { Scope } from '../scope/scope.instance'; +import { loadStreamableHttpTransport } from './adapters/transport.streamable-http.adapter'; + +// A listening server loads the Streamable HTTP transport while it waits for clients, not on the first initialize. +export function loadStreamableHttpTransportOnStart(scope: Scope): void { + const { enableStreamableHttp, enableStatelessHttp, enableStatefulHttp } = toLegacyProtocolFlags( + scope.metadata.transport?.protocol, + ); + if (!enableStreamableHttp && !enableStatelessHttp && !enableStatefulHttp) return; + scope.onServerStarted(() => { + setImmediate(() => { + loadStreamableHttpTransport().catch((error: unknown) => + scope.logger.verbose('Streamable HTTP transport preload failed', { error }), + ); + }); + }); +} From f44b363f013f06418c5d489170db13ec8c82be61 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 18:09:17 +0200 Subject: [PATCH 09/25] perf(protocol): export the MCP server's validator from the module its server comes from `AjvJsonSchemaValidator` came straight from the MCP SDK while `McpServer` comes through `#mcp-server`, which resolves to the SDK's CommonJS build under `import`. An ESM server therefore loaded the validator module from both builds. Both now come from `#mcp-server`. --- .../e2e/helpers/loaded-modules.mjs | 16 +++++++++ .../e2e/node-esm-validator.e2e.spec.ts | 33 +++++++++++++++++++ libs/protocol/src/__tests__/exports.spec.ts | 9 +++-- libs/protocol/src/browser-mcp-server.ts | 1 + libs/protocol/src/index.ts | 3 +- libs/protocol/src/node-mcp-server.ts | 1 + 6 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 apps/e2e/demo-e2e-bundle-size/e2e/helpers/loaded-modules.mjs create mode 100644 apps/e2e/demo-e2e-bundle-size/e2e/node-esm-validator.e2e.spec.ts diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/helpers/loaded-modules.mjs b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/loaded-modules.mjs new file mode 100644 index 000000000..92b342b28 --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/loaded-modules.mjs @@ -0,0 +1,16 @@ +// Imports a module and prints, as JSON, the URLs of the modules that loaded with it and match a pattern: node loaded-modules.mjs +import { registerHooks } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const [entry, pattern] = process.argv.slice(2); +const matching = new RegExp(pattern); +const loaded = []; +registerHooks({ + load(url, context, nextLoad) { + if (matching.test(url)) loaded.push(url); + return nextLoad(url, context); + }, +}); + +await import(pathToFileURL(entry).href); +process.stdout.write(JSON.stringify(loaded)); diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/node-esm-validator.e2e.spec.ts b/apps/e2e/demo-e2e-bundle-size/e2e/node-esm-validator.e2e.spec.ts new file mode 100644 index 000000000..8261e8d22 --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/node-esm-validator.e2e.spec.ts @@ -0,0 +1,33 @@ +/** + * An ESM process loads the MCP SDK's JSON Schema validator from the build its MCP server comes from, and no second + * copy from the other build (#951). + * + * Needs the packages built: `nx run-many -t build -p sdk plugin-webmcp`. + */ +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +import { workspaceRoot } from './helpers/browser-bundle'; + +function modulesLoadedBy(entry: string, pattern: string): string[] { + const runner = resolve(__dirname, 'helpers', 'loaded-modules.mjs'); + return JSON.parse( + execFileSync(process.execPath, [runner, entry, pattern], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }), + ); +} + +describe("the MCP server's JSON Schema validator under ESM", () => { + it('loads from the same build as the MCP server', () => { + const loaded = modulesLoadedBy( + resolve(workspaceRoot, 'libs/sdk/dist/esm/index.mjs'), + '/@modelcontextprotocol/sdk/dist/(esm|cjs)/(server/index|validation/ajv-provider)\\.js$', + ); + + const buildsOf = (module: string) => + loaded + .filter((url) => url.endsWith(`/${module}`)) + .map((url) => url.split('/@modelcontextprotocol/sdk/dist/')[1].split('/')[0]); + expect(buildsOf('server/index.js')).toHaveLength(1); + expect(buildsOf('validation/ajv-provider.js')).toEqual(buildsOf('server/index.js')); + }); +}); diff --git a/libs/protocol/src/__tests__/exports.spec.ts b/libs/protocol/src/__tests__/exports.spec.ts index 400b573b2..6ab2bc315 100644 --- a/libs/protocol/src/__tests__/exports.spec.ts +++ b/libs/protocol/src/__tests__/exports.spec.ts @@ -8,7 +8,10 @@ import { WebStandardStreamableHTTPServerTransport as SdkWebStandardTransport } f import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js'; import { AjvJsonSchemaValidator as SdkAjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv-provider.js'; -import { Server as BrowserServer } from '../browser-mcp-server'; +import { + AjvJsonSchemaValidator as BrowserAjvJsonSchemaValidator, + Server as BrowserServer, +} from '../browser-mcp-server'; import * as browserStreamableHttp from '../browser-mcp-streamable-http'; import * as browserServerTypes from '../browser-server-types'; import { StdioClientTransport as BrowserStdioClientTransport } from '../browser-stdio-client'; @@ -16,7 +19,7 @@ import { StdioServerTransport as BrowserStdioServerTransport } from '../browser- import * as clients from '../clients'; import { InMemoryTransport } from '../in-memory'; import * as protocol from '../index'; -import { Server as NodeServer } from '../node-mcp-server'; +import { AjvJsonSchemaValidator as NodeAjvJsonSchemaValidator, Server as NodeServer } from '../node-mcp-server'; import * as nodeStreamableHttp from '../node-mcp-streamable-http'; import * as nodeServerTypes from '../node-server-types'; import { StdioServerTransport as NodeStdioServerTransport } from '../node-stdio-server'; @@ -27,6 +30,8 @@ describe('@frontmcp/protocol exports', () => { it('re-exports the MCP SDK server, transports and types for Node', () => { expect(NodeServer).toBe(SdkServer); expect(BrowserServer).toBe(SdkServer); + expect(NodeAjvJsonSchemaValidator).toBe(SdkAjvJsonSchemaValidator); + expect(BrowserAjvJsonSchemaValidator).toBe(SdkAjvJsonSchemaValidator); expect(nodeStreamableHttp.StreamableHTTPServerTransport).toBe(SdkStreamableHTTPServerTransport); expect(nodeStreamableHttp.WebStandardStreamableHTTPServerTransport).toBe(SdkWebStandardTransport); expect(NodeStdioServerTransport).toBe(SdkStdioServerTransport); diff --git a/libs/protocol/src/browser-mcp-server.ts b/libs/protocol/src/browser-mcp-server.ts index 9db31349a..e6080c816 100644 --- a/libs/protocol/src/browser-mcp-server.ts +++ b/libs/protocol/src/browser-mcp-server.ts @@ -1,3 +1,4 @@ // Re-export the real MCP Server for browser builds. // The Server class and InMemoryTransport do not depend on Node.js streams. export { Server, type ServerOptions } from '@modelcontextprotocol/sdk/server/index.js'; +export { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv-provider.js'; diff --git a/libs/protocol/src/index.ts b/libs/protocol/src/index.ts index 7f5c72873..ff75d2160 100644 --- a/libs/protocol/src/index.ts +++ b/libs/protocol/src/index.ts @@ -26,8 +26,7 @@ export * from './stdio'; export * from './in-memory'; // Server (browser/node conditional via #imports) -export { Server as McpServer, type ServerOptions as McpServerOptions } from '#mcp-server'; -export { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv-provider.js'; +export { Server as McpServer, type ServerOptions as McpServerOptions, AjvJsonSchemaValidator } from '#mcp-server'; export { StreamableHTTPServerTransport, WebStandardStreamableHTTPServerTransport, diff --git a/libs/protocol/src/node-mcp-server.ts b/libs/protocol/src/node-mcp-server.ts index c76893221..1524b840b 100644 --- a/libs/protocol/src/node-mcp-server.ts +++ b/libs/protocol/src/node-mcp-server.ts @@ -1 +1,2 @@ export { Server, type ServerOptions } from '@modelcontextprotocol/sdk/server/index.js'; +export { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv-provider.js'; From 151249e0e4b9d5615cf70c9003f404028ea08836 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 18:09:28 +0200 Subject: [PATCH 10/25] test(sdk): pin more request bodies and the entry points that load the transport early The body pins add brotli, two byte order marks, a parameter other than charset and a vendor +json type; they pass with and without the UTF-8 JSON parser. A listening server loads the Streamable HTTP transport after start() returns and reports ready; createHandler(), createFetchHandler() and runStdio() never do. --- ...reamable-http-preload.entry-points.spec.ts | 130 ++++++++++++++++++ .../express.host.adapter.body.spec.ts | 15 +- 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 libs/sdk/src/front-mcp/__tests__/streamable-http-preload.entry-points.spec.ts diff --git a/libs/sdk/src/front-mcp/__tests__/streamable-http-preload.entry-points.spec.ts b/libs/sdk/src/front-mcp/__tests__/streamable-http-preload.entry-points.spec.ts new file mode 100644 index 000000000..36ea3840b --- /dev/null +++ b/libs/sdk/src/front-mcp/__tests__/streamable-http-preload.entry-points.spec.ts @@ -0,0 +1,130 @@ +/** + * Only a server that listens loads the Streamable HTTP transport ahead of its first session (#951). A serverless + * handler, a fetch handler and a stdio server never listen, so their first session loads it, as before. + */ +import 'reflect-metadata'; + +import { App, frontMcpMetadataSchema, LogLevel, Tool, ToolContext } from '../../common'; +import { FrontMcpServerInstance } from '../../server/server.instance'; +import { loadStreamableHttpTransport } from '../../transport/adapters/transport.streamable-http.adapter'; +import { FrontMcpInstance } from '../front-mcp'; + +jest.mock('../../transport/adapters/transport.streamable-http.adapter', () => ({ + ...jest.requireActual('../../transport/adapters/transport.streamable-http.adapter'), + loadStreamableHttpTransport: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('@frontmcp/protocol', () => { + const actual = jest.requireActual('@frontmcp/protocol'); + class IdleMcpServer { + setRequestHandler() { + return undefined; + } + setNotificationHandler() { + return undefined; + } + async connect() { + return undefined; + } + async close() { + return undefined; + } + } + class IdleStdioServerTransport { + async close() { + return undefined; + } + } + return { ...actual, McpServer: IdleMcpServer, StdioServerTransport: IdleStdioServerTransport }; +}); + +const loadTransport = loadStreamableHttpTransport as jest.MockedFunction; +const flushImmediates = () => new Promise((resolve) => setImmediate(resolve)); + +@Tool({ name: 'ping', inputSchema: {} }) +class PingTool extends ToolContext { + async execute() { + return { pong: true }; + } +} + +@App({ id: 'ping', name: 'Ping', tools: [PingTool] }) +class PingApp {} + +const config = (name: string) => ({ + info: { name, version: '1.0.0' }, + apps: [PingApp], + logging: { level: LogLevel.Off, enableConsole: false }, +}); + +describe('loading the Streamable HTTP transport ahead of the first session', () => { + const savedSentinel = process.env['FRONTMCP_DEV_BOOTSTRAP_SENTINEL']; + + beforeEach(() => loadTransport.mockClear()); + + afterAll(() => { + if (savedSentinel === undefined) delete process.env['FRONTMCP_DEV_BOOTSTRAP_SENTINEL']; + else process.env['FRONTMCP_DEV_BOOTSTRAP_SENTINEL'] = savedSentinel; + }); + + it('happens after a listening server has started and reported ready', async () => { + process.env['FRONTMCP_DEV_BOOTSTRAP_SENTINEL'] = '1'; + const instance = new FrontMcpInstance(frontMcpMetadataSchema.parse(config('listening'))); + await instance.ready; + await flushImmediates(); + expect(loadTransport).not.toHaveBeenCalled(); + const start = jest.spyOn(FrontMcpServerInstance.prototype, 'start').mockResolvedValue(undefined); + const write = jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + await instance.start(); + expect(write).toHaveBeenCalledWith(expect.stringContaining('__FRONTMCP_BOOTSTRAP_COMPLETE__')); + expect(loadTransport).not.toHaveBeenCalled(); + + await flushImmediates(); + expect(loadTransport).toHaveBeenCalledTimes(1); + } finally { + write.mockRestore(); + start.mockRestore(); + } + }); + + it('does not happen for a serverless handler', async () => { + await FrontMcpInstance.createHandler(config('serverless')); + await flushImmediates(); + expect(loadTransport).not.toHaveBeenCalled(); + }); + + it('does not happen for a fetch handler', async () => { + const handler = await FrontMcpInstance.createFetchHandler(config('fetch')); + expect((await handler(new Request('https://example.com/healthz'))).status).toBe(200); + await flushImmediates(); + expect(loadTransport).not.toHaveBeenCalled(); + }); + + describe('for a stdio server', () => { + const savedConsole = { ...console }; + const savedStdioEnv = process.env['FRONTMCP_STDIO']; + const savedLogsMax = process.env['FRONTMCP_LOGS_MAX']; + const signalsBefore = { SIGINT: process.listeners('SIGINT'), SIGTERM: process.listeners('SIGTERM') }; + + afterAll(() => { + Object.assign(console, savedConsole); + if (savedStdioEnv === undefined) delete process.env['FRONTMCP_STDIO']; + else process.env['FRONTMCP_STDIO'] = savedStdioEnv; + if (savedLogsMax === undefined) delete process.env['FRONTMCP_LOGS_MAX']; + else process.env['FRONTMCP_LOGS_MAX'] = savedLogsMax; + for (const signal of ['SIGINT', 'SIGTERM'] as const) { + for (const listener of process.listeners(signal)) { + if (!signalsBefore[signal].includes(listener)) process.removeListener(signal, listener); + } + } + }); + + it('does not happen', async () => { + process.env['FRONTMCP_LOGS_MAX'] = '0'; + await FrontMcpInstance.runStdio(config('stdio')); + await flushImmediates(); + expect(loadTransport).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/sdk/src/server/adapters/__tests__/express.host.adapter.body.spec.ts b/libs/sdk/src/server/adapters/__tests__/express.host.adapter.body.spec.ts index 75f5afd4e..82d5c06e5 100644 --- a/libs/sdk/src/server/adapters/__tests__/express.host.adapter.body.spec.ts +++ b/libs/sdk/src/server/adapters/__tests__/express.host.adapter.body.spec.ts @@ -1,5 +1,5 @@ import * as http from 'node:http'; -import { deflateSync, gzipSync } from 'node:zlib'; +import { brotliCompressSync, deflateSync, gzipSync } from 'node:zlib'; import type express from 'express'; @@ -90,6 +90,7 @@ const REQUESTS: Record = { 'a quoted charset': text('{"a":1}', 'application/json; charset="utf-8"'), 'an upper-case type': text('{"a":1}', 'APPLICATION/JSON'), 'a byte order mark': { contentType: 'application/json', body: Buffer.concat([BOM, Buffer.from('{"a":1}')]) }, + 'two byte order marks': { contentType: 'application/json', body: Buffer.concat([BOM, BOM, Buffer.from('{"a":1}')]) }, 'whitespace before an array': text(' \n\t[1,2]'), 'an empty body': text(''), 'only whitespace': text(' \n'), @@ -118,7 +119,9 @@ const REQUESTS: Record = { 'charset=latin1': text('{"a":1}', 'application/json; charset=latin1'), 'charset=utf8': text('{"a":1}', 'application/json; charset=utf8'), 'charset=utf-9': text('{"a":1}', 'application/json; charset=utf-9'), + 'a parameter other than charset': text('{"a":1}', 'application/json; foo=bar'), 'a +json suffix type': text('[1]', 'application/json-patch+json'), + 'a vendor +json type': text('[1]', 'application/vnd.api+json'), 'JSON listed with text': text('{"a":1}', 'application/json, text/plain'), 'JSON listed with a form': text('a=1', 'application/json, application/x-www-form-urlencoded'), 'a form': text('a=1&b[c]=2', 'application/x-www-form-urlencoded'), @@ -126,6 +129,7 @@ const REQUESTS: Record = { 'no content type': { body: Buffer.from('{"a":1}') }, gzip: { contentType: 'application/json', contentEncoding: 'gzip', body: gzipSync('{"a":1}') }, deflate: { contentType: 'application/json', contentEncoding: 'deflate', body: deflateSync('{"a":1}') }, + brotli: { contentType: 'application/json', contentEncoding: 'br', body: brotliCompressSync('{"a":1}') }, 'corrupt gzip': { contentType: 'application/json', contentEncoding: 'gzip', body: Buffer.from('{"a":1}') }, 'an unsupported encoding': { contentType: 'application/json', contentEncoding: 'compress', body: Buffer.from('{}') }, 'a body at the limit': text(`{"a":"${'x'.repeat(1016)}"}`), @@ -165,6 +169,7 @@ const ANSWERS: Record = { 'a quoted charset': echo({ a: 1 }), 'an upper-case type': echo({ a: 1 }), 'a byte order mark': echo({ a: 1 }), + 'two byte order marks': errorPage(400, 'Bad Request'), 'whitespace before an array': echo([1, 2]), 'an empty body': echo({}), 'only whitespace': errorPage(400, 'Bad Request'), @@ -183,7 +188,9 @@ const ANSWERS: Record = { 'charset=latin1': errorPage(415, 'Unsupported Media Type'), 'charset=utf8': errorPage(415, 'Unsupported Media Type'), 'charset=utf-9': errorPage(415, 'Unsupported Media Type'), + 'a parameter other than charset': echo({ a: 1 }), 'a +json suffix type': echo([1]), + 'a vendor +json type': echo(''), 'JSON listed with text': echo({ a: 1 }), 'JSON listed with a form': echo({ a: '1' }), 'a form': echo({ a: '1', b: { c: '2' } }), @@ -191,6 +198,7 @@ const ANSWERS: Record = { 'no content type': echo(''), gzip: echo({ a: 1 }), deflate: echo({ a: 1 }), + brotli: echo({ a: 1 }), 'corrupt gzip': errorPage(400, 'Bad Request'), 'an unsupported encoding': errorPage(415, 'Unsupported Media Type'), 'a body at the limit': echo({ a: 'x'.repeat(1016) }), @@ -234,6 +242,11 @@ const ERRORS: Record = { 'a number literal': parseFailed('SyntaxError', 'Unexpected token \'4\', "42" is not valid JSON', '42'), 'a true literal': parseFailed('SyntaxError', 'Unexpected token \'t\', "true" is not valid JSON', 'true'), 'a bare word': parseFailed('SyntaxError', 'Unexpected token \'x\', " x{}" is not valid JSON', ' x{}'), + 'two byte order marks': parseFailed( + 'SyntaxError', + 'Unexpected token \'\ufeff\', "\ufeff{"a":1}" is not valid JSON', + '\ufeff{"a":1}', + ), 'a truncated object': parseFailed('SyntaxError', 'Unexpected end of JSON input', '{"a":'), 'an unquoted key': parseFailed( 'SyntaxError', From b8bbaf06e1704b5c17bedb00f7c7d25f69cfb9b3 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 18:56:10 +0200 Subject: [PATCH 11/25] test(bundle-size): pin the names and kinds of the SDK's root exports under require() and import --- .../e2e/fixtures/sdk-root-exports.json | 926 ++++++++++++++++++ .../e2e/helpers/root-exports.mjs | 22 + .../e2e/sdk-root-exports.e2e.spec.ts | 35 + 3 files changed, 983 insertions(+) create mode 100644 apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json create mode 100644 apps/e2e/demo-e2e-bundle-size/e2e/helpers/root-exports.mjs create mode 100644 apps/e2e/demo-e2e-bundle-size/e2e/sdk-root-exports.e2e.spec.ts diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json b/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json new file mode 100644 index 000000000..60dc6d602 --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json @@ -0,0 +1,926 @@ +{ + "Adapter": "function", + "AdapterEntry": "class", + "AdapterKind": "object", + "Agent": "function", + "AgentCallDepthExceededError": "class", + "AgentCallHook": "object", + "AgentConfigKeyNotFoundError": "class", + "AgentConfigurationError": "class", + "AgentContext": "class", + "AgentEntry": "class", + "AgentExecutionError": "class", + "AgentKind": "object", + "AgentLlmError": "class", + "AgentLoopExceededError": "class", + "AgentMethodNotAvailableError": "class", + "AgentNotConfiguredError": "class", + "AgentNotFoundError": "class", + "AgentTimeoutError": "class", + "AgentToolExecutionError": "class", + "AgentToolNotFoundError": "class", + "AgentVisibilityError": "class", + "AnthropicAdapter": "class", + "App": "function", + "AppEntry": "class", + "AppKind": "object", + "AroundHookOf": "function", + "AsyncAuthProvider": "function", + "AsyncProvider": "function", + "AudioOutputSchema": "object", + "Auth": "class", + "AuthConfigurationError": "class", + "AuthHookStage": "object", + "AuthProvider": "function", + "AuthProviderEntry": "class", + "AuthProviderKind": "object", + "AuthProviderScope": "object", + "AuthProvidersNotConfiguredError": "class", + "AuthorizationRequiredError": "class", + "BaseConfig": "class", + "BaseEntry": "class", + "CapabilityCache": "class", + "Channel": "function", + "ChannelContext": "class", + "ChannelEmitter": "class", + "ChannelEntry": "class", + "ChannelEventBus": "class", + "ChannelInstance": "class", + "ChannelKind": "object", + "ChannelListHook": "object", + "ChannelNotificationService": "class", + "ChannelRegistry": "class", + "ChannelReplyTool": "class", + "ChannelSendHook": "object", + "CircuitBreaker": "class", + "CircuitBreakerManager": "class", + "CircuitOpenError": "class", + "CliTaskRunner": "class", + "CompletionHook": "object", + "ConcurrencyLimitError": "class", + "ConfigMissingError": "class", + "ConfigNotFoundError": "class", + "ConfigPlugin": "class", + "ConfigPluginConfigToken": "symbol", + "ConfigService": "class", + "ConfigValidationError": "class", + "Context": "class", + "ContextExtensionNotAvailableError": "class", + "CredentialProviderAlreadyRegisteredError": "class", + "DEFAULT_ELICITATION_OPTIONS": "object", + "DEFAULT_ELICIT_TTL": "number", + "DEFAULT_FALLBACK_WAIT_TTL": "number", + "DEFAULT_HEALTH_OPTIONS": "object", + "DEFAULT_METRICS_OPTIONS": "object", + "DEFAULT_NPM_REGISTRY": "string", + "DEFAULT_OUTPUT_SCHEMA_MODE": "string", + "DEFAULT_REGISTRY_PUBLIC_KEY_ENV": "string", + "DEFAULT_SCHEMA_DESCRIPTION_FORMAT": "string", + "DEFAULT_TOOL_PAGINATION": "object", + "DependencyCycleError": "class", + "DependencyNotFoundError": "class", + "DidHookOf": "function", + "DistributedSemaphore": "class", + "DuplicateAppIdError": "class", + "DynamicAdapter": "class", + "DynamicAdapterNameError": "class", + "DynamicJobDirectExecutionError": "class", + "DynamicJobRegistrationDisabledError": "class", + "DynamicPlugin": "class", + "ELICITATION_FALLBACK_JSON_SCHEMA": "object", + "ESM_SH_BASE_URL": "string", + "EXT_APPS_ERROR_CODES": "object", + "ElicitationDisabledError": "class", + "ElicitationEncryptionError": "class", + "ElicitationFallbackRequired": "class", + "ElicitationNotOwnedError": "class", + "ElicitationNotSupportedError": "class", + "ElicitationSecretRequiredError": "class", + "ElicitationStoreNotInitializedError": "class", + "ElicitationSubscriptionError": "class", + "ElicitationTimeoutError": "class", + "EnclaveExecutionError": "class", + "EncryptedElicitationStore": "class", + "EncryptionContextNotSetError": "class", + "EncryptionKeyNotConfiguredError": "class", + "EntryUnavailableError": "class", + "EntryValidationError": "class", + "ErrorHandler": "class", + "EsmCacheError": "class", + "EsmCacheManager": "class", + "EsmInvalidSpecifierError": "class", + "EsmManifestInvalidError": "class", + "EsmModuleLoader": "class", + "EsmPackageLoadError": "class", + "EsmRegistryAuthError": "class", + "EsmVersionResolutionError": "class", + "ExecuteJobTool": "class", + "ExecuteWorkflowTool": "class", + "ExecutionContextBase": "class", + "ExecutionTimeoutError": "class", + "ExtAppsError": "class", + "ExtAppsInvalidParamsError": "class", + "ExtAppsMessageHandler": "class", + "ExtAppsMethodNotFoundError": "class", + "ExtAppsNotSupportedError": "class", + "ExtAppsToolNotFoundError": "class", + "ExternalEntryError": "class", + "ExternalEntryLoadError": "class", + "ExternalEntryNotFoundError": "class", + "ExternalEntryNotSupportedError": "class", + "ExternalSkillProviderBase": "class", + "FRONTMCP_CONFIG_METADATA_KEY": "string", + "FRONTMCP_CONTEXT": "symbol", + "FRONTMCP_SUPPORTED_PROTOCOL_VERSIONS": "array", + "FinallyHookOf": "function", + "Flow": "function", + "FlowBase": "class", + "FlowControl": "class", + "FlowEntry": "class", + "FlowExitedWithoutOutputError": "class", + "FlowHooksOf": "function", + "FlowInputMissingError": "class", + "FlowKind": "object", + "FlowNotRegisteredError": "class", + "FrontMcp": "function", + "FrontMcpAdapter": "function", + "FrontMcpAdapterTokens": "object", + "FrontMcpAgent": "function", + "FrontMcpAgentTokens": "object", + "FrontMcpApp": "function", + "FrontMcpAuth": "class", + "FrontMcpAuthProvider": "function", + "FrontMcpAuthProviderTokens": "object", + "FrontMcpChannelTokens": "object", + "FrontMcpConfig": "symbol", + "FrontMcpContext": "class", + "FrontMcpContextProvider": "object", + "FrontMcpContextStorage": "class", + "FrontMcpFlow": "function", + "FrontMcpFlowHookTokens": "object", + "FrontMcpFlowTokens": "object", + "FrontMcpInstance": "class", + "FrontMcpJob": "function", + "FrontMcpJobTokens": "object", + "FrontMcpLocalAppTokens": "object", + "FrontMcpLogTransport": "function", + "FrontMcpLogTransportTokens": "object", + "FrontMcpLogger": "class", + "FrontMcpPlugin": "function", + "FrontMcpPluginTokens": "object", + "FrontMcpPrompt": "function", + "FrontMcpPromptTokens": "object", + "FrontMcpProvider": "function", + "FrontMcpProviderTokens": "object", + "FrontMcpRemoteAppTokens": "object", + "FrontMcpResource": "function", + "FrontMcpResourceTemplate": "function", + "FrontMcpResourceTemplateTokens": "object", + "FrontMcpResourceTokens": "object", + "FrontMcpServer": "class", + "FrontMcpSkill": "function", + "FrontMcpSkillTokens": "object", + "FrontMcpTokens": "object", + "FrontMcpTool": "function", + "FrontMcpToolTokens": "object", + "FrontMcpWorkflow": "function", + "FrontMcpWorkflowTokens": "object", + "GLOBAL_RATE_LIMIT_CHECKED": "symbol", + "GenericServerError": "class", + "GetJobStatusTool": "class", + "GetWorkflowStatusTool": "class", + "GlobalConfigNotFoundError": "class", + "GuardError": "class", + "GuardLimitMcpError": "class", + "GuardManager": "class", + "GuardStorageUnavailableError": "class", + "GuardStorageUnsupportedError": "class", + "HaConfigurationError": "class", + "HealthCheckManager": "class", + "HealthChecker": "class", + "HookEntry": "class", + "HookKind": "object", + "HookTargetNotDefinedError": "class", + "HostServerAdapter": "class", + "HttpBinarySchema": "object", + "HttpConsumedSchema": "object", + "HttpCookieSchema": "object", + "HttpCookies": "object", + "HttpEmptySchema": "object", + "HttpEmptyStatus": "object", + "HttpHeaders": "object", + "HttpHook": "object", + "HttpHtmlSchema": "object", + "HttpImageSchema": "object", + "HttpJsonRpcSchema": "object", + "HttpJsonSchema": "object", + "HttpNextSchema": "object", + "HttpProblemSchema": "object", + "HttpRedirectSchema": "object", + "HttpRedirectStatus": "object", + "HttpSseSchema": "object", + "HttpStatus": "object", + "HttpStreamSchema": "object", + "HttpTextSchema": "object", + "HttpWebResponseSchema": "object", + "IP_FILTER_REJECTION_BODY": "object", + "ImageOutputSchema": "object", + "InMemoryOrchestratedTokenStore": "class", + "InMemoryStoreRequiredError": "class", + "InProcessTaskRunner": "class", + "InputRequiredSignal": "class", + "InternalMcpError": "class", + "InvalidDecoratorMetadataError": "class", + "InvalidDependencyScopeError": "class", + "InvalidEntityError": "class", + "InvalidHookFlowError": "class", + "InvalidInputError": "class", + "InvalidInstructionSourceError": "class", + "InvalidMethodError": "class", + "InvalidOutputError": "class", + "InvalidPluginScopeError": "class", + "InvalidRegistryKindError": "class", + "InvalidResourceUriError": "class", + "InvalidRetryOptionsError": "class", + "InvalidSkillError": "class", + "InvalidTransportSessionError": "class", + "InvalidUseClassError": "class", + "InvalidUseFactoryError": "class", + "InvalidUseValueError": "class", + "InvokeStateMissingKeyError": "class", + "IpBlockedError": "class", + "IpFilter": "class", + "IpNotAllowedError": "class", + "JSON_RPC": "string", + "Job": "function", + "JobContext": "class", + "JobEmitter": "class", + "JobEntry": "class", + "JobExecutionManager": "class", + "JobHook": "object", + "JobInstance": "class", + "JobKind": "object", + "JobNotAuthorizedError": "class", + "JobPermissionGuard": "class", + "JobRegistry": "class", + "JwtSecretRequiredError": "class", + "JwtSecretWeakError": "class", + "LLM_ADAPTER": "symbol", + "LazyZodSchema": "class", + "ListJobsTool": "class", + "ListPromptsHook": "object", + "ListResourceTemplatesHook": "object", + "ListResourcesHook": "object", + "ListToolsHook": "object", + "ListWorkflowsTool": "class", + "LogLevel": "object", + "LogLevelName": "object", + "LogTransport": "function", + "LogTransportInterface": "class", + "LoggerEntry": "class", + "LoggerKind": "object", + "LoggerRegistry": "class", + "MANIFEST_PRIMITIVE_KEYS": "array", + "MAX_CONCURRENT_TASKS_PER_SESSION": "number", + "MCP_ERROR_CODES": "object", + "McpClientService": "class", + "McpError": "class", + "McpStatelessClient": "class", + "McpStatelessClientAdapter": "class", + "McpStatelessError": "class", + "MemorySkillProvider": "class", + "MethodNotImplementedError": "class", + "MissingClientCapabilityError": "class", + "MissingPromptArgumentError": "class", + "MissingProvideError": "class", + "NEVER": "object", + "NO_BODY_STATUSES": "array", + "NO_STORE_HEADERS": "object", + "NameDisambiguationError": "class", + "NoProviderIdError": "class", + "OAuthAuthorizeHook": "object", + "OAuthCallbackHook": "object", + "OAuthRegisterHook": "object", + "OAuthTokenHook": "object", + "OUTPUT_SCHEMA_MODES": "array", + "OpenAIAdapter": "class", + "OrchestratedAuthNotAvailableError": "class", + "OrchestratorJwksNotAvailableError": "class", + "PLATFORM_CLIENT_INFO": "object", + "PRIMITIVE_FILTER_KEYS": "array", + "PROTOCOL_2026_07_28": "string", + "PROTOCOL_PRESETS": "object", + "PayloadTooLargeError": "class", + "Plugin": "function", + "PluginDependencyError": "class", + "PluginEntry": "class", + "PluginKind": "object", + "Prompt": "function", + "PromptContext": "class", + "PromptEntry": "class", + "PromptExecutionError": "class", + "PromptHook": "object", + "PromptKind": "object", + "PromptNotFoundError": "class", + "Provider": "function", + "ProviderConstructionError": "class", + "ProviderDependencyError": "class", + "ProviderEntry": "class", + "ProviderKind": "object", + "ProviderNotAvailableError": "class", + "ProviderNotInstantiatedError": "class", + "ProviderNotRegisteredError": "class", + "ProviderRegistry": "class", + "ProviderScope": "object", + "ProviderScopeMismatchError": "class", + "ProviderScopedAccessError": "class", + "PublicAccessDeniedError": "class", + "PublicMcpError": "class", + "QueueTimeoutError": "class", + "QuotaExceededError": "class", + "REDIRECTS": "array", + "REGISTRY_SERVER_NAME_PATTERN": "object", + "RELATED_TASK_META_KEY": "string", + "RUN_TASK_ENV_VAR": "string", + "RUN_TASK_SUBCOMMAND": "string", + "RateLimitError": "class", + "RegisterJobTool": "class", + "RegisterWorkflowTool": "class", + "RegistryDefinitionNotFoundError": "class", + "RegistryDependencyNotRegisteredError": "class", + "RegistryGraphEntryNotFoundError": "class", + "RegistryNotInitializedError": "class", + "RemoteAuthError": "class", + "RemoteAuthorizationError": "class", + "RemoteCapabilityDiscoveryError": "class", + "RemoteCapabilityNotSupportedError": "class", + "RemoteConfigurationError": "class", + "RemoteConnectionError": "class", + "RemoteDisconnectError": "class", + "RemoteNotConnectedError": "class", + "RemotePromptGetError": "class", + "RemotePromptNotFoundError": "class", + "RemoteResourceNotFoundError": "class", + "RemoteResourceReadError": "class", + "RemoteTimeoutError": "class", + "RemoteToolExecutionError": "class", + "RemoteToolNotFoundError": "class", + "RemoteTransportError": "class", + "RemoteUnsupportedTransportError": "class", + "RemoveJobTool": "class", + "RemoveWorkflowTool": "class", + "RequestContextNotAvailableError": "class", + "RequiredConfigUndefinedError": "class", + "ReservedAppIdError": "class", + "Resource": "function", + "ResourceContext": "class", + "ResourceEntry": "class", + "ResourceHook": "object", + "ResourceKind": "object", + "ResourceLinkOutputSchema": "object", + "ResourceNotFoundError": "class", + "ResourceOutputSchema": "object", + "ResourceReadError": "class", + "ResourceTemplate": "function", + "ResourceTemplateKind": "object", + "SCHEMA_DESCRIPTION_FORMATS": "array", + "STATELESS_SESSION_ID": "string", + "SamplingNotAvailableError": "class", + "ScopeConfigurationError": "class", + "ScopeDeniedError": "class", + "ScopeEntry": "class", + "ScopeKind": "object", + "ServerNotFoundError": "class", + "ServerRequest": "class", + "ServerRequestTokens": "object", + "ServerResponse": "class", + "ServerlessHandlerNotInitializedError": "class", + "SessionClaimConflictError": "class", + "SessionHookStage": "object", + "SessionIdEmptyError": "class", + "SessionIdentityRequiredError": "class", + "SessionMissingError": "class", + "SessionOwnerUnreachableError": "class", + "SessionSecretRequiredError": "class", + "SessionVerificationFailedError": "class", + "Skill": "function", + "SkillContext": "class", + "SkillContextNotImplementedError": "class", + "SkillEmitter": "class", + "SkillEntry": "class", + "SkillHttpAuthValidator": "class", + "SkillInstance": "class", + "SkillInstructionFetchError": "class", + "SkillKind": "object", + "SkillRegistry": "class", + "SkillSessionError": "class", + "SkillToolValidator": "class", + "SkillValidationError": "class", + "SlidingWindowRateLimiter": "class", + "SocketInUseError": "class", + "SocketPathConflictError": "class", + "SocketPermissionError": "class", + "StageHookOf": "function", + "StorageElicitationStore": "class", + "StorageTaskStore": "class", + "TASKS_CLIENT_CAPABILITY": "object", + "TASKS_EXTENSION_ID": "string", + "TASK_DEFAULTS": "object", + "TASK_WORKER_MODE_FLAG": "string", + "TaskAlreadyTerminalError": "class", + "TaskAugmentationNotSupportedError": "class", + "TaskAugmentationRequiredError": "class", + "TaskConcurrencyLimitError": "class", + "TaskNotFoundError": "class", + "TaskNotifier": "class", + "TaskRegistry": "class", + "TaskStoreNotInitializedError": "class", + "TaskStoreNotSupportedError": "class", + "TasksCancelFlow": "class", + "TasksGetFlow": "class", + "TasksListFlow": "class", + "TasksResultFlow": "class", + "TokenLeakDetectedError": "class", + "TokenNotAvailableError": "class", + "TokenStoreRequiredError": "class", + "Tool": "function", + "ToolCallError": "class", + "ToolContext": "class", + "ToolCredentialsRequiredError": "class", + "ToolEntry": "class", + "ToolExecutionError": "class", + "ToolHook": "object", + "ToolHookStage": "object", + "ToolInstance": "class", + "ToolKind": "object", + "ToolNameConflictError": "class", + "ToolNotConsentedError": "class", + "ToolNotFoundError": "class", + "ToolRegistry": "class", + "TransportAlreadyStartedError": "class", + "TransportBusRequiredError": "class", + "TransportNotConnectedError": "class", + "TransportServiceNotAvailableError": "class", + "UNDECLARED_KEYS_POLICIES": "array", + "UnauthorizedError": "class", + "UnenforcedMetadataError": "class", + "UnsafeSocketDirectoryError": "class", + "UnsupportedClientVersionError": "class", + "UnsupportedContentTypeError": "class", + "UnsupportedHookOwnerKindError": "class", + "UnsupportedTransportTypeError": "class", + "VaultLoadError": "class", + "VaultNotFoundError": "class", + "VercelKvAsyncInitRequiredError": "class", + "VercelKvNotSupportedError": "class", + "VersionPoller": "class", + "VersionResolver": "class", + "WillHookOf": "function", + "Workflow": "function", + "WorkflowContext": "class", + "WorkflowDagValidationError": "class", + "WorkflowEmitter": "class", + "WorkflowEngine": "class", + "WorkflowEntry": "class", + "WorkflowInstance": "class", + "WorkflowJobTimeoutError": "class", + "WorkflowKind": "object", + "WorkflowRegistry": "class", + "WorkflowStepExecutor": "class", + "WorkflowStepNotFoundError": "class", + "WorkflowTimeoutError": "class", + "ZodAny": "function", + "ZodArray": "function", + "ZodBigInt": "function", + "ZodBoolean": "function", + "ZodDate": "function", + "ZodDefault": "function", + "ZodDiscriminatedUnion": "function", + "ZodEnum": "function", + "ZodError": "function", + "ZodIntersection": "function", + "ZodLiteral": "function", + "ZodNever": "function", + "ZodNull": "function", + "ZodNullable": "function", + "ZodNumber": "function", + "ZodObject": "function", + "ZodOptional": "function", + "ZodRecord": "function", + "ZodString": "function", + "ZodSymbol": "function", + "ZodTuple": "function", + "ZodType": "function", + "ZodUndefined": "function", + "ZodUnion": "function", + "ZodUnknown": "function", + "ZodVoid": "function", + "acquireConcurrencySlots": "function", + "adapterInitOptionsOf": "function", + "agent": "function", + "aiPlatformTypeSchema": "object", + "allowsPublicAccess": "function", + "annotatedFrontMcpAdaptersSchema": "object", + "annotatedFrontMcpAgentsSchema": "object", + "annotatedFrontMcpAppSchema": "object", + "annotatedFrontMcpAuthProvidersSchema": "object", + "annotatedFrontMcpChannelsSchema": "object", + "annotatedFrontMcpJobsSchema": "object", + "annotatedFrontMcpLoggerSchema": "object", + "annotatedFrontMcpPluginsSchema": "object", + "annotatedFrontMcpPromptsSchema": "object", + "annotatedFrontMcpProvidersSchema": "object", + "annotatedFrontMcpResourcesSchema": "object", + "annotatedFrontMcpSkillsSchema": "object", + "annotatedFrontMcpToolsSchema": "object", + "annotatedFrontMcpWorkflowsSchema": "object", + "app": "function", + "appAuthOptionsSchema": "object", + "appFilterConfigSchema": "object", + "applyPrimitiveFilter": "function", + "assertStaticStartupConfig": "function", + "authInfoFromAuthorization": "function", + "authOptionsSchema": "object", + "authenticateFnSchema": "object", + "authorizationRequiredDataSchema": "object", + "authorizationRequiredMetaSchema": "object", + "authorizationRequiredParamsSchema": "object", + "authorizationSchema": "object", + "authorizeSessionTermination": "function", + "authorizeSkillHttpRequest": "function", + "buildChannelInstructions": "function", + "buildEsmPromptRecord": "function", + "buildEsmResourceRecord": "function", + "buildEsmShUrl": "function", + "buildEsmToolRecord": "function", + "buildParamHeaders": "function", + "buildPartitionContext": "function", + "buildPersistentWebStandardMcp": "function", + "buildRemotePromptRecord": "function", + "buildRemoteResourceRecord": "function", + "buildRemoteResourceTemplateRecord": "function", + "buildRemoteToolRecord": "function", + "buildSkillsCatalogSummary": "function", + "buildStorageKey": "function", + "canDeliverNotifications": "function", + "captureCallerDir": "function", + "challengeHeaders": "function", + "channel": "function", + "channelNotificationSchema": "object", + "channelSourceConfigSchema": "object", + "channelsConfigSchema": "object", + "clearCreateCache": "function", + "compareVersions": "function", + "composeCallerInstructions": "function", + "composeInitializeInstructions": "function", + "computeIssuer": "function", + "computeResource": "function", + "computeTaskCapabilities": "function", + "concurrencyConfigSchema": "object", + "connect": "function", + "connectClaude": "function", + "connectLangChain": "function", + "connectOpenAI": "function", + "connectVercelAI": "function", + "consentConfigSchema": "object", + "consumePendingTC39Hooks": "function", + "create": "function", + "createChildSpanContext": "function", + "createContextResolver": "function", + "createDeferredServerBuild": "function", + "createDirectResolver": "function", + "createElicitationStore": "function", + "createElicitationStoreFromStorage": "function", + "createErrorHandler": "function", + "createEsmPromptContextClass": "function", + "createEsmPromptInstance": "function", + "createEsmResourceContextClass": "function", + "createEsmResourceInstance": "function", + "createEsmToolContextClass": "function", + "createEsmToolInstance": "function", + "createExtAppsMessageHandler": "function", + "createGuardManager": "function", + "createInMemoryServer": "function", + "createMemoryElicitationStore": "function", + "createMemorySkillProvider": "function", + "createMemoryTaskStore": "function", + "createRemotePromptContextClass": "function", + "createRemotePromptInstance": "function", + "createRemoteResourceContextClass": "function", + "createRemoteResourceInstance": "function", + "createRemoteResourceTemplateInstance": "function", + "createRemoteToolContextClass": "function", + "createRemoteToolInstance": "function", + "createSkillHttpAuthValidator": "function", + "createSkillInstance": "function", + "createSkillStorageProvider": "function", + "createTaskStore": "function", + "createWebFetchHandler": "function", + "decideIntent": "function", + "decisionSchema": "object", + "decryptElicitationData": "function", + "deepMerge": "function", + "default": "object", + "defaultHttpPort": "function", + "deriveElicitationKey": "function", + "describeConfigIssues": "function", + "deserializeAndDecrypt": "function", + "deserializeElicitationBlob": "function", + "detectConflicts": "function", + "detectPlatform": "function", + "eagerZ": "object", + "elicitationOptionsSchema": "object", + "encryptAndSerialize": "function", + "encryptElicitationData": "function", + "enforceGlobalRateLimit": "function", + "enforceIpFilter": "function", + "esmRegistryAuthSchema": "object", + "eventStoreConfigSchema": "object", + "expandProtocolConfig": "function", + "extAppsHostCapabilitiesSchema": "object", + "extAppsOptionsSchema": "object", + "extendOutputSchemaForElicitation": "function", + "extendedAgentMetadata": "symbol", + "extendedJobMetadata": "symbol", + "extendedPromptMetadata": "symbol", + "extendedResourceMetadata": "symbol", + "extendedResourceTemplateMetadata": "symbol", + "extendedSkillMetadata": "symbol", + "extendedToolMetadata": "symbol", + "extendedWorkflowMetadata": "symbol", + "extractPublicMessage": "function", + "extractSchemaPaths": "function", + "extractToolNames": "function", + "federatedAuthConfigSchema": "object", + "filterServableSkills": "function", + "flatRemoteProviderFields": "object", + "forceMaterialize": "function", + "formatMcpErrorResponse": "function", + "formatOutputSchemaForDescription": "function", + "formatResultForPlatform": "function", + "formatSkillForLLM": "function", + "formatToolsForPlatform": "function", + "frontMcpAdapterMetadataSchema": "object", + "frontMcpAgent": "function", + "frontMcpAgentMetadataSchema": "object", + "frontMcpApp": "function", + "frontMcpAuthProviderMetadataSchema": "object", + "frontMcpBaseSchema": "object", + "frontMcpChannelMetadataSchema": "object", + "frontMcpFlowMetadataSchema": "object", + "frontMcpJob": "function", + "frontMcpJobMetadataSchema": "object", + "frontMcpLocalAppMetadataSchema": "object", + "frontMcpLogTransportMetadataSchema": "object", + "frontMcpMetadataSchema": "object", + "frontMcpPackageManifestSchema": "object", + "frontMcpPluginMetadataSchema": "object", + "frontMcpPrompt": "function", + "frontMcpPromptMetadataSchema": "object", + "frontMcpProviderMetadataSchema": "object", + "frontMcpRemoteAppMetadataSchema": "object", + "frontMcpResource": "function", + "frontMcpResourceMetadataSchema": "object", + "frontMcpResourceTemplate": "function", + "frontMcpResourceTemplateMetadataSchema": "object", + "frontMcpSkill": "function", + "frontMcpTool": "function", + "frontMcpToolMetadataSchema": "object", + "frontMcpWorkflow": "function", + "frontMcpWorkflowMetadataSchema": "object", + "generateElicitationId": "function", + "generateEnvFallbacks": "function", + "generateFallbacks": "function", + "generateTaskId": "function", + "generateTraceContext": "function", + "getCallSurface": "function", + "getConfig": "function", + "getDecoratorConfig": "function", + "getElicitationSecret": "function", + "getGlobalStoreConfig": "function", + "getNestedValue": "function", + "getPinnedPublicUrl": "function", + "getRegistryUrl": "function", + "getRequestBaseUrl": "function", + "getRunningTool": "function", + "getServerlessHandler": "function", + "getServerlessHandlerAsync": "function", + "getSkillMetadata": "function", + "getToolNameFromClass": "function", + "guardConfigSchema": "object", + "handleWaitingFallback": "function", + "hasSkillAuditFactory": "function", + "healthOptionsSchema": "object", + "healthReadyzOptionsSchema": "object", + "httpInputSchema": "object", + "httpOptionsSchema": "object", + "httpOutputSchema": "object", + "httpRequestInputSchema": "object", + "httpRespond": "object", + "incrementalAuthConfigSchema": "object", + "initOptionsOf": "function", + "intentSchema": "object", + "ipFilterConfigSchema": "object", + "isAlive": "function", + "isAnonymousSubject": "function", + "isAuthError": "function", + "isChannelClass": "function", + "isChannelFunction": "function", + "isClientFacingError": "function", + "isConnectionError": "function", + "isDistributedMode": "function", + "isDynamicPluginClass": "function", + "isElicitationEncryptionAvailable": "function", + "isEncryptedBlob": "function", + "isEntryGatedBy": "function", + "isFileInstructions": "function", + "isInlineInstructions": "function", + "isLazy": "function", + "isLocalMode": "function", + "isMrtrSignal": "function", + "isNewerVersion": "function", + "isOrchestratedLocal": "function", + "isOrchestratedMode": "function", + "isOrchestratedRemote": "function", + "isPackageSpecifier": "function", + "isPrimitiveIncluded": "function", + "isProxyTrusted": "function", + "isPublicError": "function", + "isPublicMode": "function", + "isPublicUrlPinned": "function", + "isPubsubConfigured": "function", + "isRedisProvider": "function", + "isRemoteMode": "function", + "isSkillDecorated": "function", + "isSkillRecord": "function", + "isStaticMode": "function", + "isTaskWorkerProcess": "function", + "isTerminal": "function", + "isToolRefWithClass": "function", + "isToolRefWithName": "function", + "isTransientError": "function", + "isTransparentMode": "function", + "isUrlInstructions": "function", + "isValidRange": "function", + "isValidVersion": "function", + "isVercelKvProvider": "function", + "job": "function", + "joinPath": "function", + "jsonWebKeySetSchema": "object", + "jwkParametersSchema": "object", + "jwkSchema": "object", + "lazyZ": "function", + "loadConfig": "function", + "loadEnvFiles": "function", + "loadSkillDirectory": "function", + "localAuthSchema": "object", + "localDcrClientSchema": "object", + "localDcrConfigSchema": "object", + "localSigningConfigSchema": "object", + "loggingOptionsSchema": "object", + "loginConfigSchema": "object", + "makeWellKnownPaths": "function", + "mapEnvToNestedConfig": "function", + "maxSatisfying": "function", + "mcpRegistryOptionsSchema": "object", + "mcpRegistryRepositorySchema": "object", + "mcpSessionHeaderSchema": "object", + "metricsOptionsSchema": "object", + "metricsProcessOptionsSchema": "object", + "negotiateRemoteProtocol": "function", + "normalizeEndpointConfig": "function", + "normalizeEntryPrefix": "function", + "normalizeEsmExport": "function", + "normalizeNameForEnv": "function", + "normalizePathSegment": "function", + "normalizeResourceUri": "function", + "normalizeScopeBase": "function", + "normalizeSkill": "function", + "normalizeSkillsConfigOptions": "function", + "normalizeToolAuthProviders": "function", + "normalizeToolRef": "function", + "observabilityOptionsSchema": "object", + "orchestratedLocalSchema": "object", + "orchestratedRemoteSchema": "object", + "outboundFetchOptionsSchema": "object", + "outputPolicySchema": "object", + "packageLoaderSchema": "object", + "paginationOptionsSchema": "object", + "parseAuthOptions": "function", + "parseCallerDir": "function", + "parseEnvContent": "function", + "parseEnvContentSync": "function", + "parseFrontMcpConfigLite": "function", + "parseListInput": "function", + "parsePackageSpecifier": "function", + "parseRedisUrl": "function", + "parseTraceContext": "function", + "partitionKeySchema": "object", + "partitionsByIdentity": "function", + "pathToEnvKey": "function", + "peekPendingTC39HooksForClass": "function", + "performElicit": "function", + "performListRoots": "function", + "performSample": "function", + "persistenceConfigSchema": "object", + "platformDetectionConfigSchema": "object", + "platformMappingEntrySchema": "object", + "populateProcessEnv": "function", + "previewItems": "function", + "prompt": "function", + "providerConfigSchema": "object", + "publicAccessConfigSchema": "object", + "publicAuthOptionsSchema": "object", + "pubsubOptionsSchema": "object", + "rateLimitConfigSchema": "object", + "redisOptionsSchema": "object", + "redisProviderSchema": "object", + "redisUrlSchema": "object", + "registerOptionalModule": "function", + "registerPendingTC39Hook": "function", + "remoteAuthSchema": "object", + "remoteProviderConfigSchema": "object", + "resolveDefaultSqlitePath": "function", + "resolveEntryPath": "function", + "resolveNameConflicts": "function", + "resolveOutputSchemaMode": "function", + "resolvePartitionKey": "function", + "resolvePendingTC39HooksForClass": "function", + "resolvePrincipal": "function", + "resolveRegistryToken": "function", + "resolveSchemaDescriptionFormat": "function", + "resolveToolVisibility": "function", + "resolveUndeclaredKeysPolicy": "function", + "resolveWithFallbacks": "function", + "resolvesToVercelKvTaskBackend": "function", + "resource": "function", + "resourceTemplate": "function", + "resourceUriMatches": "function", + "runHttpRequestFlowWeb": "function", + "runTaskInBackground": "function", + "runWebStandardMcp": "function", + "satisfiesRange": "function", + "scanSkillResources": "function", + "secureStoreConfigSchema": "object", + "secureStoreCustomBackendSchema": "object", + "secureStoreEncryptionSchema": "object", + "secureStoreScopeSchema": "object", + "sendFallbackNotification": "function", + "serializeElicitationBlob": "function", + "serializeHttpCookie": "function", + "serverInfoOptionsSchema": "object", + "sessionIdPayloadSchema": "object", + "sessionIdPresentedBy": "function", + "sessionIdSchema": "object", + "sessionOptionsSchema": "object", + "setNestedValue": "function", + "setServerlessHandler": "function", + "setServerlessHandlerError": "function", + "setServerlessHandlerPromise": "function", + "setSkillAuditFactory": "function", + "shouldCacheProviders": "function", + "shouldStopExecution": "function", + "skill": "function", + "skillCallerDir": "symbol", + "skillDir": "function", + "skillMetadataSchema": "object", + "skillsConfigAuthModeSchema": "object", + "skillsConfigEndpointConfigSchema": "object", + "skillsConfigOptionsSchema": "object", + "skippedAppBehaviorSchema": "object", + "sqliteOptionsSchema": "object", + "startupFailureResponse": "function", + "staticAuthOptionsSchema": "object", + "storageProviderSchema": "object", + "supportsStreaming": "function", + "timeoutConfigSchema": "object", + "toJSONSchema": "function", + "toMcpError": "function", + "toRegistryAuthRecord": "function", + "toWireShape": "function", + "tokenRefreshConfigSchema": "object", + "tokenStorageConfigSchema": "object", + "tokenStorageSqliteSchema": "object", + "tool": "function", + "toolPaginationOptionsSchema": "object", + "transparentAuthOptionsSchema": "object", + "transportOptionsSchema": "object", + "trimSlashes": "function", + "tryDecryptStoredValue": "function", + "tryGetConfig": "function", + "upstreamProviderSchema": "object", + "urlToSafeId": "function", + "userClaimSchema": "object", + "validateElicitationContent": "function", + "validateHeaderParams": "function", + "validateMcpSessionHeader": "function", + "validateRemoteUrl": "function", + "vercelKvProviderSchema": "object", + "withConfig": "function", + "withHttpHeaders": "function", + "withRetry": "function", + "withTimeout": "function", + "withUndeclaredKeysAllowed": "function", + "workflow": "function", + "z": "object" +} diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/helpers/root-exports.mjs b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/root-exports.mjs new file mode 100644 index 000000000..9e5f4df54 --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/root-exports.mjs @@ -0,0 +1,22 @@ +// Loads an SDK entry with require() or import() and prints, as JSON, the kind of each of its exports: node root-exports.mjs +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +const [entry, loader] = process.argv.slice(2); +const sdk = loader === 'require' ? createRequire(import.meta.url)(entry) : await import(pathToFileURL(entry).href); + +function kindOf(value) { + if (value === null) return 'null'; + if (Array.isArray(value)) return 'array'; + if (typeof value === 'function') { + return /^class\b/.test(Function.prototype.toString.call(value)) ? 'class' : 'function'; + } + return typeof value; +} + +const kinds = Object.fromEntries( + Object.keys(sdk) + .sort() + .map((name) => [name, kindOf(sdk[name])]), +); +process.stdout.write(JSON.stringify(kinds)); diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/sdk-root-exports.e2e.spec.ts b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-root-exports.e2e.spec.ts new file mode 100644 index 000000000..ee3d98cf4 --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-root-exports.e2e.spec.ts @@ -0,0 +1,35 @@ +/** + * The SDK's CommonJS and ESM entry points export the same names, each of the same kind, as the list pinned in + * `fixtures/sdk-root-exports.json` (#950): loading a subsystem on first use changes none of them. + * + * Needs the packages built: `nx run-many -t build -p sdk plugin-webmcp`. + */ +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +import { readJSON } from '@frontmcp/utils'; + +import { workspaceRoot } from './helpers/browser-bundle'; + +const pinnedExports = () => readJSON>(resolve(__dirname, 'fixtures', 'sdk-root-exports.json')); + +function exportKinds(entry: string, loader: 'require' | 'import'): Record { + const runner = resolve(__dirname, 'helpers', 'root-exports.mjs'); + return JSON.parse( + execFileSync(process.execPath, [runner, resolve(workspaceRoot, entry), loader], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 16 * 1024 * 1024, + }), + ); +} + +describe('the root exports of @frontmcp/sdk', () => { + it('are the pinned names and kinds under require()', async () => { + expect(exportKinds('libs/sdk/dist/index.js', 'require')).toEqual(await pinnedExports()); + }); + + it('are the pinned names and kinds under import', async () => { + expect(exportKinds('libs/sdk/dist/esm/index.mjs', 'import')).toEqual(await pinnedExports()); + }); +}); From 196cba0f2117bd358136c6325bae62dd8ad9b92a Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 18:59:22 +0200 Subject: [PATCH 12/25] test(bundle-size): set up the SDK's unused subsystems and OAuth flows on first use, with their hooks still running --- .../e2e/helpers/initialized-modules.cjs | 65 +++++++++ .../e2e/helpers/subsystem-hooks.mjs | 125 ++++++++++++++++++ .../e2e/sdk-lazy-subsystems.e2e.spec.ts | 62 +++++++++ .../e2e/sdk-subsystem-hooks.e2e.spec.ts | 42 ++++++ 4 files changed, 294 insertions(+) create mode 100644 apps/e2e/demo-e2e-bundle-size/e2e/helpers/initialized-modules.cjs create mode 100644 apps/e2e/demo-e2e-bundle-size/e2e/helpers/subsystem-hooks.mjs create mode 100644 apps/e2e/demo-e2e-bundle-size/e2e/sdk-lazy-subsystems.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-bundle-size/e2e/sdk-subsystem-hooks.e2e.spec.ts diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/helpers/initialized-modules.cjs b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/initialized-modules.cjs new file mode 100644 index 000000000..514125859 --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/initialized-modules.cjs @@ -0,0 +1,65 @@ +// Loads the SDK's CommonJS build with esbuild's module-init helper wrapped, so the source modules it initializes are +// recorded, then prints, as JSON, the ones initialized by loading it and by each step after: +// node initialized-modules.cjs [read:,...] [fetch: ]... +const Module = require('node:module'); +const { readFileSync, realpathSync } = require('node:fs'); +const path = require('node:path'); + +const ESM_INIT_HELPER = + 'var __esm=(fn,res,err)=>function __init(){if(err)throw err[0];try{return fn&&(res=(0,fn[__getOwnPropNames(fn)[0]])(fn=0)),res}catch(e){throw err=[e],e}};'; +const RECORD_INITS = + 'var __esmUnrecorded=__esm;__esm=(fn,res,err)=>{const source=__getOwnPropNames(fn)[0];const init=__esmUnrecorded(fn,res,err);return function(){globalThis.__sdkInitialized.add(source);return init()}};'; + +const [entryArg, ...steps] = process.argv.slice(2); +const entry = realpathSync(path.resolve(entryArg)); +const code = readFileSync(entry, 'utf8'); +if (!code.includes(ESM_INIT_HELPER)) throw new Error(`esbuild's __esm helper not found in ${entry}`); + +const initialized = (globalThis.__sdkInitialized = new Set()); +const sdkModule = new Module(entry, module); +sdkModule.filename = entry; +sdkModule.paths = Module._nodeModulePaths(path.dirname(entry)); +require.cache[entry] = sdkModule; +sdkModule._compile(code.replace(ESM_INIT_HELPER, ESM_INIT_HELPER + RECORD_INITS), entry); +sdkModule.loaded = true; +const sdk = sdkModule.exports; + +let seen = new Set(); +function initializedSince() { + const added = [...initialized].filter((source) => !seen.has(source)); + seen = new Set(initialized); + return added; +} + +async function main() { + const report = [{ step: 'load', initialized: initializedSince() }]; + let handler; + for (const step of steps) { + const [kind, arg] = [step.slice(0, step.indexOf(':')), step.slice(step.indexOf(':') + 1)]; + if (kind === 'read') { + for (const name of arg.split(',')) void sdk[name]; + } else if (kind === 'fetch') { + handler ??= await sdk.FrontMcpInstance.createFetchHandler({ + info: { name: 'initialized-modules', version: '1.0.0' }, + apps: [sdk.app({ id: 'empty', name: 'Empty' })], + logging: { level: sdk.LogLevel.Off }, + }); + if (arg) { + const [method, url] = arg.split(' '); + await (await handler(new Request(`http://localhost${url}`, { method }))).arrayBuffer(); + } + } else { + throw new Error(`unknown step ${step}`); + } + report.push({ step, initialized: initializedSince() }); + } + process.stdout.write(JSON.stringify(report)); +} + +main().then( + () => process.exit(0), + (error) => { + process.stderr.write(String(error?.stack ?? error)); + process.exit(1); + }, +); diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/helpers/subsystem-hooks.mjs b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/subsystem-hooks.mjs new file mode 100644 index 000000000..5a7a2b322 --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/subsystem-hooks.mjs @@ -0,0 +1,125 @@ +// Requires the SDK's CommonJS build, runs a job, a workflow, a channel notification, the channel list and a request +// to each OAuth route of a public-mode server, and prints, as JSON, the flows a server plugin's hooks saw: +// node subsystem-hooks.mjs +import { createRequire } from 'node:module'; + +const [entry] = process.argv.slice(2); +const sdk = createRequire(import.meta.url)(entry); +const { + App, + Channel, + ChannelContext, + FlowHooksOf, + FrontMcpInstance, + Job, + JobContext, + LogLevel, + Plugin, + Tool, + ToolContext, +} = sdk; +const { Workflow, connect, z } = sdk; + +const OAUTH_ROUTES = { + 'oauth:authorize': ['GET', '/oauth/authorize'], + 'oauth:token': ['POST', '/oauth/token'], + 'oauth:userinfo': ['GET', '/oauth/userinfo'], + 'oauth:callback': ['GET', '/oauth/callback'], + 'oauth:connect': ['GET', '/oauth/connect'], + 'oauth:register': ['POST', '/oauth/register'], + 'oauth:provider-callback': ['GET', '/oauth/provider/upstream/callback'], + 'oauth:auth-ui-extra': ['POST', '/oauth/ui/extra'], +}; + +const seen = []; + +function decorate(target, name, decorator) { + decorator(target, name, Object.getOwnPropertyDescriptor(target, name)); +} + +class SubsystemHooksPlugin { + recordJob(flowCtx) { + const { job, workflow } = flowCtx.state; + seen.push(workflow ? `jobs:execute-job ${job.name} in ${workflow.name}` : `jobs:execute-job ${job.name}`); + } + recordChannelSend() { + seen.push('channels:send-notification'); + } + recordChannelList() { + seen.push('channels:list'); + } +} +decorate(SubsystemHooksPlugin.prototype, 'recordJob', FlowHooksOf('jobs:execute-job').Did('updateRunState')); +decorate(SubsystemHooksPlugin.prototype, 'recordChannelSend', FlowHooksOf('channels:send-notification').Will('send')); +decorate(SubsystemHooksPlugin.prototype, 'recordChannelList', FlowHooksOf('channels:list').Will('listChannels')); +for (const flow of Object.keys(OAUTH_ROUTES)) { + const method = `record:${flow}`; + SubsystemHooksPlugin.prototype[method] = () => seen.push(flow); + decorate(SubsystemHooksPlugin.prototype, method, FlowHooksOf(flow).Will('checkIpFilter')); +} +Plugin({ name: 'subsystem-hooks', description: 'Records the flows of each subsystem that ran' })(SubsystemHooksPlugin); + +class GreetJob extends JobContext { + async execute({ name }) { + return { message: `Hello, ${name}` }; + } +} +Job({ name: 'greet', inputSchema: { name: z.string() }, outputSchema: { message: z.string() } })(GreetJob); + +class GreetWorkflow {} +Workflow({ name: 'greet-twice', steps: [{ id: 'greet', jobName: 'greet', input: { name: 'Workflow' } }] })( + GreetWorkflow, +); + +class AlertChannel extends ChannelContext { + async onEvent(payload) { + return { content: `alert: ${payload.message}` }; + } +} +Channel({ name: 'alerts', source: { type: 'app-event', event: 'alert' } })(AlertChannel); + +class AlertTool extends ToolContext { + async execute() { + this.scope.channelEventBus.emit('alert', { message: 'raised' }); + await this.scope.runFlow('channels:list', {}); + return 'raised'; + } +} +Tool({ name: 'raise-alert', inputSchema: {} })(AlertTool); + +class SubsystemsApp {} +App({ + name: 'Subsystems', + jobs: [GreetJob], + workflows: [GreetWorkflow], + channels: [AlertChannel], + tools: [AlertTool], +})(SubsystemsApp); + +const config = { + info: { name: 'subsystem-hooks', version: '1.0.0' }, + apps: [SubsystemsApp], + plugins: [SubsystemHooksPlugin], + jobs: { enabled: true }, + channels: { enabled: true }, + logging: { level: LogLevel.Off }, +}; + +const client = await connect(config); +try { + await client.callTool('execute_job', { name: 'greet', input: { name: 'Ada' } }); + await client.callTool('execute_workflow', { name: 'greet-twice' }); + await client.callTool('raise-alert', {}); + await new Promise((resolveDelivery) => setTimeout(resolveDelivery, 100)); +} finally { + await client.close(); +} + +const handler = await FrontMcpInstance.createFetchHandler(config); +for (const [method, path] of Object.values(OAUTH_ROUTES)) { + const response = await handler(new Request(`http://localhost${path}`, { method })); + await response.arrayBuffer(); +} + +process.stdout.write(JSON.stringify(seen)); +process.exit(0); diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/sdk-lazy-subsystems.e2e.spec.ts b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-lazy-subsystems.e2e.spec.ts new file mode 100644 index 000000000..56c1f27f5 --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-lazy-subsystems.e2e.spec.ts @@ -0,0 +1,62 @@ +/** + * The SDK's CommonJS build sets up the subsystems a server may never use the first time one of their exports is read, + * and a public-mode server loads each of its OAuth flows with the first request that reaches it (#950). + * + * Needs the packages built: `nx run-many -t build -p sdk plugin-webmcp`. + */ +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +import { workspaceRoot } from './helpers/browser-bundle'; + +type Step = { step: string; initialized: string[] }; + +const SUBSYSTEM_MODULE = /^libs\/sdk\/src\/(job|workflow|channel|remote-mcp|esm-loader)\//; +const OAUTH_FLOW_MODULE = /^libs\/sdk\/src\/auth\/flows\/oauth\.[a-z-]+\.flow\.ts$/; + +function initializedModules(...steps: string[]): Step[] { + const runner = resolve(__dirname, 'helpers', 'initialized-modules.cjs'); + return JSON.parse( + execFileSync(process.execPath, [runner, resolve(workspaceRoot, 'libs/sdk/dist/index.js'), ...steps], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + maxBuffer: 16 * 1024 * 1024, + }), + ); +} + +describe('the subsystems of the SDK under require()', () => { + it('are not set up when the SDK loads', () => { + const [load] = initializedModules(); + + expect(load.initialized.length).toBeGreaterThan(0); + expect(load.initialized.filter((source) => SUBSYSTEM_MODULE.test(source))).toEqual([]); + }); + + it.each([ + ['JobRegistry', 'libs/sdk/src/job/job.registry.ts'], + ['WorkflowRegistry', 'libs/sdk/src/workflow/workflow.registry.ts'], + ['ChannelRegistry', 'libs/sdk/src/channel/channel.registry.ts'], + ['McpClientService', 'libs/sdk/src/remote-mcp/mcp-client.service.ts'], + ['EsmModuleLoader', 'libs/sdk/src/esm-loader/esm-module-loader.ts'], + ])('are set up when %s is read', (exportName, source) => { + const [, read] = initializedModules(`read:${exportName}`); + + expect(read.initialized).toContain(source); + }); +}); + +describe('the OAuth flows of a public-mode server', () => { + it('load with the first request that reaches each one', () => { + const [, start, authorize, token] = initializedModules( + 'fetch:', + 'fetch:GET /oauth/authorize', + 'fetch:POST /oauth/token', + ); + const oauthFlows = (step: Step) => step.initialized.filter((source) => OAUTH_FLOW_MODULE.test(source)); + + expect(oauthFlows(start)).toEqual([]); + expect(oauthFlows(authorize)).toEqual(['libs/sdk/src/auth/flows/oauth.authorize.flow.ts']); + expect(oauthFlows(token)).toEqual(['libs/sdk/src/auth/flows/oauth.token.flow.ts']); + }); +}); diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/sdk-subsystem-hooks.e2e.spec.ts b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-subsystem-hooks.e2e.spec.ts new file mode 100644 index 000000000..d45a4b30d --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-subsystem-hooks.e2e.spec.ts @@ -0,0 +1,42 @@ +/** + * With the SDK's CommonJS build, a plugin's hooks on the flows of the subsystems it sets up on first use still run: + * jobs and workflow steps, channel notifications and the channel list, and every OAuth flow of a public-mode server + * (#950). + * + * Needs the packages built: `nx run-many -t build -p sdk plugin-webmcp`. + */ +import { execFileSync } from 'node:child_process'; +import { resolve } from 'node:path'; + +import { workspaceRoot } from './helpers/browser-bundle'; + +function flowsTheHooksSaw(): string[] { + const runner = resolve(__dirname, 'helpers', 'subsystem-hooks.mjs'); + return JSON.parse( + execFileSync(process.execPath, [runner, resolve(workspaceRoot, 'libs/sdk/dist/index.js')], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }), + ); +} + +describe('hooks on the flows of the subsystems the SDK sets up on first use', () => { + it('run under require()', () => { + expect(flowsTheHooksSaw().sort()).toEqual( + [ + 'jobs:execute-job greet', + 'jobs:execute-job greet in greet-twice', + 'channels:send-notification', + 'channels:list', + 'oauth:authorize', + 'oauth:token', + 'oauth:userinfo', + 'oauth:callback', + 'oauth:connect', + 'oauth:register', + 'oauth:provider-callback', + 'oauth:auth-ui-extra', + ].sort(), + ); + }); +}); From 79b15bb11736b1d3395de0d4dec6968998240748 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 19:12:30 +0200 Subject: [PATCH 13/25] perf(sdk): set up jobs, workflows, channels, remote MCP and the ESM loader when a CommonJS process first reads their exports --- .../e2e/sdk-lazy-subsystems.e2e.spec.ts | 11 +- libs/sdk/esbuild.cjs.config.js | 55 ++++++++++ libs/sdk/project.json | 34 +----- .../sdk/src/__tests__/lazy-subsystems.spec.ts | 35 ++++++ libs/sdk/src/index.ts | 30 +---- libs/sdk/src/lazy-subsystems.ts | 103 ++++++++++++++++++ libs/sdk/src/subsystems.ts | 34 ++++++ 7 files changed, 240 insertions(+), 62 deletions(-) create mode 100644 libs/sdk/esbuild.cjs.config.js create mode 100644 libs/sdk/src/__tests__/lazy-subsystems.spec.ts create mode 100644 libs/sdk/src/lazy-subsystems.ts create mode 100644 libs/sdk/src/subsystems.ts diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/sdk-lazy-subsystems.e2e.spec.ts b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-lazy-subsystems.e2e.spec.ts index 56c1f27f5..0d9a2e377 100644 --- a/apps/e2e/demo-e2e-bundle-size/e2e/sdk-lazy-subsystems.e2e.spec.ts +++ b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-lazy-subsystems.e2e.spec.ts @@ -12,6 +12,14 @@ import { workspaceRoot } from './helpers/browser-bundle'; type Step = { step: string; initialized: string[] }; const SUBSYSTEM_MODULE = /^libs\/sdk\/src\/(job|workflow|channel|remote-mcp|esm-loader)\//; +// Helpers the startup path itself uses: the decorators parse `Tool.esm()` specifiers, the scope normalizes app entries +const STARTUP_HELPERS = [ + 'libs/sdk/src/esm-loader/package-specifier.ts', + 'libs/sdk/src/esm-loader/module-specifiers.ts', + 'libs/sdk/src/job/job.utils.ts', + 'libs/sdk/src/channel/channel.utils.ts', + 'libs/sdk/src/channel/sources/completion-events.ts', +]; const OAUTH_FLOW_MODULE = /^libs\/sdk\/src\/auth\/flows\/oauth\.[a-z-]+\.flow\.ts$/; function initializedModules(...steps: string[]): Step[] { @@ -30,7 +38,8 @@ describe('the subsystems of the SDK under require()', () => { const [load] = initializedModules(); expect(load.initialized.length).toBeGreaterThan(0); - expect(load.initialized.filter((source) => SUBSYSTEM_MODULE.test(source))).toEqual([]); + const subsystemModules = load.initialized.filter((source) => SUBSYSTEM_MODULE.test(source)); + expect(subsystemModules.filter((source) => !STARTUP_HELPERS.includes(source))).toEqual([]); }); it.each([ diff --git a/libs/sdk/esbuild.cjs.config.js b/libs/sdk/esbuild.cjs.config.js new file mode 100644 index 000000000..eacd34ce7 --- /dev/null +++ b/libs/sdk/esbuild.cjs.config.js @@ -0,0 +1,55 @@ +// The esbuild options of the CommonJS build (build-cjs). Its root takes src/lazy-subsystems.ts for ./subsystems, so a +// CommonJS process sets up jobs, workflows, channels, remote MCP and the ESM loader when it first reads their exports. +const path = require('node:path'); + +const ROOT_ENTRY = path.join('libs', 'sdk', 'src', 'index.ts'); + +const lazySubsystems = { + name: 'lazy-subsystems', + setup(build) { + let swapped = false; + build.onResolve({ filter: /^\.\/subsystems$/ }, (args) => { + if (!args.importer.endsWith(ROOT_ENTRY)) return undefined; + swapped = true; + return { path: path.join(args.resolveDir, 'lazy-subsystems.ts') }; + }); + build.onEnd(() => { + if (!swapped) throw new Error(`${ROOT_ENTRY} no longer imports ./subsystems for lazy-subsystems to swap`); + }); + }, +}; + +module.exports = { + minifyWhitespace: true, + outExtension: { '.js': '.js' }, + external: [ + '#js-yaml', + '#semver', + '#uipack-render', + '#protocol-client', + '@frontmcp/sdk', + '@frontmcp/utils', + '@frontmcp/di', + '@frontmcp/lazy-zod', + '@frontmcp/uipack', + '@frontmcp/uipack/*', + '@frontmcp/auth', + '@frontmcp/guard', + '@frontmcp/protocol', + '@frontmcp/protocol/*', + '@frontmcp/observability', + '@opentelemetry/api', + '@opentelemetry/sdk-trace-base', + 'ioredis', + 'jose', + 'reflect-metadata', + 'zod', + 'express', + 'cors', + 'raw-body', + 'content-type', + '@vercel/kv', + '#import-computed', + ], + plugins: [lazySubsystems], +}; diff --git a/libs/sdk/project.json b/libs/sdk/project.json index 1cc00470f..9ef6533b9 100644 --- a/libs/sdk/project.json +++ b/libs/sdk/project.json @@ -31,39 +31,7 @@ "libs/sdk/src/third-party/import-protocol-client.ts" ], "assets": ["libs/sdk/README.md", "LICENSE", "libs/sdk/package.json"], - "esbuildOptions": { - "minifyWhitespace": true, - "outExtension": { ".js": ".js" }, - "external": [ - "#js-yaml", - "#semver", - "#uipack-render", - "#protocol-client", - "@frontmcp/sdk", - "@frontmcp/utils", - "@frontmcp/di", - "@frontmcp/lazy-zod", - "@frontmcp/uipack", - "@frontmcp/uipack/*", - "@frontmcp/auth", - "@frontmcp/guard", - "@frontmcp/protocol", - "@frontmcp/protocol/*", - "@frontmcp/observability", - "@opentelemetry/api", - "@opentelemetry/sdk-trace-base", - "ioredis", - "jose", - "reflect-metadata", - "zod", - "express", - "cors", - "raw-body", - "content-type", - "@vercel/kv", - "#import-computed" - ] - } + "esbuildConfig": "libs/sdk/esbuild.cjs.config.js" } }, "build-esm": { diff --git a/libs/sdk/src/__tests__/lazy-subsystems.spec.ts b/libs/sdk/src/__tests__/lazy-subsystems.spec.ts new file mode 100644 index 000000000..4c56ba15c --- /dev/null +++ b/libs/sdk/src/__tests__/lazy-subsystems.spec.ts @@ -0,0 +1,35 @@ +/** + * The CommonJS build's `./subsystems` (`lazy-subsystems.ts`) exports what `subsystems.ts` exports, and loads a + * subsystem the first time one of its exports is read (#950). + */ +describe('lazy-subsystems', () => { + afterEach(() => { + jest.dontMock('../job'); + jest.resetModules(); + }); + + it('exports the values subsystems.ts exports', () => { + const lazySubsystems = require('../lazy-subsystems'); + const subsystems = require('../subsystems'); + + expect(Object.keys(lazySubsystems).sort()).toEqual(Object.keys(subsystems).sort()); + for (const name of Object.keys(subsystems)) { + expect(lazySubsystems[name]).toBe(subsystems[name]); + } + }); + + it('loads a subsystem the first time one of its exports is read', () => { + const JobRegistry = class {}; + const loadJob = jest.fn(() => ({ JobRegistry })); + jest.doMock('../job', loadJob); + + jest.isolateModules(() => { + const lazySubsystems = require('../lazy-subsystems'); + expect(loadJob).not.toHaveBeenCalled(); + + expect(lazySubsystems.JobRegistry).toBe(JobRegistry); + expect(lazySubsystems.JobRegistry).toBe(JobRegistry); + expect(loadJob).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/libs/sdk/src/index.ts b/libs/sdk/src/index.ts index 1e4bb3a0f..19b1f30d6 100644 --- a/libs/sdk/src/index.ts +++ b/libs/sdk/src/index.ts @@ -36,8 +36,8 @@ export * from './elicitation'; export * from './task'; export { default as LoggerRegistry } from './logger/logger.registry'; export * from '@frontmcp/guard'; -export * from './remote-mcp'; -export * from './esm-loader'; +// Jobs, workflows, channels, remote MCP and the ESM loader +export * from './subsystems'; // Lazy-zod: `z` is lazy by default for cold-start wins, `eagerZ` is the // straight zod pass-through, `lazyZ` is the explicit factory form. @@ -150,7 +150,6 @@ export { ToolInstance } from './tool/tool.instance'; export { default as ProviderRegistry } from './provider/provider.registry'; // Job exports - saved, discoverable, triggerable executions -export { JobRegistry, JobInstance, JobPermissionGuard, JobExecutionManager, JobEmitter } from './job'; export type { JobRegistryInterface, IndexedJob, @@ -164,13 +163,7 @@ export type { } from './job'; export type { JobStateStore, JobRunRecord, WorkflowRunRecord, JobExecutionState, JobDefinitionStore } from './job'; -// Job-management tool classes (issue #408 — opt-in manual registration via -// @App({ tools: [...] }) when finer-grained control than auto-registration -// is needed, e.g. to omit register_job / remove_job in production). -export { ExecuteJobTool, GetJobStatusTool, ListJobsTool, RegisterJobTool, RemoveJobTool } from './job/tools'; - // Workflow exports - managed multi-step job pipelines -export { WorkflowRegistry, WorkflowInstance, WorkflowEngine, WorkflowStepExecutor, WorkflowEmitter } from './workflow'; export type { WorkflowRegistryInterface, IndexedWorkflow, @@ -179,24 +172,7 @@ export type { WorkflowChangeScope, } from './workflow'; -// Workflow-management tool classes (issue #408) -export { - ExecuteWorkflowTool, - GetWorkflowStatusTool, - ListWorkflowsTool, - RegisterWorkflowTool, - RemoveWorkflowTool, -} from './workflow/tools'; - // Channel exports - push-based notification channels for Claude Code -export { - ChannelInstance, - ChannelRegistry, - ChannelEmitter, - ChannelNotificationService, - ChannelEventBus, - ChannelReplyTool, -} from './channel'; export type { ChannelRegistryInterface, IndexedChannel, @@ -540,8 +516,6 @@ export { McpStatelessClient, McpStatelessError, TASKS_CLIENT_CAPABILITY } from ' export type { McpStatelessClientOptions, McpStatelessInputHandlers } from './transport/mcp-20260728'; export { validateHeaderParams, buildParamHeaders } from './transport/mcp-20260728'; export { FRONTMCP_SUPPORTED_PROTOCOL_VERSIONS, TASKS_EXTENSION_ID } from './transport/mcp-20260728'; -// Remote-proxy adapter so a FrontMCP server can proxy a 2026-07-28 remote. -export { McpStatelessClientAdapter, negotiateRemoteProtocol } from './remote-mcp/mcp-stateless-client.adapter'; export { PROTOCOL_2026_07_28 } from '@frontmcp/protocol'; // Web-standard MCP transport helpers — stateless runner + persistent (Durable diff --git a/libs/sdk/src/lazy-subsystems.ts b/libs/sdk/src/lazy-subsystems.ts new file mode 100644 index 000000000..8a67cef4c --- /dev/null +++ b/libs/sdk/src/lazy-subsystems.ts @@ -0,0 +1,103 @@ +// The CommonJS build's `./subsystems` (see esbuild.cjs.config.js): a subsystem loads the first time one of its exports is read. +{ + const defineLazyExports = (load: () => Record, names: readonly string[]): void => { + let loaded: Record | undefined; + for (const name of names) { + Object.defineProperty(exports, name, { enumerable: true, get: () => (loaded ??= load())[name] }); + } + }; + + defineLazyExports( + () => require('./remote-mcp'), + [ + 'CapabilityCache', + 'CircuitBreaker', + 'CircuitBreakerManager', + 'CircuitOpenError', + 'HealthCheckManager', + 'HealthChecker', + 'McpClientService', + 'buildRemotePromptRecord', + 'buildRemoteResourceRecord', + 'buildRemoteResourceTemplateRecord', + 'buildRemoteToolRecord', + 'createRemotePromptContextClass', + 'createRemotePromptInstance', + 'createRemoteResourceContextClass', + 'createRemoteResourceInstance', + 'createRemoteResourceTemplateInstance', + 'createRemoteToolContextClass', + 'createRemoteToolInstance', + 'isAuthError', + 'isConnectionError', + 'isTransientError', + 'withRetry', + ], + ); + defineLazyExports( + () => require('./remote-mcp/mcp-stateless-client.adapter'), + ['McpStatelessClientAdapter', 'negotiateRemoteProtocol'], + ); + defineLazyExports( + () => require('./esm-loader'), + [ + 'DEFAULT_NPM_REGISTRY', + 'ESM_SH_BASE_URL', + 'EsmCacheManager', + 'EsmModuleLoader', + 'MANIFEST_PRIMITIVE_KEYS', + 'VersionPoller', + 'VersionResolver', + 'buildEsmPromptRecord', + 'buildEsmResourceRecord', + 'buildEsmShUrl', + 'buildEsmToolRecord', + 'compareVersions', + 'createEsmPromptContextClass', + 'createEsmPromptInstance', + 'createEsmResourceContextClass', + 'createEsmResourceInstance', + 'createEsmToolContextClass', + 'createEsmToolInstance', + 'esmRegistryAuthSchema', + 'frontMcpPackageManifestSchema', + 'getRegistryUrl', + 'isNewerVersion', + 'isPackageSpecifier', + 'isValidRange', + 'isValidVersion', + 'maxSatisfying', + 'normalizeEsmExport', + 'parsePackageSpecifier', + 'resolveRegistryToken', + 'satisfiesRange', + ], + ); + defineLazyExports( + () => require('./job'), + ['JobRegistry', 'JobInstance', 'JobPermissionGuard', 'JobExecutionManager', 'JobEmitter'], + ); + defineLazyExports( + () => require('./job/tools'), + ['ExecuteJobTool', 'GetJobStatusTool', 'ListJobsTool', 'RegisterJobTool', 'RemoveJobTool'], + ); + defineLazyExports( + () => require('./workflow'), + ['WorkflowRegistry', 'WorkflowInstance', 'WorkflowEngine', 'WorkflowStepExecutor', 'WorkflowEmitter'], + ); + defineLazyExports( + () => require('./workflow/tools'), + ['ExecuteWorkflowTool', 'GetWorkflowStatusTool', 'ListWorkflowsTool', 'RegisterWorkflowTool', 'RemoveWorkflowTool'], + ); + defineLazyExports( + () => require('./channel'), + [ + 'ChannelInstance', + 'ChannelRegistry', + 'ChannelEmitter', + 'ChannelNotificationService', + 'ChannelEventBus', + 'ChannelReplyTool', + ], + ); +} diff --git a/libs/sdk/src/subsystems.ts b/libs/sdk/src/subsystems.ts new file mode 100644 index 000000000..34b038d38 --- /dev/null +++ b/libs/sdk/src/subsystems.ts @@ -0,0 +1,34 @@ +// The root exports of the subsystems a server may never use. The CommonJS build takes `./lazy-subsystems` instead. +export * from './remote-mcp'; +export * from './esm-loader'; +export { McpStatelessClientAdapter, negotiateRemoteProtocol } from './remote-mcp/mcp-stateless-client.adapter'; + +// Job exports - saved, discoverable, triggerable executions +export { JobRegistry, JobInstance, JobPermissionGuard, JobExecutionManager, JobEmitter } from './job'; + +// Job-management tool classes (issue #408 — opt-in manual registration via +// @App({ tools: [...] }) when finer-grained control than auto-registration +// is needed, e.g. to omit register_job / remove_job in production). +export { ExecuteJobTool, GetJobStatusTool, ListJobsTool, RegisterJobTool, RemoveJobTool } from './job/tools'; + +// Workflow exports - managed multi-step job pipelines +export { WorkflowRegistry, WorkflowInstance, WorkflowEngine, WorkflowStepExecutor, WorkflowEmitter } from './workflow'; + +// Workflow-management tool classes (issue #408) +export { + ExecuteWorkflowTool, + GetWorkflowStatusTool, + ListWorkflowsTool, + RegisterWorkflowTool, + RemoveWorkflowTool, +} from './workflow/tools'; + +// Channel exports - push-based notification channels for Claude Code +export { + ChannelInstance, + ChannelRegistry, + ChannelEmitter, + ChannelNotificationService, + ChannelEventBus, + ChannelReplyTool, +} from './channel'; From 2d815d96b34fb8bee2ab1e020019018ecaac7641 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 19:12:36 +0200 Subject: [PATCH 14/25] perf(sdk): register the OAuth flows by name and route, and load each one with the first request that reaches it --- .../auth/flows/__tests__/oauth-flows.spec.ts | 69 +++++++++++ libs/sdk/src/auth/flows/oauth-flows.ts | 29 +++++ libs/sdk/src/auth/flows/oauth.auth-ui.flow.ts | 2 + .../src/auth/flows/oauth.authorize.flow.ts | 2 + .../sdk/src/auth/flows/oauth.callback.flow.ts | 2 + libs/sdk/src/auth/flows/oauth.connect.flow.ts | 2 + .../flows/oauth.provider-callback.flow.ts | 2 + .../sdk/src/auth/flows/oauth.register.flow.ts | 2 + libs/sdk/src/auth/flows/oauth.token.flow.ts | 2 + .../sdk/src/auth/flows/oauth.userinfo.flow.ts | 2 + .../instances/instance.local-primary-auth.ts | 19 +-- .../src/flows/__tests__/deferred-flow.spec.ts | 115 ++++++++++++++++++ libs/sdk/src/flows/deferred-flow.ts | 29 +++++ libs/sdk/src/flows/flow.instance.ts | 18 +++ 14 files changed, 278 insertions(+), 17 deletions(-) create mode 100644 libs/sdk/src/auth/flows/__tests__/oauth-flows.spec.ts create mode 100644 libs/sdk/src/auth/flows/oauth-flows.ts create mode 100644 libs/sdk/src/flows/__tests__/deferred-flow.spec.ts create mode 100644 libs/sdk/src/flows/deferred-flow.ts diff --git a/libs/sdk/src/auth/flows/__tests__/oauth-flows.spec.ts b/libs/sdk/src/auth/flows/__tests__/oauth-flows.spec.ts new file mode 100644 index 000000000..bd81507c2 --- /dev/null +++ b/libs/sdk/src/auth/flows/__tests__/oauth-flows.spec.ts @@ -0,0 +1,69 @@ +/** + * The OAuth flows are registered by name and route, and each one's class loads with the first request that reaches it + * (#950). A public-mode server still runs a plugin's hooks on every one of them. + */ +import 'reflect-metadata'; + +import { FlowHooksOf, LogLevel, Plugin, type FlowType } from '../../../common'; +import { deferredFlowLoader } from '../../../flows/deferred-flow'; +import { collectFlowMetadata } from '../../../flows/flow.utils'; +import { FrontMcpInstance } from '../../../front-mcp/front-mcp'; +import { oauthFlows } from '../oauth-flows'; + +const ROUTES = [ + ['oauth:authorize', 'GET', '/oauth/authorize'], + ['oauth:token', 'POST', '/oauth/token'], + ['oauth:userinfo', 'GET', '/oauth/userinfo'], + ['oauth:callback', 'GET', '/oauth/callback'], + ['oauth:connect', 'POST', '/oauth/connect'], + ['oauth:register', 'POST', '/oauth/register'], + ['oauth:provider-callback', 'GET', '/oauth/provider/upstream/callback'], + ['oauth:auth-ui-extra', 'POST', '/oauth/ui/extra'], +] as const; + +const hooked: string[] = []; + +function loadFlowClass(flow: FlowType): Promise { + const load = deferredFlowLoader(flow); + if (!load) throw new Error(`${flow.name} is not a deferred flow`); + return load(); +} + +class OAuthHooksPlugin {} +for (const [flow] of ROUTES) { + const method = `will:${flow}`; + const descriptor: PropertyDescriptor = { value: () => hooked.push(flow) }; + Object.defineProperty(OAuthHooksPlugin.prototype, method, descriptor); + FlowHooksOf(flow).Will('checkIpFilter')(OAuthHooksPlugin.prototype, method, descriptor); +} +Plugin({ name: 'oauth-hooks' })(OAuthHooksPlugin); + +describe('oauthFlows', () => { + it('declares each flow by the name and route of the class it loads', async () => { + const declared = oauthFlows.map((flow) => collectFlowMetadata(flow)); + const loaded = await Promise.all(oauthFlows.map(async (flow) => collectFlowMetadata(await loadFlowClass(flow)))); + + expect(declared.map(({ name, middleware }) => ({ name, middleware }))).toEqual( + loaded.map(({ name, middleware }) => ({ + name, + middleware: { method: middleware?.method, path: middleware?.path }, + })), + ); + expect(declared.map(({ name }) => name)).toEqual(ROUTES.map(([flow]) => flow)); + }); + + it('runs the hooks of every OAuth flow of a public-mode server', async () => { + const handler = await FrontMcpInstance.createFetchHandler({ + info: { name: 'oauth-flows', version: '1.0.0' }, + apps: [], + plugins: [OAuthHooksPlugin], + logging: { level: LogLevel.Off }, + }); + + for (const [, method, path] of ROUTES) { + await (await handler(new Request(`http://localhost${path}`, { method }))).arrayBuffer(); + } + + expect(hooked).toEqual(ROUTES.map(([flow]) => flow)); + }); +}); diff --git a/libs/sdk/src/auth/flows/oauth-flows.ts b/libs/sdk/src/auth/flows/oauth-flows.ts new file mode 100644 index 000000000..0a80cc75f --- /dev/null +++ b/libs/sdk/src/auth/flows/oauth-flows.ts @@ -0,0 +1,29 @@ +import { deferredFlow } from '../../flows/deferred-flow'; + +/** The OAuth flows, in route order. Each is registered by name and route, and loads with its first request. */ +export const oauthFlows = [ + deferredFlow({ name: 'oauth:authorize', middleware: { method: 'GET', path: '/oauth/authorize' } }, () => + import('./oauth.authorize.flow.js').then((flow) => flow.OauthAuthorizeFlow), + ), + deferredFlow({ name: 'oauth:token', middleware: { method: 'POST', path: '/oauth/token' } }, () => + import('./oauth.token.flow.js').then((flow) => flow.OauthTokenFlow), + ), + deferredFlow({ name: 'oauth:userinfo', middleware: { method: 'GET', path: '/oauth/userinfo' } }, () => + import('./oauth.userinfo.flow.js').then((flow) => flow.OauthUserInfoFlow), + ), + deferredFlow({ name: 'oauth:callback', middleware: { path: '/oauth/callback' } }, () => + import('./oauth.callback.flow.js').then((flow) => flow.OauthCallbackFlow), + ), + deferredFlow({ name: 'oauth:connect', middleware: { path: '/oauth/connect' } }, () => + import('./oauth.connect.flow.js').then((flow) => flow.OauthConnectFlow), + ), + deferredFlow({ name: 'oauth:register', middleware: { method: 'POST', path: '/oauth/register' } }, () => + import('./oauth.register.flow.js').then((flow) => flow.OauthRegisterFlow), + ), + deferredFlow({ name: 'oauth:provider-callback', middleware: { path: '/oauth/provider/:providerId/callback' } }, () => + import('./oauth.provider-callback.flow.js').then((flow) => flow.OauthProviderCallbackFlow), + ), + deferredFlow({ name: 'oauth:auth-ui-extra', middleware: { method: 'POST', path: '/oauth/ui/extra' } }, () => + import('./oauth.auth-ui.flow.js').then((flow) => flow.OauthAuthUiExtraFlow), + ), +]; diff --git a/libs/sdk/src/auth/flows/oauth.auth-ui.flow.ts b/libs/sdk/src/auth/flows/oauth.auth-ui.flow.ts index f96856503..b6dbaa75a 100644 --- a/libs/sdk/src/auth/flows/oauth.auth-ui.flow.ts +++ b/libs/sdk/src/auth/flows/oauth.auth-ui.flow.ts @@ -182,3 +182,5 @@ function timingSafeEqualStr(a: string, b: string): boolean { } return diff === 0; } + +export { OauthAuthUiExtraFlow }; diff --git a/libs/sdk/src/auth/flows/oauth.authorize.flow.ts b/libs/sdk/src/auth/flows/oauth.authorize.flow.ts index 36416de7c..7db8d5c0c 100644 --- a/libs/sdk/src/auth/flows/oauth.authorize.flow.ts +++ b/libs/sdk/src/auth/flows/oauth.authorize.flow.ts @@ -1446,3 +1446,5 @@ export default class OauthAuthorizeFlow extends FlowBase { `; } } + +export { OauthAuthorizeFlow }; diff --git a/libs/sdk/src/auth/flows/oauth.callback.flow.ts b/libs/sdk/src/auth/flows/oauth.callback.flow.ts index 3ce178650..8e95d8601 100644 --- a/libs/sdk/src/auth/flows/oauth.callback.flow.ts +++ b/libs/sdk/src/auth/flows/oauth.callback.flow.ts @@ -1617,3 +1617,5 @@ function timingSafeEqualStr(a: string, b: string): boolean { } return diff === 0; } + +export { OauthCallbackFlow }; diff --git a/libs/sdk/src/auth/flows/oauth.connect.flow.ts b/libs/sdk/src/auth/flows/oauth.connect.flow.ts index ab3fda4e0..ea9ae7988 100644 --- a/libs/sdk/src/auth/flows/oauth.connect.flow.ts +++ b/libs/sdk/src/auth/flows/oauth.connect.flow.ts @@ -386,3 +386,5 @@ export default class OauthConnectFlow extends FlowBase { `; } } + +export { OauthConnectFlow }; diff --git a/libs/sdk/src/auth/flows/oauth.provider-callback.flow.ts b/libs/sdk/src/auth/flows/oauth.provider-callback.flow.ts index c6fc0bde7..e7e05bffb 100644 --- a/libs/sdk/src/auth/flows/oauth.provider-callback.flow.ts +++ b/libs/sdk/src/auth/flows/oauth.provider-callback.flow.ts @@ -1074,3 +1074,5 @@ export default class OauthProviderCallbackFlow extends FlowBase { function timingSafeEqualStr(a: string, b: string): boolean { return timingSafeEqual(sha256(a), sha256(b)); } + +export { OauthProviderCallbackFlow }; diff --git a/libs/sdk/src/auth/flows/oauth.register.flow.ts b/libs/sdk/src/auth/flows/oauth.register.flow.ts index ab53607bd..c95b689c1 100644 --- a/libs/sdk/src/auth/flows/oauth.register.flow.ts +++ b/libs/sdk/src/auth/flows/oauth.register.flow.ts @@ -381,3 +381,5 @@ export default class OauthRegisterFlow extends FlowBase { // no-op; httpRespond.json enforces shape } } + +export { OauthRegisterFlow }; diff --git a/libs/sdk/src/auth/flows/oauth.token.flow.ts b/libs/sdk/src/auth/flows/oauth.token.flow.ts index 03c505cdd..53098a4ee 100644 --- a/libs/sdk/src/auth/flows/oauth.token.flow.ts +++ b/libs/sdk/src/auth/flows/oauth.token.flow.ts @@ -571,3 +571,5 @@ export default class OauthTokenFlow extends FlowBase { return computeResource(this.rawInput.request, this.scope.entryPath, this.scope.routeBase); } } + +export { OauthTokenFlow }; diff --git a/libs/sdk/src/auth/flows/oauth.userinfo.flow.ts b/libs/sdk/src/auth/flows/oauth.userinfo.flow.ts index ee634fe81..c13718d1c 100644 --- a/libs/sdk/src/auth/flows/oauth.userinfo.flow.ts +++ b/libs/sdk/src/auth/flows/oauth.userinfo.flow.ts @@ -155,3 +155,5 @@ export default class OauthUserInfoFlow extends FlowBase { this.respond(httpRespond.json(body)); } } + +export { OauthUserInfoFlow }; diff --git a/libs/sdk/src/auth/instances/instance.local-primary-auth.ts b/libs/sdk/src/auth/instances/instance.local-primary-auth.ts index db22fe219..1193070f2 100644 --- a/libs/sdk/src/auth/instances/instance.local-primary-auth.ts +++ b/libs/sdk/src/auth/instances/instance.local-primary-auth.ts @@ -69,14 +69,7 @@ import { importWithRequireFallback } from '../../utils/dynamic-import.utils'; import { CimdService } from '../cimd'; import { createCredentialsProviders } from '../credentials'; import { credentialsContextExtension } from '../credentials/credentials.context-extension'; -import OauthAuthUiExtraFlow from '../flows/oauth.auth-ui.flow'; -import OauthAuthorizeFlow from '../flows/oauth.authorize.flow'; -import OauthCallbackFlow from '../flows/oauth.callback.flow'; -import OauthConnectFlow from '../flows/oauth.connect.flow'; -import OauthProviderCallbackFlow from '../flows/oauth.provider-callback.flow'; -import OauthRegisterFlow from '../flows/oauth.register.flow'; -import OauthTokenFlow from '../flows/oauth.token.flow'; -import OauthUserInfoFlow from '../flows/oauth.userinfo.flow'; +import { oauthFlows } from '../flows/oauth-flows'; import SessionVerifyFlow from '../flows/session.verify.flow'; import WellKnownJwksFlow from '../flows/well-known.jwks.flow'; import WellKnownAsFlow from '../flows/well-known.oauth-authorization-server.flow'; @@ -1582,15 +1575,7 @@ export class LocalPrimaryAuth extends FrontMcpAuth { WellKnownAsFlow /** /.well-known/oauth-authorization-server */, WellKnownJwksFlow /** /.well-known/jwks.json */, SessionVerifyFlow /** Session verification flow */, - - OauthAuthorizeFlow /** GET /oauth/authorize */, - OauthTokenFlow /** POST /oauth/token */, - OauthUserInfoFlow /** GET /oauth/userinfo - OIDC userinfo */, - OauthCallbackFlow /** GET /oauth/callback - login callback */, - OauthConnectFlow /** GET|POST /oauth/connect - mid-session add-credential (Checkpoint 3b) */, - OauthRegisterFlow /** POST /oauth/register */, - OauthProviderCallbackFlow /** GET /oauth/provider/:providerId/callback */, - OauthAuthUiExtraFlow /** POST /oauth/ui/extra — @AuthExtra validated-field submit (#469) */, + ...oauthFlows, ); } diff --git a/libs/sdk/src/flows/__tests__/deferred-flow.spec.ts b/libs/sdk/src/flows/__tests__/deferred-flow.spec.ts new file mode 100644 index 000000000..1ab9322b8 --- /dev/null +++ b/libs/sdk/src/flows/__tests__/deferred-flow.spec.ts @@ -0,0 +1,115 @@ +/** + * A deferred flow is registered under its name and route like any flow, and its class loads the first time a request + * reaches the route or the flow runs (#950). Hooks on its stages run as they do on a flow registered by its class. + */ +import 'reflect-metadata'; + +import { z, ZodError } from '@frontmcp/lazy-zod'; + +import { + Flow, + FlowBase, + FlowHooksOf, + LogLevel, + Plugin, + type FlowPlan, + type FlowRunOptions, + type FlowType, + type ServerRequest, +} from '../../common'; +import { FrontMcpInstance } from '../../front-mcp/front-mcp'; +import type { Scope } from '../../scope/scope.instance'; +import { deferredFlow } from '../deferred-flow'; + +const name = 'test:deferred-greet' as const; +const inputSchema = z.object({ name: z.string() }); +const outputSchema = z.object({ message: z.string() }); +const stateSchema = z.object({}); +const plan = { execute: ['greet'] } as const satisfies FlowPlan; + +declare global { + interface ExtendFlows { + 'test:deferred-greet': FlowRunOptions< + GreetFlow, + typeof plan, + typeof inputSchema, + typeof outputSchema, + typeof stateSchema + >; + } +} + +const GreetHook = FlowHooksOf(name); + +@Flow({ name, plan, inputSchema, outputSchema, access: 'public', middleware: { method: 'POST', path: '/greet' } }) +class GreetFlow extends FlowBase { + @GreetHook.Stage('greet') + async greet() { + this.respond({ message: `Hello, ${this.input.name}` }); + } +} + +const hooked: string[] = []; + +@Plugin({ name: 'greet-hooks' }) +class GreetHooksPlugin { + @GreetHook.Will('greet') + willGreet() { + hooked.push('will greet'); + } +} + +function request(method: ServerRequest['method'], path: string): ServerRequest { + return { method, path, url: path, headers: {}, query: {}, body: undefined } as ServerRequest; +} + +describe('deferredFlow', () => { + let scope: Scope; + let load: jest.Mock, []>; + + beforeEach(async () => { + hooked.length = 0; + load = jest.fn(async () => GreetFlow); + const frontMcp = await FrontMcpInstance.createForGraph({ + info: { name: 'deferred-flow', version: '1.0.0' }, + apps: [], + plugins: [GreetHooksPlugin], + logging: { level: LogLevel.Off }, + }); + scope = frontMcp.getScopes()[0] as Scope; + await scope.registryFlows(deferredFlow({ name, middleware: { method: 'POST', path: '/greet' } }, load)); + }); + + it('registers the flow without loading its class', () => { + expect(load).not.toHaveBeenCalled(); + }); + + it('loads its class once when it runs, and runs its stages and hooks', async () => { + const [first, second] = await Promise.all([ + scope.runFlowForOutput(name, { name: 'Ada' }), + scope.runFlowForOutput(name, { name: 'Lin' }), + ]); + + expect(first).toEqual({ message: 'Hello, Ada' }); + expect(second).toEqual({ message: 'Hello, Lin' }); + expect(hooked).toEqual(['will greet', 'will greet']); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('validates input with the schema of the class it loads', async () => { + await expect(scope.runFlowForOutput(name, { name: 42 } as never)).rejects.toThrow(ZodError); + }); + + it('leaves its class unloaded for a request to another route or with another method', async () => { + await scope.findHttpFlowName(request('POST', '/elsewhere')); + await scope.findHttpFlowName(request('GET', '/greet')); + + expect(load).not.toHaveBeenCalled(); + }); + + it('loads its class to answer a request to its route', async () => { + await expect(scope.findHttpFlowName(request('POST', '/greet'))).resolves.toBe(name); + + expect(load).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/sdk/src/flows/deferred-flow.ts b/libs/sdk/src/flows/deferred-flow.ts new file mode 100644 index 000000000..2f85bb0cf --- /dev/null +++ b/libs/sdk/src/flows/deferred-flow.ts @@ -0,0 +1,29 @@ +import 'reflect-metadata'; + +import { FlowBase, FrontMcpFlowTokens, type FlowName, type FlowType, type HttpMethod } from '../common'; + +/** What a deferred flow is registered by before its class loads: its name and its route. */ +export interface DeferredFlowDeclaration { + name: FlowName; + middleware: { path: string; method?: HttpMethod }; +} + +const flowClassLoaders = new WeakMap Promise>(); + +/** + * A flow registered under its name and route like any flow, whose class loads the first time a request reaches the + * route or the flow runs. + */ +export function deferredFlow(declaration: DeferredFlowDeclaration, load: () => Promise): FlowType { + class DeferredFlow extends FlowBase {} + Reflect.defineMetadata(FrontMcpFlowTokens.type, true, DeferredFlow); + Reflect.defineMetadata(FrontMcpFlowTokens.name, declaration.name, DeferredFlow); + Reflect.defineMetadata(FrontMcpFlowTokens.middleware, declaration.middleware, DeferredFlow); + flowClassLoaders.set(DeferredFlow, load); + return DeferredFlow; +} + +/** The loader of the class a flow registered with {@link deferredFlow} stands for. */ +export function deferredFlowLoader(flowClass: FlowType): (() => Promise) | undefined { + return flowClassLoaders.get(flowClass); +} diff --git a/libs/sdk/src/flows/flow.instance.ts b/libs/sdk/src/flows/flow.instance.ts index d4d8e5bfa..c0d26488a 100644 --- a/libs/sdk/src/flows/flow.instance.ts +++ b/libs/sdk/src/flows/flow.instance.ts @@ -37,6 +37,7 @@ import { bindContextHookTargets, staticHooksFor } from '../hooks/hooks.utils'; import type ProviderRegistry from '../provider/provider.registry'; import { writeHttpResponse } from '../server/server.validation'; import { flowErrorToHttpOutput } from '../transport/flow-error-output'; +import { deferredFlowLoader } from './deferred-flow'; import { matchMountedPath } from './flow.http-path'; import { cloneStageMap, @@ -47,6 +48,7 @@ import { type StageEntry, type StageMap, } from './flow.stages'; +import { collectFlowMetadata } from './flow.utils'; /** * Where a request's hook observer lives on its `FrontMcpContext`: `@frontmcp/observability` installs one to @@ -170,6 +172,7 @@ export class FlowInstance extends FlowEntry { readonly globalProviders: ProviderRegistry; private plan: FlowPlan; private FlowClass: FlowType; + private flowClassLoad?: Promise; private stages: StageMap; private hooks: HookRegistry; private readonly logger: FrontMcpLogger; @@ -283,8 +286,22 @@ export class FlowInstance extends FlowEntry { return Promise.resolve(); } + /** Loads the class of a flow registered with `deferredFlow`, and takes its metadata, plan and stages. */ + private loadFlowClass(): Promise { + const load = deferredFlowLoader(this.FlowClass); + if (!load) return Promise.resolve(); + this.flowClassLoad ??= load().then((flowClass) => { + Object.assign(this.metadata, collectFlowMetadata(flowClass)); + this.plan = this.metadata.plan; + this.stages = collectFlowHookMap(flowClass); + this.FlowClass = flowClass; + }); + return this.flowClassLoad; + } + async canActivate(request: ServerRequest): Promise { if (this.method && request.method !== this.method) return false; + await this.loadFlowClass(); const canActivate = this.metadata.middleware?.canActivate ?? []; if ((this.FlowClass as any)['canActivate']) { @@ -379,6 +396,7 @@ export class FlowInstance extends FlowEntry { async run(input: FlowInputOf, deps: Map): Promise | undefined> { this.logger.verbose(`run: starting flow '${this.name}'`); + await this.loadFlowClass(); const scope = this.globalProviders.getActiveScope(); // Build provider views for scoped DI From ac6708db7b239e888a0bc2b03b0a44ce84a56873 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 19:26:05 +0200 Subject: [PATCH 15/25] fix(sdk): name the root exports the CommonJS build loads on first use, so an ESM import of that build still sees them --- .../e2e/sdk-root-exports.e2e.spec.ts | 7 + libs/sdk/src/index.ts | 141 +++++++++++++++++- libs/sdk/src/subsystems.ts | 14 +- 3 files changed, 147 insertions(+), 15 deletions(-) diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/sdk-root-exports.e2e.spec.ts b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-root-exports.e2e.spec.ts index ee3d98cf4..57dc35188 100644 --- a/apps/e2e/demo-e2e-bundle-size/e2e/sdk-root-exports.e2e.spec.ts +++ b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-root-exports.e2e.spec.ts @@ -29,6 +29,13 @@ describe('the root exports of @frontmcp/sdk', () => { expect(exportKinds('libs/sdk/dist/index.js', 'require')).toEqual(await pinnedExports()); }); + it("are the pinned names and kinds when an ESM import loads the CommonJS build, as Node's export detection sees it", async () => { + expect(exportKinds('libs/sdk/dist/index.js', 'import')).toEqual({ + ...(await pinnedExports()), + 'module.exports': 'object', + }); + }); + it('are the pinned names and kinds under import', async () => { expect(exportKinds('libs/sdk/dist/esm/index.mjs', 'import')).toEqual(await pinnedExports()); }); diff --git a/libs/sdk/src/index.ts b/libs/sdk/src/index.ts index 19b1f30d6..d2b1681a3 100644 --- a/libs/sdk/src/index.ts +++ b/libs/sdk/src/index.ts @@ -36,8 +36,113 @@ export * from './elicitation'; export * from './task'; export { default as LoggerRegistry } from './logger/logger.registry'; export * from '@frontmcp/guard'; -// Jobs, workflows, channels, remote MCP and the ESM loader -export * from './subsystems'; +// Remote MCP and the ESM loader; the classes and functions of every subsystem in ./subsystems load on first use under CommonJS +export type { + McpClientConnection, + McpConnectionStatus, + McpConnectionInfo, + McpTransportType, + McpHttpTransportOptions, + McpWorkerTransportOptions, + McpEsmTransportOptions, + McpTransportOptions, + McpStaticCredentials, + McpRemoteAuthConfig, + McpRemoteAuthContext, + McpRemoteCapabilities, + McpCapabilityChangeEvent, + McpClientServiceOptions, + McpConnectRequest, + McpRemoteCallToolResult, + McpRemoteReadResourceResult, + McpRemoteGetPromptResult, + McpCapabilityChangeCallback, + McpConnectionChangeCallback, + McpUnsubscribeFn, + CapabilityCacheConfig, + RetryOptions, + CircuitState, + CircuitBreakerOptions, + HealthStatus, + HealthCheckResult, + HealthCheckOptions, +} from './remote-mcp'; +export { + McpClientService, + CapabilityCache, + withRetry, + isTransientError, + isConnectionError, + isAuthError, + CircuitBreaker, + CircuitBreakerManager, + CircuitOpenError, + HealthChecker, + HealthCheckManager, + createRemoteToolContextClass, + createRemoteResourceContextClass, + createRemotePromptContextClass, + buildRemoteToolRecord, + buildRemoteResourceRecord, + buildRemoteResourceTemplateRecord, + buildRemotePromptRecord, + createRemoteToolInstance, + createRemoteResourceInstance, + createRemoteResourceTemplateInstance, + createRemotePromptInstance, +} from './subsystems'; +export type { + ParsedPackageSpecifier, + EsmRegistryAuth, + EsmCacheEntry, + EsmCacheOptions, + FrontMcpPackageManifest, + ManifestPrimitiveKey, + VersionResolutionResult, + VersionResolverOptions, + EsmLoadResult, + EsmModuleLoaderOptions, + VersionPollerOptions, + VersionCheckResult, + EsmToolExecuteHandler, + EsmResourceReadHandler, + EsmPromptExecuteHandler, + EsmToolDefinition, + EsmResourceDefinition, + EsmPromptDefinition, +} from './esm-loader'; +export { + parsePackageSpecifier, + isPackageSpecifier, + buildEsmShUrl, + ESM_SH_BASE_URL, + esmRegistryAuthSchema, + resolveRegistryToken, + getRegistryUrl, + DEFAULT_NPM_REGISTRY, + satisfiesRange, + maxSatisfying, + isValidRange, + isValidVersion, + compareVersions, + isNewerVersion, + EsmCacheManager, + normalizeEsmExport, + frontMcpPackageManifestSchema, + MANIFEST_PRIMITIVE_KEYS, + VersionResolver, + EsmModuleLoader, + VersionPoller, + createEsmToolContextClass, + createEsmResourceContextClass, + createEsmPromptContextClass, + buildEsmToolRecord, + buildEsmResourceRecord, + buildEsmPromptRecord, + createEsmToolInstance, + createEsmResourceInstance, + createEsmPromptInstance, +} from './subsystems'; // Lazy-zod: `z` is lazy by default for cold-start wins, `eagerZ` is the // straight zod pass-through, `lazyZ` is the explicit factory form. @@ -150,6 +255,7 @@ export { ToolInstance } from './tool/tool.instance'; export { default as ProviderRegistry } from './provider/provider.registry'; // Job exports - saved, discoverable, triggerable executions +export { JobRegistry, JobInstance, JobPermissionGuard, JobExecutionManager, JobEmitter } from './subsystems'; export type { JobRegistryInterface, IndexedJob, @@ -163,7 +269,19 @@ export type { } from './job'; export type { JobStateStore, JobRunRecord, WorkflowRunRecord, JobExecutionState, JobDefinitionStore } from './job'; +// Job-management tool classes (issue #408 — opt-in manual registration via +// @App({ tools: [...] }) when finer-grained control than auto-registration +// is needed, e.g. to omit register_job / remove_job in production). +export { ExecuteJobTool, GetJobStatusTool, ListJobsTool, RegisterJobTool, RemoveJobTool } from './subsystems'; + // Workflow exports - managed multi-step job pipelines +export { + WorkflowRegistry, + WorkflowInstance, + WorkflowEngine, + WorkflowStepExecutor, + WorkflowEmitter, +} from './subsystems'; export type { WorkflowRegistryInterface, IndexedWorkflow, @@ -172,7 +290,24 @@ export type { WorkflowChangeScope, } from './workflow'; +// Workflow-management tool classes (issue #408) +export { + ExecuteWorkflowTool, + GetWorkflowStatusTool, + ListWorkflowsTool, + RegisterWorkflowTool, + RemoveWorkflowTool, +} from './subsystems'; + // Channel exports - push-based notification channels for Claude Code +export { + ChannelInstance, + ChannelRegistry, + ChannelEmitter, + ChannelNotificationService, + ChannelEventBus, + ChannelReplyTool, +} from './subsystems'; export type { ChannelRegistryInterface, IndexedChannel, @@ -516,6 +651,8 @@ export { McpStatelessClient, McpStatelessError, TASKS_CLIENT_CAPABILITY } from ' export type { McpStatelessClientOptions, McpStatelessInputHandlers } from './transport/mcp-20260728'; export { validateHeaderParams, buildParamHeaders } from './transport/mcp-20260728'; export { FRONTMCP_SUPPORTED_PROTOCOL_VERSIONS, TASKS_EXTENSION_ID } from './transport/mcp-20260728'; +// Remote-proxy adapter so a FrontMCP server can proxy a 2026-07-28 remote. +export { McpStatelessClientAdapter, negotiateRemoteProtocol } from './subsystems'; export { PROTOCOL_2026_07_28 } from '@frontmcp/protocol'; // Web-standard MCP transport helpers — stateless runner + persistent (Durable diff --git a/libs/sdk/src/subsystems.ts b/libs/sdk/src/subsystems.ts index 34b038d38..9a4217169 100644 --- a/libs/sdk/src/subsystems.ts +++ b/libs/sdk/src/subsystems.ts @@ -1,20 +1,10 @@ -// The root exports of the subsystems a server may never use. The CommonJS build takes `./lazy-subsystems` instead. +// The root exports of the subsystems a server may never use. The CommonJS build takes ./lazy-subsystems instead. export * from './remote-mcp'; export * from './esm-loader'; export { McpStatelessClientAdapter, negotiateRemoteProtocol } from './remote-mcp/mcp-stateless-client.adapter'; - -// Job exports - saved, discoverable, triggerable executions export { JobRegistry, JobInstance, JobPermissionGuard, JobExecutionManager, JobEmitter } from './job'; - -// Job-management tool classes (issue #408 — opt-in manual registration via -// @App({ tools: [...] }) when finer-grained control than auto-registration -// is needed, e.g. to omit register_job / remove_job in production). export { ExecuteJobTool, GetJobStatusTool, ListJobsTool, RegisterJobTool, RemoveJobTool } from './job/tools'; - -// Workflow exports - managed multi-step job pipelines export { WorkflowRegistry, WorkflowInstance, WorkflowEngine, WorkflowStepExecutor, WorkflowEmitter } from './workflow'; - -// Workflow-management tool classes (issue #408) export { ExecuteWorkflowTool, GetWorkflowStatusTool, @@ -22,8 +12,6 @@ export { RegisterWorkflowTool, RemoveWorkflowTool, } from './workflow/tools'; - -// Channel exports - push-based notification channels for Claude Code export { ChannelInstance, ChannelRegistry, From 6db38c4d4722651905d34e9f289f3b12bc37aa58 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 19:31:29 +0200 Subject: [PATCH 16/25] test(bundle-size): hook the tools of a remote app and an ESM package under the SDK's CommonJS build --- .../e2e/helpers/subsystem-hooks.mjs | 77 +++++++++++++++++-- .../e2e/sdk-subsystem-hooks.e2e.spec.ts | 17 ++-- 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/helpers/subsystem-hooks.mjs b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/subsystem-hooks.mjs index 5a7a2b322..817d5d764 100644 --- a/apps/e2e/demo-e2e-bundle-size/e2e/helpers/subsystem-hooks.mjs +++ b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/subsystem-hooks.mjs @@ -1,6 +1,7 @@ -// Requires the SDK's CommonJS build, runs a job, a workflow, a channel notification, the channel list and a request -// to each OAuth route of a public-mode server, and prints, as JSON, the flows a server plugin's hooks saw: -// node subsystem-hooks.mjs +// Requires the SDK's CommonJS build, runs a job, a workflow, a channel notification, the channel list, a remote app's +// tool, an ESM package's tool and a request to each OAuth route of a public-mode server, and prints, as JSON, the +// flows a server plugin's hooks saw, on its last line: node subsystem-hooks.mjs +import { createServer } from 'node:http'; import { createRequire } from 'node:module'; const [entry] = process.argv.slice(2); @@ -48,10 +49,15 @@ class SubsystemHooksPlugin { recordChannelList() { seen.push('channels:list'); } + recordToolCall(flowCtx) { + const tool = flowCtx.rawInput.request.params.name; + if (tool.startsWith('remote') || tool.startsWith('esm')) seen.push(`tools:call-tool ${tool}`); + } } decorate(SubsystemHooksPlugin.prototype, 'recordJob', FlowHooksOf('jobs:execute-job').Did('updateRunState')); decorate(SubsystemHooksPlugin.prototype, 'recordChannelSend', FlowHooksOf('channels:send-notification').Will('send')); decorate(SubsystemHooksPlugin.prototype, 'recordChannelList', FlowHooksOf('channels:list').Will('listChannels')); +decorate(SubsystemHooksPlugin.prototype, 'recordToolCall', FlowHooksOf('tools:call-tool').Will('parseInput')); for (const flow of Object.keys(OAUTH_ROUTES)) { const method = `record:${flow}`; SubsystemHooksPlugin.prototype[method] = () => seen.push(flow); @@ -96,9 +102,64 @@ App({ tools: [AlertTool], })(SubsystemsApp); +class ShoutTool extends ToolContext { + async execute() { + return 'HELLO'; + } +} +Tool({ name: 'shout', inputSchema: {} })(ShoutTool); +class RemoteApp {} +App({ name: 'Remote', tools: [ShoutTool] })(RemoteApp); + +function listen(server) { + return new Promise((resolveListen) => server.listen(0, '127.0.0.1', () => resolveListen(server.address().port))); +} + +const portProbe = createServer(); +const remotePort = await listen(portProbe); +await new Promise((resolveClose) => portProbe.close(resolveClose)); +const remote = await FrontMcpInstance.bootstrap({ + info: { name: 'remote', version: '1.0.0' }, + apps: [RemoteApp], + http: { port: remotePort }, + logging: { level: LogLevel.Off }, +}); + +const ESM_PACKAGE = '@frontmcp-e2e/wave-tools'; +const ESM_BUNDLE = `module.exports = { default: { name: '${ESM_PACKAGE}', version: '1.0.0', tools: [{ + name: 'wave', description: 'Waves', inputSchema: { type: 'object', properties: {} }, + execute: async () => ({ content: [{ type: 'text', text: 'wave' }] }), +}] } };`; +const esmPackages = createServer((request, response) => { + const path = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname).slice(1); + if (path === ESM_PACKAGE) { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + name: ESM_PACKAGE, + 'dist-tags': { latest: '1.0.0' }, + versions: { '1.0.0': { name: ESM_PACKAGE, version: '1.0.0' } }, + time: { '1.0.0': new Date().toISOString() }, + }), + ); + } else if (path === `${ESM_PACKAGE}@1.0.0`) { + response.writeHead(200, { 'Content-Type': 'application/javascript' }); + response.end(ESM_BUNDLE); + } else { + response.writeHead(404); + response.end(); + } +}); +const esmPort = await listen(esmPackages); + const config = { info: { name: 'subsystem-hooks', version: '1.0.0' }, - apps: [SubsystemsApp], + loader: { url: `http://127.0.0.1:${esmPort}` }, + apps: [ + SubsystemsApp, + { name: 'remote', urlType: 'url', url: `http://127.0.0.1:${remotePort}/`, namespace: 'remote', standalone: false }, + App.esm(`${ESM_PACKAGE}@^1.0.0`, { namespace: 'esm' }), + ], plugins: [SubsystemHooksPlugin], jobs: { enabled: true }, channels: { enabled: true }, @@ -110,6 +171,10 @@ try { await client.callTool('execute_job', { name: 'greet', input: { name: 'Ada' } }); await client.callTool('execute_workflow', { name: 'greet-twice' }); await client.callTool('raise-alert', {}); + const tools = await client.listTools(); + for (const { name } of tools.filter((tool) => tool.name.startsWith('remote') || tool.name.startsWith('esm'))) { + await client.callTool(name, {}); + } await new Promise((resolveDelivery) => setTimeout(resolveDelivery, 100)); } finally { await client.close(); @@ -121,5 +186,7 @@ for (const [method, path] of Object.values(OAUTH_ROUTES)) { await response.arrayBuffer(); } -process.stdout.write(JSON.stringify(seen)); +await remote?.shutdown?.(); +esmPackages.close(); +process.stdout.write(`\n${JSON.stringify(seen)}\n`); process.exit(0); diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/sdk-subsystem-hooks.e2e.spec.ts b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-subsystem-hooks.e2e.spec.ts index d45a4b30d..01bf184a9 100644 --- a/apps/e2e/demo-e2e-bundle-size/e2e/sdk-subsystem-hooks.e2e.spec.ts +++ b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-subsystem-hooks.e2e.spec.ts @@ -1,7 +1,7 @@ /** * With the SDK's CommonJS build, a plugin's hooks on the flows of the subsystems it sets up on first use still run: - * jobs and workflow steps, channel notifications and the channel list, and every OAuth flow of a public-mode server - * (#950). + * jobs and workflow steps, channel notifications and the channel list, the tools of a remote app and of an ESM package, + * and every OAuth flow of a public-mode server (#950). * * Needs the packages built: `nx run-many -t build -p sdk plugin-webmcp`. */ @@ -12,12 +12,11 @@ import { workspaceRoot } from './helpers/browser-bundle'; function flowsTheHooksSaw(): string[] { const runner = resolve(__dirname, 'helpers', 'subsystem-hooks.mjs'); - return JSON.parse( - execFileSync(process.execPath, [runner, resolve(workspaceRoot, 'libs/sdk/dist/index.js')], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }), - ); + const output = execFileSync(process.execPath, [runner, resolve(workspaceRoot, 'libs/sdk/dist/index.js')], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return JSON.parse(output.trim().split('\n').at(-1) ?? '[]'); } describe('hooks on the flows of the subsystems the SDK sets up on first use', () => { @@ -28,6 +27,8 @@ describe('hooks on the flows of the subsystems the SDK sets up on first use', () 'jobs:execute-job greet in greet-twice', 'channels:send-notification', 'channels:list', + 'tools:call-tool remote:shout', + 'tools:call-tool esm:wave', 'oauth:authorize', 'oauth:token', 'oauth:userinfo', From 7e02769744a502243d78e96f41e8220eb82f9a01 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 19:34:55 +0200 Subject: [PATCH 17/25] fix(sdk): run a flow's route guards once per request when its class also has a static canActivate --- .../src/flows/__tests__/deferred-flow.spec.ts | 41 +++++++++++++++++++ libs/sdk/src/flows/flow.instance.ts | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/libs/sdk/src/flows/__tests__/deferred-flow.spec.ts b/libs/sdk/src/flows/__tests__/deferred-flow.spec.ts index 1ab9322b8..83907c8af 100644 --- a/libs/sdk/src/flows/__tests__/deferred-flow.spec.ts +++ b/libs/sdk/src/flows/__tests__/deferred-flow.spec.ts @@ -36,6 +36,13 @@ declare global { typeof outputSchema, typeof stateSchema >; + 'test:deferred-guarded': FlowRunOptions< + GuardedFlow, + typeof plan, + typeof inputSchema, + typeof outputSchema, + typeof stateSchema + >; } } @@ -49,6 +56,26 @@ class GreetFlow extends FlowBase { } } +const routeGuard = jest.fn(async () => true); +const classGuard = jest.fn(async () => true); + +@Flow({ + name: 'test:deferred-guarded', + plan, + inputSchema, + outputSchema, + access: 'public', + middleware: { path: '/guarded', canActivate: [routeGuard] }, +}) +class GuardedFlow extends FlowBase<'test:deferred-guarded'> { + static canActivate = classGuard; + + @(FlowHooksOf('test:deferred-guarded').Stage('greet')) + async greet() { + this.respond({ message: 'guarded' }); + } +} + const hooked: string[] = []; @Plugin({ name: 'greet-hooks' }) @@ -69,6 +96,8 @@ describe('deferredFlow', () => { beforeEach(async () => { hooked.length = 0; + routeGuard.mockClear(); + classGuard.mockClear(); load = jest.fn(async () => GreetFlow); const frontMcp = await FrontMcpInstance.createForGraph({ info: { name: 'deferred-flow', version: '1.0.0' }, @@ -107,6 +136,18 @@ describe('deferredFlow', () => { expect(load).not.toHaveBeenCalled(); }); + it('runs each guard of the class it loads once per request', async () => { + await scope.registryFlows( + deferredFlow({ name: 'test:deferred-guarded', middleware: { path: '/guarded' } }, async () => GuardedFlow), + ); + + await scope.findHttpFlowName(request('GET', '/guarded')); + await scope.findHttpFlowName(request('GET', '/guarded')); + + expect(routeGuard).toHaveBeenCalledTimes(2); + expect(classGuard).toHaveBeenCalledTimes(2); + }); + it('loads its class to answer a request to its route', async () => { await expect(scope.findHttpFlowName(request('POST', '/greet'))).resolves.toBe(name); diff --git a/libs/sdk/src/flows/flow.instance.ts b/libs/sdk/src/flows/flow.instance.ts index c0d26488a..55dae6f86 100644 --- a/libs/sdk/src/flows/flow.instance.ts +++ b/libs/sdk/src/flows/flow.instance.ts @@ -303,7 +303,7 @@ export class FlowInstance extends FlowEntry { if (this.method && request.method !== this.method) return false; await this.loadFlowClass(); - const canActivate = this.metadata.middleware?.canActivate ?? []; + const canActivate = [...(this.metadata.middleware?.canActivate ?? [])]; if ((this.FlowClass as any)['canActivate']) { canActivate.push((this.FlowClass as any)['canActivate']); } From 95b33fb9d5bb9361b2cbbb5762bdf21835125768 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 20:10:12 +0200 Subject: [PATCH 18/25] fix(sdk): answer a request to a deferred flow whose class fails to load with a 500 canActivate awaited the class load outside the Express middleware's error handling and before the Web fetch handler's, so a load that failed escaped as an unhandled rejection (Express 4) or a thrown fetch handler. The flow now claims its route, and its run throws the load error, which both adapters answer with a 500. --- .../src/flows/__tests__/deferred-flow.spec.ts | 31 +++++++++++++++++++ libs/sdk/src/flows/flow.instance.ts | 7 ++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/libs/sdk/src/flows/__tests__/deferred-flow.spec.ts b/libs/sdk/src/flows/__tests__/deferred-flow.spec.ts index 83907c8af..7604dc375 100644 --- a/libs/sdk/src/flows/__tests__/deferred-flow.spec.ts +++ b/libs/sdk/src/flows/__tests__/deferred-flow.spec.ts @@ -19,6 +19,7 @@ import { } from '../../common'; import { FrontMcpInstance } from '../../front-mcp/front-mcp'; import type { Scope } from '../../scope/scope.instance'; +import { runMatchingHttpFlowWeb } from '../../transport/web-fetch-handler'; import { deferredFlow } from '../deferred-flow'; const name = 'test:deferred-greet' as const; @@ -153,4 +154,34 @@ describe('deferredFlow', () => { expect(load).toHaveBeenCalledTimes(1); }); + + describe('when its class fails to load', () => { + const failingName = 'test:deferred-guarded' as const; + const failedLoad = async (): Promise => { + throw new Error('flow module failed to load'); + }; + const failing = () => deferredFlow({ name: failingName, middleware: { path: '/failing' } }, failedLoad); + + it('answers a Web request to its route with a 500', async () => { + await scope.registryFlows(failing()); + + const response = await runMatchingHttpFlowWeb(scope, new Request('http://localhost/failing')); + + expect(response?.status).toBe(500); + }); + + it('answers a request its middleware receives with a 500', async () => { + const server = scope.providers.getActiveServer(); + const register = jest.spyOn(server, 'registerMiddleware'); + await scope.registryFlows(failing()); + const handler = register.mock.calls.find(([path]) => path === '/failing')?.[1]; + const response = { setHeader: jest.fn(), status: jest.fn().mockReturnThis(), end: jest.fn() }; + const next = jest.fn(); + + await handler?.(request('GET', '/failing'), response as never, next); + + expect(response.status).toHaveBeenCalledWith(500); + expect(next).not.toHaveBeenCalled(); + }); + }); }); diff --git a/libs/sdk/src/flows/flow.instance.ts b/libs/sdk/src/flows/flow.instance.ts index 55dae6f86..ab41cc82c 100644 --- a/libs/sdk/src/flows/flow.instance.ts +++ b/libs/sdk/src/flows/flow.instance.ts @@ -301,7 +301,12 @@ export class FlowInstance extends FlowEntry { async canActivate(request: ServerRequest): Promise { if (this.method && request.method !== this.method) return false; - await this.loadFlowClass(); + try { + await this.loadFlowClass(); + } catch { + // The route is the flow's: its run throws the same error, which the adapter answers with a 500 + return true; + } const canActivate = [...(this.metadata.middleware?.canActivate ?? [])]; if ((this.FlowClass as any)['canActivate']) { From 1a4d98a364c72b0b7de57ebc752df1db6d446b11 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 20:24:30 +0200 Subject: [PATCH 19/25] test(e2e): pin the root exports main gained since this branch was cut Regenerated from main's own build (91531ebfc): 42 names added by the feature stack, none removed, no kind changed. The merged build exports the same 966 names and kinds under require() and import. --- .../e2e/fixtures/sdk-root-exports.json | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json b/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json index 60dc6d602..4250a7f7f 100644 --- a/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json +++ b/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json @@ -1,4 +1,5 @@ { + "ANSWER_COUNTERS": "object", "Adapter": "function", "AdapterEntry": "class", "AdapterKind": "object", @@ -37,8 +38,12 @@ "AuthProviderScope": "object", "AuthProvidersNotConfiguredError": "class", "AuthorizationRequiredError": "class", + "BUDGET_LIST_POLICIES": "array", + "BUDGET_META_KEY": "string", "BaseConfig": "class", "BaseEntry": "class", + "CallRecorderConfigurationError": "class", + "CallRecorderPlugin": "class", "CapabilityCache": "class", "Channel": "function", "ChannelContext": "class", @@ -132,6 +137,7 @@ "FRONTMCP_CONFIG_METADATA_KEY": "string", "FRONTMCP_CONTEXT": "symbol", "FRONTMCP_SUPPORTED_PROTOCOL_VERSIONS": "array", + "FeatureOffError": "class", "FinallyHookOf": "function", "Flow": "function", "FlowBase": "class", @@ -195,11 +201,15 @@ "GuardManager": "class", "GuardStorageUnavailableError": "class", "GuardStorageUnsupportedError": "class", + "HOOK_OBSERVER_KEY": "symbol", + "HOOK_TRACE_META_KEY": "string", "HaConfigurationError": "class", "HealthCheckManager": "class", "HealthChecker": "class", "HookEntry": "class", "HookKind": "object", + "HookOrderCycleError": "class", + "HookPhase": "object", "HookTargetNotDefinedError": "class", "HostServerAdapter": "class", "HttpBinarySchema": "object", @@ -233,10 +243,12 @@ "InvalidDecoratorMetadataError": "class", "InvalidDependencyScopeError": "class", "InvalidEntityError": "class", + "InvalidFileSignalError": "class", "InvalidHookFlowError": "class", "InvalidInputError": "class", "InvalidInstructionSourceError": "class", "InvalidMethodError": "class", + "InvalidOffloadCallError": "class", "InvalidOutputError": "class", "InvalidPluginScopeError": "class", "InvalidRegistryKindError": "class", @@ -283,6 +295,7 @@ "MANIFEST_PRIMITIVE_KEYS": "array", "MAX_CONCURRENT_TASKS_PER_SESSION": "number", "MCP_ERROR_CODES": "object", + "MIN_OUTPUT_BUDGET_BYTES": "number", "McpClientService": "class", "McpError": "class", "McpStatelessClient": "class", @@ -303,6 +316,11 @@ "OAuthRegisterHook": "object", "OAuthTokenHook": "object", "OUTPUT_SCHEMA_MODES": "array", + "OffloadAbortedError": "class", + "OffloadBudgetExceededError": "class", + "OffloadNotCloneableError": "class", + "OffloadUnsupportedRuntimeError": "class", + "OffloadWorkerError": "class", "OpenAIAdapter": "class", "OrchestratedAuthNotAvailableError": "class", "OrchestratorJwksNotAvailableError": "class", @@ -323,6 +341,7 @@ "PromptKind": "object", "PromptNotFoundError": "class", "Provider": "function", + "ProviderBootstrapError": "class", "ProviderConstructionError": "class", "ProviderDependencyError": "class", "ProviderEntry": "class", @@ -385,6 +404,7 @@ "ResourceTemplateKind": "object", "SCHEMA_DESCRIPTION_FORMATS": "array", "STATELESS_SESSION_ID": "string", + "SURFACE_META_KEY": "string", "SamplingNotAvailableError": "class", "ScopeConfigurationError": "class", "ScopeDeniedError": "class", @@ -420,12 +440,15 @@ "SocketInUseError": "class", "SocketPathConflictError": "class", "SocketPermissionError": "class", + "SqliteStorageToken": "symbol", "StageHookOf": "function", + "StaleToolListError": "class", "StorageElicitationStore": "class", "StorageTaskStore": "class", "TASKS_CLIENT_CAPABILITY": "object", "TASKS_EXTENSION_ID": "string", "TASK_DEFAULTS": "object", + "TASK_WORKER_MARKER": "string", "TASK_WORKER_MODE_FLAG": "string", "TaskAlreadyTerminalError": "class", "TaskAugmentationNotSupportedError": "class", @@ -444,6 +467,7 @@ "TokenNotAvailableError": "class", "TokenStoreRequiredError": "class", "Tool": "function", + "ToolAliasConflictError": "class", "ToolCallError": "class", "ToolContext": "class", "ToolCredentialsRequiredError": "class", @@ -517,6 +541,7 @@ "ZodVoid": "function", "acquireConcurrencySlots": "function", "adapterInitOptionsOf": "function", + "addHookObserver": "function", "agent": "function", "aiPlatformTypeSchema": "object", "allowsPublicAccess": "function", @@ -564,12 +589,14 @@ "buildStorageKey": "function", "canDeliverNotifications": "function", "captureCallerDir": "function", + "captureCallerFile": "function", "challengeHeaders": "function", "channel": "function", "channelNotificationSchema": "object", "channelSourceConfigSchema": "object", "channelsConfigSchema": "object", "clearCreateCache": "function", + "clientProfilesOptionsSchema": "object", "compareVersions": "function", "composeCallerInstructions": "function", "composeInitializeInstructions": "function", @@ -586,6 +613,7 @@ "consumePendingTC39Hooks": "function", "create": "function", "createChildSpanContext": "function", + "createContainer": "function", "createContextResolver": "function", "createDeferredServerBuild": "function", "createDirectResolver": "function", @@ -628,8 +656,10 @@ "deserializeElicitationBlob": "function", "detectConflicts": "function", "detectPlatform": "function", + "devtoolsOptionsSchema": "object", "eagerZ": "object", "elicitationOptionsSchema": "object", + "enabledOptionSchema": "object", "encryptAndSerialize": "function", "encryptElicitationData": "function", "enforceGlobalRateLimit": "function", @@ -652,7 +682,11 @@ "extractSchemaPaths": "function", "extractToolNames": "function", "federatedAuthConfigSchema": "object", + "fileSignal": "function", "filterServableSkills": "function", + "firstCallerFile": "function", + "fitResourceAnswer": "function", + "fitToolAnswer": "function", "flatRemoteProviderFields": "object", "forceMaterialize": "function", "formatMcpErrorResponse": "function", @@ -712,6 +746,7 @@ "hasSkillAuditFactory": "function", "healthOptionsSchema": "object", "healthReadyzOptionsSchema": "object", + "hookOptionsSchema": "object", "httpInputSchema": "object", "httpOptionsSchema": "object", "httpOutputSchema": "object", @@ -731,6 +766,7 @@ "isDistributedMode": "function", "isDynamicPluginClass": "function", "isElicitationEncryptionAvailable": "function", + "isEnabledSource": "function", "isEncryptedBlob": "function", "isEntryGatedBy": "function", "isFileInstructions": "function", @@ -770,6 +806,7 @@ "jwkParametersSchema": "object", "jwkSchema": "object", "lazyZ": "function", + "lifecycleOptionsSchema": "object", "loadConfig": "function", "loadEnvFiles": "function", "loadSkillDirectory": "function", @@ -800,6 +837,7 @@ "normalizeToolAuthProviders": "function", "normalizeToolRef": "function", "observabilityOptionsSchema": "object", + "offloadOptionsSchema": "object", "orchestratedLocalSchema": "object", "orchestratedRemoteSchema": "object", "outboundFetchOptionsSchema": "object", @@ -836,6 +874,7 @@ "redisOptionsSchema": "object", "redisProviderSchema": "object", "redisUrlSchema": "object", + "refused": "function", "registerOptionalModule": "function", "registerPendingTC39Hook": "function", "remoteAuthSchema": "object", @@ -843,6 +882,7 @@ "resolveDefaultSqlitePath": "function", "resolveEntryPath": "function", "resolveNameConflicts": "function", + "resolveOutputBudget": "function", "resolveOutputSchemaMode": "function", "resolvePartitionKey": "function", "resolvePendingTC39HooksForClass": "function", @@ -889,10 +929,12 @@ "skillsConfigOptionsSchema": "object", "skippedAppBehaviorSchema": "object", "sqliteOptionsSchema": "object", + "sqliteStorageProvider": "function", "startupFailureResponse": "function", "staticAuthOptionsSchema": "object", "storageProviderSchema": "object", "supportsStreaming": "function", + "surfaceHash": "function", "timeoutConfigSchema": "object", "toJSONSchema": "function", "toMcpError": "function", From 61e67ef89c01d8e1eccea6643a86ad59b9e590d3 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 21:24:52 +0200 Subject: [PATCH 20/25] perf(lazy-zod): keep what a stand-in is built from in fields, not closures internalZ made two closures and their contexts per stand-in, which cost more than the stand-in itself. A stand-in now keeps zod's factory, its namespace and the arguments (or the parent and method it was chained from) and drops the arguments once built. The public z's stand-ins make one closure instead of two. --- .../lazy-zod/src/__tests__/internal-z.spec.ts | 33 ++++++++++ libs/lazy-zod/src/internal-z.ts | 66 ++++++++++++++----- libs/lazy-zod/src/lazy-z.ts | 11 ++-- 3 files changed, 90 insertions(+), 20 deletions(-) diff --git a/libs/lazy-zod/src/__tests__/internal-z.spec.ts b/libs/lazy-zod/src/__tests__/internal-z.spec.ts index e4d061e01..0d5be74b9 100644 --- a/libs/lazy-zod/src/__tests__/internal-z.spec.ts +++ b/libs/lazy-zod/src/__tests__/internal-z.spec.ts @@ -472,4 +472,37 @@ describe('internalZ', () => { const typed: internalZ.ZodType = schema; expect(typed.parse(value)).toEqual({ a: 'x' }); }); + + it('makes no function per stand-in: two alike stand-ins hold the same functions', () => { + const functionsOf = (schema: unknown) => + Object.values(targetOf(schema)).filter((value) => typeof value === 'function'); + const builds: Array<() => unknown> = [ + () => internalZ.string(), + () => internalZ.object({ a: internalZ.number() }), + () => internalZ.string().min(1).optional(), + () => internalZ.union([internalZ.string(), internalZ.number()]).optional(), + ]; + + for (const build of builds) { + const first = functionsOf(build()); + expect(first.length).toBeGreaterThan(0); + expect(functionsOf(build())).toEqual(first); + } + }); + + it('lets a built stand-in go of the arguments it was made with', () => { + const shape = { name: internalZ.string() }; + const options = [internalZ.literal('a'), internalZ.literal('b')]; + const object = internalZ.object(shape); + const union = internalZ.union(options).optional(); + + object.parse({ name: 'x' }); + union.parse('a'); + + for (const target of [object, union].map(targetOf)) { + const held = Object.values(target).flatMap((value) => (Array.isArray(value) ? value : [value])); + expect(held).not.toContain(shape); + expect(held).not.toContain(options); + } + }); }); diff --git a/libs/lazy-zod/src/internal-z.ts b/libs/lazy-zod/src/internal-z.ts index fad0779ba..bedc9e257 100644 --- a/libs/lazy-zod/src/internal-z.ts +++ b/libs/lazy-zod/src/internal-z.ts @@ -2,37 +2,71 @@ import { z as realZ, type ZodTypeAny } from 'zod'; import { HEAVY_FACTORIES, LEAF_FACTORIES } from './factories'; import { LAZY_TARGET, LazyZodSchema, wrapLazy } from './lazy-schema'; -import { createLazyZ, type StandInFactory, type z } from './lazy-z'; +import { createLazyZ, type SchemaFactory, type StandInFactory, type z } from './lazy-z'; import { withoutRecursionWalk } from './memoizer'; import { isLazy } from './utils'; const BUILT_WITH_PARENT: unique symbol = Symbol('builtWithParent'); -class InternalLazySchema extends LazyZodSchema { +const NO_ARGS: readonly unknown[] = Object.freeze([]); + +/** A stand-in keeps what it is built from in fields: a closure per stand-in would cost more than the stand-in. */ +abstract class InternalLazySchema extends LazyZodSchema { readonly [BUILT_WITH_PARENT]: boolean; + private args: readonly unknown[]; - constructor(factory: () => ZodTypeAny, builtWithParent: boolean) { - super(factory); + protected constructor(args: readonly unknown[], builtWithParent: boolean) { + super(InternalLazySchema.buildOnce); + this.args = args; this[BUILT_WITH_PARENT] = builtWithParent; } + private static buildOnce(this: InternalLazySchema): ZodTypeAny { + const args = this.args; + this.args = NO_ARGS; + return this.buildWith(args); + } + + protected abstract buildWith(args: readonly unknown[]): ZodTypeAny; + protected override build(factory: () => ZodTypeAny): ZodTypeAny { - return withoutRecursionWalk(factory); + return withoutRecursionWalk(() => factory.call(this)); } override chain(method: string, args: unknown[]): unknown { - return internalStandIn( - () => { - const real = this.materialize() as unknown as Record ZodTypeAny>; - return real[method](...args.map(withBuiltChildren)); - }, - this[BUILT_WITH_PARENT] && args.every(holdsOnlyLeaves), - ); + return wrapLazy(new ChainedStandIn(this, method, args, this[BUILT_WITH_PARENT] && args.every(holdsOnlyLeaves))); + } +} + +class FactoryStandIn extends InternalLazySchema { + constructor( + private readonly factory: SchemaFactory, + private readonly namespace: object, + args: readonly unknown[], + builtWithParent: boolean, + ) { + super(args, builtWithParent); + } + + protected buildWith(args: readonly unknown[]): ZodTypeAny { + return this.factory.apply(this.namespace, args.map(withBuiltChildren)); } } -function internalStandIn(build: () => ZodTypeAny, builtWithParent: boolean): unknown { - return wrapLazy(new InternalLazySchema(build, builtWithParent)); +class ChainedStandIn extends InternalLazySchema { + constructor( + private readonly parent: InternalLazySchema, + private readonly method: string, + args: readonly unknown[], + builtWithParent: boolean, + ) { + super(args, builtWithParent); + } + + protected buildWith(args: readonly unknown[]): ZodTypeAny { + const real = this.parent.materialize() as unknown as Record; + return real[this.method](...args.map(withBuiltChildren)); + } } function targetOf(value: unknown): LazyZodSchema | undefined { @@ -85,8 +119,8 @@ function withBuiltChildren(arg: unknown): unknown { return changed ? Object.create(Object.getPrototypeOf(arg), descriptors) : arg; } -const deferEveryFactory: StandInFactory = (name, args, build) => - internalStandIn(() => build(args.map(withBuiltChildren)), LEAF_FACTORIES.has(name) && args.every(holdsOnlyLeaves)); +const deferEveryFactory: StandInFactory = (name, args, factory, namespace) => + wrapLazy(new FactoryStandIn(factory, namespace, args, LEAF_FACTORIES.has(name) && args.every(holdsOnlyLeaves))); /** FrontMCP's own `z`: every factory returns a stand-in that builds its leaf children with it. Not public API. */ export const internalZ: typeof realZ = createLazyZ( diff --git a/libs/lazy-zod/src/lazy-z.ts b/libs/lazy-zod/src/lazy-z.ts index 9268737bd..7abc7c317 100644 --- a/libs/lazy-zod/src/lazy-z.ts +++ b/libs/lazy-zod/src/lazy-z.ts @@ -19,9 +19,12 @@ import { LazyZodSchema, wrapLazy } from './lazy-schema'; * target owns nothing, so no invariant applies; every trap forwards to * `namespace` instead. Exported for tests — the public entry point is `z`. */ -export type StandInFactory = (name: string, args: unknown[], build: (args: unknown[]) => ZodTypeAny) => unknown; +export type SchemaFactory = (...args: unknown[]) => ZodTypeAny; -const compoundStandIn: StandInFactory = (_name, args, build) => wrapLazy(new LazyZodSchema(() => build(args))); +export type StandInFactory = (name: string, args: unknown[], factory: SchemaFactory, namespace: object) => unknown; + +const compoundStandIn: StandInFactory = (_name, args, factory, namespace) => + wrapLazy(new LazyZodSchema(() => factory.apply(namespace, args))); export function createLazyZ( namespace: typeof realZ, @@ -31,8 +34,8 @@ export function createLazyZ( return new Proxy({} as typeof realZ, { get(_target, key, receiver) { if (typeof key === 'string' && factories.has(key)) { - const realFactory = (namespace as unknown as Record ZodTypeAny>)[key]; - return (...args: unknown[]) => standIn(key, args, (built) => realFactory.apply(namespace, built)); + const realFactory = (namespace as unknown as Record)[key]; + return (...args: unknown[]) => standIn(key, args, realFactory, namespace); } return Reflect.get(namespace, key, receiver); }, From f43ad8c9258fc33c252d4edc1bfabfd06ec38b8c Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 22:52:58 +0200 Subject: [PATCH 21/25] fix(lazy-zod): drop a stand-in's arguments and parent only once it has built A stand-in cleared its arguments before building, so a build that threw left it empty: the next parse built the schema without them (an internalZ.object whose build threw became z.object() and accepted any object). It now drops them after a successful build, as the closure was dropped before. A chained stand-in also kept its parent stand-in, and the parent's built schema, for as long as it lived. It now drops the parent once built. --- .../lazy-zod/src/__tests__/internal-z.spec.ts | 20 +++++++++++++++++++ libs/lazy-zod/src/internal-z.ts | 15 +++++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/libs/lazy-zod/src/__tests__/internal-z.spec.ts b/libs/lazy-zod/src/__tests__/internal-z.spec.ts index 0d5be74b9..76930a1b7 100644 --- a/libs/lazy-zod/src/__tests__/internal-z.spec.ts +++ b/libs/lazy-zod/src/__tests__/internal-z.spec.ts @@ -505,4 +505,24 @@ describe('internalZ', () => { expect(held).not.toContain(options); } }); + + it('lets a built chained stand-in go of the stand-in it was chained on', () => { + const parent = internalZ.object({ a: internalZ.string() }); + const extended = parent.extend({ b: internalZ.number() }); + + extended.parse({ a: 'x', b: 1 }); + + expect(Object.values(targetOf(extended))).not.toContain(targetOf(parent)); + }); + + it('builds from the same arguments when a build that threw is tried again', () => { + const shape = { a: internalZ.string() }; + const conflicting = { message: 'm', error: 'e' }; + const object = internalZ.object(shape, conflicting); + const optional = internalZ.object(shape, conflicting).optional(); + + for (const schema of [object, optional, object, optional]) { + expect(() => schema.parse({ a: 1 })).toThrow('Cannot specify both `message` and `error` params'); + } + }); }); diff --git a/libs/lazy-zod/src/internal-z.ts b/libs/lazy-zod/src/internal-z.ts index bedc9e257..0ad04ff86 100644 --- a/libs/lazy-zod/src/internal-z.ts +++ b/libs/lazy-zod/src/internal-z.ts @@ -22,9 +22,9 @@ abstract class InternalLazySchema extends LazyZodSchema { } private static buildOnce(this: InternalLazySchema): ZodTypeAny { - const args = this.args; + const built = this.buildWith(this.args); this.args = NO_ARGS; - return this.buildWith(args); + return built; } protected abstract buildWith(args: readonly unknown[]): ZodTypeAny; @@ -54,18 +54,23 @@ class FactoryStandIn extends InternalLazySchema { } class ChainedStandIn extends InternalLazySchema { + private parent: InternalLazySchema | undefined; + constructor( - private readonly parent: InternalLazySchema, + parent: InternalLazySchema, private readonly method: string, args: readonly unknown[], builtWithParent: boolean, ) { super(args, builtWithParent); + this.parent = parent; } protected buildWith(args: readonly unknown[]): ZodTypeAny { - const real = this.parent.materialize() as unknown as Record; - return real[this.method](...args.map(withBuiltChildren)); + const real = (this.parent as InternalLazySchema).materialize() as unknown as Record; + const built = real[this.method](...args.map(withBuiltChildren)); + this.parent = undefined; + return built; } } From dae7555267dc4977d71453995a095a7f367bf2a1 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Tue, 3 Nov 2026 09:37:57 +0200 Subject: [PATCH 22/25] test(e2e): pin the root exports main gained from the feature stack Regenerated from main's own build (998ca63ab): 76 names added, none removed, no kind changed. This branch's CommonJS and ESM builds export exactly the same names and kinds. --- .../e2e/fixtures/sdk-root-exports.json | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json b/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json index 4250a7f7f..9ece5e7ab 100644 --- a/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json +++ b/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json @@ -44,6 +44,7 @@ "BaseEntry": "class", "CallRecorderConfigurationError": "class", "CallRecorderPlugin": "class", + "CancelJobTool": "class", "CapabilityCache": "class", "Channel": "function", "ChannelContext": "class", @@ -72,13 +73,16 @@ "Context": "class", "ContextExtensionNotAvailableError": "class", "CredentialProviderAlreadyRegisteredError": "class", + "CrossOriginRequestError": "class", "DEFAULT_ELICITATION_OPTIONS": "object", "DEFAULT_ELICIT_TTL": "number", "DEFAULT_FALLBACK_WAIT_TTL": "number", "DEFAULT_HEALTH_OPTIONS": "object", + "DEFAULT_INSTRUCTIONS_BUDGET": "object", "DEFAULT_METRICS_OPTIONS": "object", "DEFAULT_NPM_REGISTRY": "string", "DEFAULT_OUTPUT_SCHEMA_MODE": "string", + "DEFAULT_QUESTION_TTL_MS": "number", "DEFAULT_REGISTRY_PUBLIC_KEY_ENV": "string", "DEFAULT_SCHEMA_DESCRIPTION_FORMAT": "string", "DEFAULT_TOOL_PAGINATION": "object", @@ -87,6 +91,8 @@ "DidHookOf": "function", "DistributedSemaphore": "class", "DuplicateAppIdError": "class", + "Duty": "function", + "DutyNameConflictError": "class", "DynamicAdapter": "class", "DynamicAdapterNameError": "class", "DynamicJobDirectExecutionError": "class", @@ -136,6 +142,7 @@ "ExternalSkillProviderBase": "class", "FRONTMCP_CONFIG_METADATA_KEY": "string", "FRONTMCP_CONTEXT": "symbol", + "FRONTMCP_LOGGER": "symbol", "FRONTMCP_SUPPORTED_PROTOCOL_VERSIONS": "array", "FeatureOffError": "class", "FinallyHookOf": "function", @@ -203,6 +210,7 @@ "GuardStorageUnsupportedError": "class", "HOOK_OBSERVER_KEY": "symbol", "HOOK_TRACE_META_KEY": "string", + "HTTP_ROUTE_PATH_MESSAGE": "string", "HaConfigurationError": "class", "HealthCheckManager": "class", "HealthChecker": "class", @@ -228,6 +236,9 @@ "HttpProblemSchema": "object", "HttpRedirectSchema": "object", "HttpRedirectStatus": "object", + "HttpRoute": "function", + "HttpRouteContext": "class", + "HttpRouteHook": "object", "HttpSseSchema": "object", "HttpStatus": "object", "HttpStreamSchema": "object", @@ -245,15 +256,20 @@ "InvalidEntityError": "class", "InvalidFileSignalError": "class", "InvalidHookFlowError": "class", + "InvalidHttpRouteError": "class", "InvalidInputError": "class", "InvalidInstructionSourceError": "class", "InvalidMethodError": "class", "InvalidOffloadCallError": "class", "InvalidOutputError": "class", + "InvalidPeopleAuthOptionsError": "class", "InvalidPluginScopeError": "class", + "InvalidQuestionStoreError": "class", "InvalidRegistryKindError": "class", + "InvalidResourceStoreError": "class", "InvalidResourceUriError": "class", "InvalidRetryOptionsError": "class", + "InvalidSharedServerOptionsError": "class", "InvalidSkillError": "class", "InvalidTransportSessionError": "class", "InvalidUseClassError": "class", @@ -263,21 +279,34 @@ "IpBlockedError": "class", "IpFilter": "class", "IpNotAllowedError": "class", + "JOB_WORKER_MARKER": "string", "JSON_RPC": "string", "Job": "function", + "JobAdmissionTimeoutError": "class", + "JobCancelledError": "class", "JobContext": "class", "JobEmitter": "class", "JobEntry": "class", "JobExecutionManager": "class", "JobHook": "object", "JobInstance": "class", + "JobInterruptedError": "class", "JobKind": "object", "JobNotAuthorizedError": "class", "JobPermissionGuard": "class", "JobRegistry": "class", + "JobRunFinishedError": "class", + "JobRunNotCancellableError": "class", + "JobRunNotFoundError": "class", + "JobRunResource": "class", + "JobRunnerBootError": "class", + "JobRunnerUnavailableError": "class", + "JobTimeoutError": "class", "JwtSecretRequiredError": "class", "JwtSecretWeakError": "class", "LLM_ADAPTER": "symbol", + "LazyAppConfigurationError": "class", + "LazyAppLoadError": "class", "LazyZodSchema": "class", "ListJobsTool": "class", "ListPromptsHook": "object", @@ -292,10 +321,14 @@ "LoggerEntry": "class", "LoggerKind": "object", "LoggerRegistry": "class", + "LoopbackTokenAuth": "class", "MANIFEST_PRIMITIVE_KEYS": "array", + "MATERIALIZED_META_KEY": "string", "MAX_CONCURRENT_TASKS_PER_SESSION": "number", + "MAX_JOB_TIMER_MS": "number", "MCP_ERROR_CODES": "object", "MIN_OUTPUT_BUDGET_BYTES": "number", + "MaterializedResourceContext": "class", "McpClientService": "class", "McpError": "class", "McpStatelessClient": "class", @@ -324,11 +357,13 @@ "OpenAIAdapter": "class", "OrchestratedAuthNotAvailableError": "class", "OrchestratorJwksNotAvailableError": "class", + "PEOPLE_SESSION_COOKIE": "string", "PLATFORM_CLIENT_INFO": "object", "PRIMITIVE_FILTER_KEYS": "array", "PROTOCOL_2026_07_28": "string", "PROTOCOL_PRESETS": "object", "PayloadTooLargeError": "class", + "PersonNotSignedInError": "class", "Plugin": "function", "PluginDependencyError": "class", "PluginEntry": "class", @@ -355,6 +390,15 @@ "ProviderScopedAccessError": "class", "PublicAccessDeniedError": "class", "PublicMcpError": "class", + "QUESTIONS_PATH": "string", + "QUESTIONS_TOPIC": "string", + "QUESTION_INBOX_PATH": "string", + "QuestionAlreadyAnsweredError": "class", + "QuestionNotFoundError": "class", + "QuestionPendingError": "class", + "QuestionStore": "class", + "QuestionStoreBusyError": "class", + "QuestionSupersededError": "class", "QueueTimeoutError": "class", "QuotaExceededError": "class", "REDIRECTS": "array", @@ -400,9 +444,12 @@ "ResourceNotFoundError": "class", "ResourceOutputSchema": "object", "ResourceReadError": "class", + "ResourceStore": "class", "ResourceTemplate": "function", "ResourceTemplateKind": "object", "SCHEMA_DESCRIPTION_FORMATS": "array", + "SERVER_MANIFEST_FILE": "string", + "SERVER_MANIFEST_VERSION": "number", "STATELESS_SESSION_ID": "string", "SURFACE_META_KEY": "string", "SamplingNotAvailableError": "class", @@ -423,6 +470,10 @@ "SessionOwnerUnreachableError": "class", "SessionSecretRequiredError": "class", "SessionVerificationFailedError": "class", + "SharedServerIdentityError": "class", + "SharedServerStartError": "class", + "SignInLockedError": "class", + "SignInRefusedError": "class", "Skill": "function", "SkillContext": "class", "SkillContextNotImplementedError": "class", @@ -488,6 +539,8 @@ "UNDECLARED_KEYS_POLICIES": "array", "UnauthorizedError": "class", "UnenforcedMetadataError": "class", + "UnsafeDutyStoreError": "class", + "UnsafeSharedServerDirectoryError": "class", "UnsafeSocketDirectoryError": "class", "UnsupportedClientVersionError": "class", "UnsupportedContentTypeError": "class", @@ -578,6 +631,7 @@ "buildEsmResourceRecord": "function", "buildEsmShUrl": "function", "buildEsmToolRecord": "function", + "buildManifest": "function", "buildParamHeaders": "function", "buildPartitionContext": "function", "buildPersistentWebStandardMcp": "function", @@ -632,6 +686,7 @@ "createMemoryElicitationStore": "function", "createMemorySkillProvider": "function", "createMemoryTaskStore": "function", + "createQuestionStore": "function", "createRemotePromptContextClass": "function", "createRemotePromptInstance": "function", "createRemoteResourceContextClass": "function", @@ -650,6 +705,8 @@ "deepMerge": "function", "default": "object", "defaultHttpPort": "function", + "defineDutyMetadata": "function", + "defineHttpRouteMetadata": "function", "deriveElicitationKey": "function", "describeConfigIssues": "function", "deserializeAndDecrypt": "function", @@ -657,6 +714,8 @@ "detectConflicts": "function", "detectPlatform": "function", "devtoolsOptionsSchema": "object", + "dutyMetadataOf": "function", + "dutyMetadataSchema": "object", "eagerZ": "object", "elicitationOptionsSchema": "object", "enabledOptionSchema": "object", @@ -684,6 +743,7 @@ "federatedAuthConfigSchema": "object", "fileSignal": "function", "filterServableSkills": "function", + "findBudgetOverruns": "function", "firstCallerFile": "function", "fitResourceAnswer": "function", "fitToolAnswer": "function", @@ -752,8 +812,10 @@ "httpOutputSchema": "object", "httpRequestInputSchema": "object", "httpRespond": "object", + "httpRouteMetadataOf": "function", "incrementalAuthConfigSchema": "object", "initOptionsOf": "function", + "instructionsBudgetOptionsSchema": "object", "intentSchema": "object", "ipFilterConfigSchema": "object", "isAlive": "function", @@ -770,6 +832,7 @@ "isEncryptedBlob": "function", "isEntryGatedBy": "function", "isFileInstructions": "function", + "isHttpRoutePath": "function", "isInlineInstructions": "function", "isLazy": "function", "isLocalMode": "function", @@ -787,6 +850,7 @@ "isPubsubConfigured": "function", "isRedisProvider": "function", "isRemoteMode": "function", + "isResourceKey": "function", "isSkillDecorated": "function", "isSkillRecord": "function", "isStaticMode": "function", @@ -801,12 +865,15 @@ "isValidVersion": "function", "isVercelKvProvider": "function", "job": "function", + "jobRunnerSchema": "object", "joinPath": "function", "jsonWebKeySetSchema": "object", "jwkParametersSchema": "object", "jwkSchema": "object", + "lazyAppMetadataSchema": "object", "lazyZ": "function", "lifecycleOptionsSchema": "object", + "lintServerManifest": "function", "loadConfig": "function", "loadEnvFiles": "function", "loadSkillDirectory": "function", @@ -865,11 +932,14 @@ "platformMappingEntrySchema": "object", "populateProcessEnv": "function", "previewItems": "function", + "printQuestionInboxLink": "function", "prompt": "function", "providerConfigSchema": "object", "publicAccessConfigSchema": "object", "publicAuthOptionsSchema": "object", "pubsubOptionsSchema": "object", + "questionInboxRoutes": "function", + "questionSubjectOf": "function", "rateLimitConfigSchema": "object", "redisOptionsSchema": "object", "redisProviderSchema": "object", @@ -881,12 +951,14 @@ "remoteProviderConfigSchema": "object", "resolveDefaultSqlitePath": "function", "resolveEntryPath": "function", + "resolveInstructionsBudget": "function", "resolveNameConflicts": "function", "resolveOutputBudget": "function", "resolveOutputSchemaMode": "function", "resolvePartitionKey": "function", "resolvePendingTC39HooksForClass": "function", "resolvePrincipal": "function", + "resolveQuestionStorage": "function", "resolveRegistryToken": "function", "resolveSchemaDescriptionFormat": "function", "resolveToolVisibility": "function", @@ -894,6 +966,8 @@ "resolveWithFallbacks": "function", "resolvesToVercelKvTaskBackend": "function", "resource": "function", + "resourceKeySchema": "object", + "resourceStore": "function", "resourceTemplate": "function", "resourceUriMatches": "function", "runHttpRequestFlowWeb": "function", @@ -909,6 +983,7 @@ "serializeElicitationBlob": "function", "serializeHttpCookie": "function", "serverInfoOptionsSchema": "object", + "serverManifestSchema": "object", "sessionIdPayloadSchema": "object", "sessionIdPresentedBy": "function", "sessionIdSchema": "object", @@ -936,6 +1011,7 @@ "supportsStreaming": "function", "surfaceHash": "function", "timeoutConfigSchema": "object", + "timerLimitMessage": "function", "toJSONSchema": "function", "toMcpError": "function", "toRegistryAuthRecord": "function", From 701508fbaf1f124a4de9acb4ba61cc4530d8c14c Mon Sep 17 00:00:00 2001 From: David Antoon Date: Tue, 3 Nov 2026 09:56:27 +0200 Subject: [PATCH 23/25] fix(sdk): ship the JavaScript of the transport, job/tools and workflow/tools subpath exports The published exports map offers @frontmcp/sdk/transport, /job/tools and /workflow/tools, but the build emitted only their declarations, so require() threw MODULE_NOT_FOUND and import threw ERR_MODULE_NOT_FOUND. The build now writes each subpath's require and import files as a re-export of the package root, with the names its source barrel exports, so a subpath hands back the root's own classes from whichever root build the importer resolves, and never a second copy of the SDK. The root entry exports the seven values only ./transport had: the recreateable and SSE transports and the event stores, which the CommonJS build loads on first read, and buildScopedServerOptions. --- .../e2e/fixtures/sdk-root-exports.json | 7 ++ .../e2e/helpers/subpath-exports.mjs | 12 +++ .../e2e/sdk-lazy-subsystems.e2e.spec.ts | 6 +- .../e2e/sdk-subpath-exports.e2e.spec.ts | 89 +++++++++++++++++++ .../fixture/subpath-imports.mjs | 4 + libs/sdk/project.json | 2 + .../sdk/src/__tests__/subpath-exports.spec.ts | 15 ++++ libs/sdk/src/index.ts | 11 +++ libs/sdk/src/lazy-subsystems.ts | 10 +++ libs/sdk/src/subsystems.ts | 4 + libs/sdk/src/transport/event-stores/index.ts | 4 +- .../references/configure-transport.md | 2 + .../references/create-job.md | 2 +- scripts/build-subpath-reexports.mjs | 47 ++++++++++ 14 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 apps/e2e/demo-e2e-bundle-size/e2e/helpers/subpath-exports.mjs create mode 100644 apps/e2e/demo-e2e-bundle-size/e2e/sdk-subpath-exports.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-bundle-size/fixture/subpath-imports.mjs create mode 100644 libs/sdk/src/__tests__/subpath-exports.spec.ts create mode 100644 scripts/build-subpath-reexports.mjs diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json b/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json index 9ece5e7ab..2a156a64b 100644 --- a/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json +++ b/apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json @@ -334,6 +334,7 @@ "McpStatelessClient": "class", "McpStatelessClientAdapter": "class", "McpStatelessError": "class", + "MemoryEventStore": "class", "MemorySkillProvider": "class", "MethodNotImplementedError": "class", "MissingClientCapabilityError": "class", @@ -407,6 +408,9 @@ "RUN_TASK_ENV_VAR": "string", "RUN_TASK_SUBCOMMAND": "string", "RateLimitError": "class", + "RecreateableSSEServerTransport": "class", + "RecreateableStreamableHTTPServerTransport": "class", + "RedisEventStore": "class", "RegisterJobTool": "class", "RegisterWorkflowTool": "class", "RegistryDefinitionNotFoundError": "class", @@ -450,6 +454,7 @@ "SCHEMA_DESCRIPTION_FORMATS": "array", "SERVER_MANIFEST_FILE": "string", "SERVER_MANIFEST_VERSION": "number", + "SSEServerTransport": "class", "STATELESS_SESSION_ID": "string", "SURFACE_META_KEY": "string", "SamplingNotAvailableError": "class", @@ -639,6 +644,7 @@ "buildRemoteResourceRecord": "function", "buildRemoteResourceTemplateRecord": "function", "buildRemoteToolRecord": "function", + "buildScopedServerOptions": "function", "buildSkillsCatalogSummary": "function", "buildStorageKey": "function", "canDeliverNotifications": "function", @@ -680,6 +686,7 @@ "createEsmResourceInstance": "function", "createEsmToolContextClass": "function", "createEsmToolInstance": "function", + "createEventStore": "function", "createExtAppsMessageHandler": "function", "createGuardManager": "function", "createInMemoryServer": "function", diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/helpers/subpath-exports.mjs b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/subpath-exports.mjs new file mode 100644 index 000000000..1fbe0f14c --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/subpath-exports.mjs @@ -0,0 +1,12 @@ +// Loads @frontmcp/sdk and one of its subpaths from the project this file is copied into, with require() or import(), and +// prints, as JSON, the subpath's export names and those not the root's: node subpath-exports.mjs +import { createRequire } from 'node:module'; + +const [subpath, loader] = process.argv.slice(2); +const load = loader === 'require' ? createRequire(import.meta.url) : (specifier) => import(specifier); +const root = await load('@frontmcp/sdk'); +const subpathExports = await load(`@frontmcp/sdk/${subpath}`); + +const names = Object.keys(subpathExports).sort(); +const notFromRoot = names.filter((name) => subpathExports[name] === undefined || subpathExports[name] !== root[name]); +process.stdout.write(JSON.stringify({ names, notFromRoot })); diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/sdk-lazy-subsystems.e2e.spec.ts b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-lazy-subsystems.e2e.spec.ts index 0d9a2e377..cf55fb21d 100644 --- a/apps/e2e/demo-e2e-bundle-size/e2e/sdk-lazy-subsystems.e2e.spec.ts +++ b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-lazy-subsystems.e2e.spec.ts @@ -11,7 +11,8 @@ import { workspaceRoot } from './helpers/browser-bundle'; type Step = { step: string; initialized: string[] }; -const SUBSYSTEM_MODULE = /^libs\/sdk\/src\/(job|workflow|channel|remote-mcp|esm-loader)\//; +const SUBSYSTEM_MODULE = + /^libs\/sdk\/src\/((job|workflow|channel|remote-mcp|esm-loader)\/|transport\/(event-stores\/|adapters\/(base-sse|sse|streamable-http)-transport\.ts))/; // Helpers the startup path itself uses: the decorators parse `Tool.esm()` specifiers, the scope normalizes app entries const STARTUP_HELPERS = [ 'libs/sdk/src/esm-loader/package-specifier.ts', @@ -48,6 +49,9 @@ describe('the subsystems of the SDK under require()', () => { ['ChannelRegistry', 'libs/sdk/src/channel/channel.registry.ts'], ['McpClientService', 'libs/sdk/src/remote-mcp/mcp-client.service.ts'], ['EsmModuleLoader', 'libs/sdk/src/esm-loader/esm-module-loader.ts'], + ['RecreateableStreamableHTTPServerTransport', 'libs/sdk/src/transport/adapters/streamable-http-transport.ts'], + ['SSEServerTransport', 'libs/sdk/src/transport/adapters/base-sse-transport.ts'], + ['createEventStore', 'libs/sdk/src/transport/event-stores/event-store.factory.ts'], ])('are set up when %s is read', (exportName, source) => { const [, read] = initializedModules(`read:${exportName}`); diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/sdk-subpath-exports.e2e.spec.ts b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-subpath-exports.e2e.spec.ts new file mode 100644 index 000000000..3d1da0b4b --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/e2e/sdk-subpath-exports.e2e.spec.ts @@ -0,0 +1,89 @@ +/** + * The published exports map of @frontmcp/sdk names only files its build emits, and each subpath hands back the root + * entry's own classes and functions: under require() and import in Node, and in a browser bundle. + * + * Needs the packages built: `nx run-many -t build -p sdk plugin-webmcp`. + */ +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { copyFile, fileExists, mkdir, mkdtemp, readFileSync, rm, symlink } from '@frontmcp/utils'; + +import { bundleForBrowser, fixtureDir, inputSizes, SERVER_MODULES, workspaceRoot } from './helpers/browser-bundle'; + +type Loader = 'require' | 'import'; + +const sdkDist = resolve(workspaceRoot, 'libs/sdk/dist'); +const exportsMap: Record = JSON.parse(readFileSync(join(sdkDist, 'package.json'))).exports; +const SUBPATHS = Object.keys(exportsMap) + .filter((key) => key !== '.' && key !== './package.json' && exportsMap[key] !== null) + .map((key) => key.slice('./'.length)); + +function exportTargets(entry: unknown): string[] { + if (typeof entry === 'string') return [entry]; + if (entry === null || typeof entry !== 'object') return []; + return Object.values(entry).flatMap(exportTargets); +} + +describe('the exports map of the published @frontmcp/sdk', () => { + it('names only files its build emits', async () => { + const targets = exportTargets(exportsMap); + const missing: string[] = []; + for (const target of targets) { + if (!(await fileExists(join(sdkDist, target)))) missing.push(target); + } + + expect(targets.length).toBeGreaterThan(0); + expect(missing).toEqual([]); + }); +}); + +describe('a subpath of @frontmcp/sdk installed in a project', () => { + let project: string; + + beforeAll(async () => { + project = await mkdtemp(join(tmpdir(), 'frontmcp-sdk-subpaths-')); + await mkdir(join(project, 'node_modules', '@frontmcp'), { recursive: true }); + await symlink(sdkDist, join(project, 'node_modules', '@frontmcp', 'sdk'), 'dir'); + await copyFile(resolve(__dirname, 'helpers', 'subpath-exports.mjs'), join(project, 'subpath-exports.mjs')); + }); + + afterAll(async () => { + if (project) await rm(project, { recursive: true, force: true }); + }); + + function subpathExports(subpath: string, loader: Loader): { names: string[]; notFromRoot: string[] } { + return JSON.parse( + execFileSync(process.execPath, [join(project, 'subpath-exports.mjs'), subpath, loader], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }), + ); + } + + describe.each(SUBPATHS)('@frontmcp/sdk/%s', (subpath) => { + it.each(['require', 'import'])("hands back the root entry's own values under %s", (loader) => { + const { names, notFromRoot } = subpathExports(subpath, loader); + + expect(names.length).toBeGreaterThan(0); + expect(notFromRoot).toEqual([]); + }); + + it('exports the same names under require and import', () => { + expect(subpathExports(subpath, 'import').names).toEqual(subpathExports(subpath, 'require').names); + }); + }); +}); + +describe('a browser bundle of the subpaths of @frontmcp/sdk', () => { + it('takes their values from the browser build the root entry comes from', async () => { + const bundle = await bundleForBrowser(resolve(fixtureDir, 'subpath-imports.mjs'), SERVER_MODULES); + const sdkInputs = inputSizes(bundle.metafile) + .map(([input]) => input) + .filter((input) => input.startsWith('libs/sdk/dist/')); + + expect(sdkInputs).toContain('libs/sdk/dist/browser/job/tools/execute-job.tool.mjs'); + expect(sdkInputs.filter((input) => !input.startsWith('libs/sdk/dist/browser/'))).toEqual([]); + }); +}); diff --git a/apps/e2e/demo-e2e-bundle-size/fixture/subpath-imports.mjs b/apps/e2e/demo-e2e-bundle-size/fixture/subpath-imports.mjs new file mode 100644 index 000000000..ded892825 --- /dev/null +++ b/apps/e2e/demo-e2e-bundle-size/fixture/subpath-imports.mjs @@ -0,0 +1,4 @@ +export { ExecuteJobTool } from '@frontmcp/sdk/job/tools'; +export { ExecuteWorkflowTool } from '@frontmcp/sdk/workflow/tools'; +export { createEventStore } from '@frontmcp/sdk/transport'; +export { ToolContext } from '@frontmcp/sdk'; diff --git a/libs/sdk/project.json b/libs/sdk/project.json index 9ef6533b9..037fe3737 100644 --- a/libs/sdk/project.json +++ b/libs/sdk/project.json @@ -137,10 +137,12 @@ "build": { "executor": "nx:run-commands", "dependsOn": ["build-cjs", "build-esm", "build-browser", "build-browser-esm"], + "inputs": ["production", "^production", "{workspaceRoot}/scripts/build-subpath-reexports.mjs"], "options": { "commands": [ "npx esbuild libs/sdk/src/esm-loader/import-computed.ts --format=cjs --platform=node --outfile=libs/sdk/dist/esm-loader/import-computed.js", "npx esbuild libs/sdk/src/esm-loader/import-computed.ts --format=esm --platform=node --outfile=libs/sdk/dist/esm/esm-loader/import-computed.mjs", + "node scripts/build-subpath-reexports.mjs libs/sdk", "node scripts/strip-dist-from-pkg.js libs/sdk/dist/package.json", "node scripts/inject-esm-require-banner.js libs/sdk/dist/esm libs/sdk/dist/browser", "node scripts/escape-non-ascii.mjs libs/sdk/dist" diff --git a/libs/sdk/src/__tests__/subpath-exports.spec.ts b/libs/sdk/src/__tests__/subpath-exports.spec.ts new file mode 100644 index 000000000..61a1fc92f --- /dev/null +++ b/libs/sdk/src/__tests__/subpath-exports.spec.ts @@ -0,0 +1,15 @@ +// The build writes each subpath export of the package as a re-export of the root entry, so the root exports all its values +describe.each([ + ['./transport', () => import('../transport')], + ['./job/tools', () => import('../job/tools')], + ['./workflow/tools', () => import('../workflow/tools')], +])('the %s subpath', (_subpath, loadSubpath) => { + it('exports only values the root entry exports', async () => { + const root: Record = await import('../index'); + const subpathExports: Record = await loadSubpath(); + + const names = Object.keys(subpathExports); + expect(names.length).toBeGreaterThan(0); + expect(names.filter((name) => root[name] !== subpathExports[name])).toEqual([]); + }); +}); diff --git a/libs/sdk/src/index.ts b/libs/sdk/src/index.ts index d394692e6..4b969a098 100644 --- a/libs/sdk/src/index.ts +++ b/libs/sdk/src/index.ts @@ -766,6 +766,17 @@ export type { PersistentSessionOwnerStore } from './transport/persistent-session // Transport types export type { TransportType, TransportKey } from './transport/transport.types'; export { STATELESS_SESSION_ID } from './transport/transport.types'; +export { buildScopedServerOptions } from './transport/build-scoped-server-options'; + +// The transports and SSE event stores `@frontmcp/sdk/transport` also exports, which the CommonJS build loads on first use +export { + RecreateableStreamableHTTPServerTransport, + RecreateableSSEServerTransport, + SSEServerTransport, + createEventStore, + MemoryEventStore, + RedisEventStore, +} from './subsystems'; // ───────────────────────────────────────────────────────────────────────────── // MCP Apps (ext-apps) Support diff --git a/libs/sdk/src/lazy-subsystems.ts b/libs/sdk/src/lazy-subsystems.ts index 659c9d1e0..f0aa7c6cb 100644 --- a/libs/sdk/src/lazy-subsystems.ts +++ b/libs/sdk/src/lazy-subsystems.ts @@ -89,6 +89,16 @@ () => require('./workflow/tools'), ['ExecuteWorkflowTool', 'GetWorkflowStatusTool', 'ListWorkflowsTool', 'RegisterWorkflowTool', 'RemoveWorkflowTool'], ); + defineLazyExports( + () => require('./transport/adapters/streamable-http-transport'), + ['RecreateableStreamableHTTPServerTransport'], + ); + defineLazyExports(() => require('./transport/adapters/sse-transport'), ['RecreateableSSEServerTransport']); + defineLazyExports(() => require('#sse-transport'), ['SSEServerTransport']); + defineLazyExports( + () => require('./transport/event-stores'), + ['createEventStore', 'MemoryEventStore', 'RedisEventStore'], + ); defineLazyExports( () => require('./channel'), [ diff --git a/libs/sdk/src/subsystems.ts b/libs/sdk/src/subsystems.ts index f8eedce9b..b6ab25a87 100644 --- a/libs/sdk/src/subsystems.ts +++ b/libs/sdk/src/subsystems.ts @@ -19,6 +19,10 @@ export { RegisterWorkflowTool, RemoveWorkflowTool, } from './workflow/tools'; +export { RecreateableStreamableHTTPServerTransport } from './transport/adapters/streamable-http-transport'; +export { RecreateableSSEServerTransport } from './transport/adapters/sse-transport'; +export { SSEServerTransport } from '#sse-transport'; +export { createEventStore, MemoryEventStore, RedisEventStore } from './transport/event-stores'; export { ChannelInstance, ChannelRegistry, diff --git a/libs/sdk/src/transport/event-stores/index.ts b/libs/sdk/src/transport/event-stores/index.ts index 6c4fd1c6b..da2459579 100644 --- a/libs/sdk/src/transport/event-stores/index.ts +++ b/libs/sdk/src/transport/event-stores/index.ts @@ -15,7 +15,7 @@ * * @example Memory EventStore (single-node) * ```typescript - * import { createEventStore } from '@frontmcp/sdk/transport/event-stores'; + * import { createEventStore } from '@frontmcp/sdk'; * * const { eventStore, type } = createEventStore({ * enabled: true, @@ -27,7 +27,7 @@ * * @example Redis EventStore (distributed) * ```typescript - * import { createEventStore } from '@frontmcp/sdk/transport/event-stores'; + * import { createEventStore } from '@frontmcp/sdk'; * * const { eventStore, type } = createEventStore({ * enabled: true, diff --git a/libs/skills/catalog/frontmcp-config/references/configure-transport.md b/libs/skills/catalog/frontmcp-config/references/configure-transport.md index af106a409..6e32882a7 100644 --- a/libs/skills/catalog/frontmcp-config/references/configure-transport.md +++ b/libs/skills/catalog/frontmcp-config/references/configure-transport.md @@ -149,6 +149,8 @@ transport: { From 1.7.2 each session gets a view over the shared store scoped to its own session id: an event id belonging to another session replays nothing. +Code that builds its own transport can import the stores and transports: `createEventStore`, `MemoryEventStore`, `RedisEventStore`, `RecreateableStreamableHTTPServerTransport`, `RecreateableSSEServerTransport` and `SSEServerTransport` come from `@frontmcp/sdk`, and from `@frontmcp/sdk/transport`, which re-exports them from the root entry. Up to 1.10 neither loaded them at runtime: the root did not export them, and the subpath shipped type declarations only (`MODULE_NOT_FOUND` under `require`, `ERR_MODULE_NOT_FOUND` under `import`). + ## Target-Specific Recommendations | Target | Recommended Preset | Persistence | Event Store | diff --git a/libs/skills/catalog/frontmcp-development/references/create-job.md b/libs/skills/catalog/frontmcp-development/references/create-job.md index b2f3000ec..54402c790 100644 --- a/libs/skills/catalog/frontmcp-development/references/create-job.md +++ b/libs/skills/catalog/frontmcp-development/references/create-job.md @@ -644,7 +644,7 @@ import { App, ExecuteJobTool, GetJobStatusTool, ListJobsTool } from '@frontmcp/s class DataApp {} ``` -The same classes are also reachable via the subpath `@frontmcp/sdk/job/tools` and `@frontmcp/sdk/workflow/tools` for bundlers that prefer narrower imports. +The subpaths `@frontmcp/sdk/job/tools` and `@frontmcp/sdk/workflow/tools` export the same classes: they re-export them from the root entry, so `ExecuteJobTool` from either is the class `@frontmcp/sdk` exports, and a subpath import does not make a bundle smaller. Up to 1.10 the subpaths shipped type declarations only, so loading one failed with `MODULE_NOT_FOUND` under `require` and `ERR_MODULE_NOT_FOUND` under `import`; on those versions import the classes from `@frontmcp/sdk`. ## Nx Generator diff --git a/scripts/build-subpath-reexports.mjs b/scripts/build-subpath-reexports.mjs new file mode 100644 index 000000000..c87c52fcc --- /dev/null +++ b/scripts/build-subpath-reexports.mjs @@ -0,0 +1,47 @@ +// Writes the require and import files of each subpath export as a re-export of the package root, so a subpath hands back the +// root's own classes instead of a second copy of them: node scripts/build-subpath-reexports.mjs +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { build } from 'esbuild'; + +const [packageDirArg] = process.argv.slice(2); +if (!packageDirArg) { + console.error('Usage: node scripts/build-subpath-reexports.mjs '); + process.exit(1); +} + +const packageDir = resolve(packageDirArg); +const manifest = JSON.parse(readFileSync(resolve(packageDir, 'package.json'), 'utf8')); +const FORMAT_BY_CONDITION = { require: 'cjs', import: 'esm' }; + +async function exportedNames(sourcePath) { + const { metafile, outputFiles } = await build({ + entryPoints: [sourcePath], + write: false, + metafile: true, + format: 'esm', + logLevel: 'warning', + }); + if (/^export \* from /m.test(outputFiles[0].text)) { + throw new Error(`${sourcePath} re-exports with export *, so the names its subpath re-exports are unknown`); + } + return Object.values(metafile.outputs)[0].exports; +} + +for (const [subpath, conditions] of Object.entries(manifest.exports)) { + if (subpath === '.' || !conditions?.development) continue; + const names = await exportedNames(resolve(packageDir, conditions.development)); + const contents = `export { ${names.join(', ')} } from '${manifest.name}';`; + for (const [condition, format] of Object.entries(FORMAT_BY_CONDITION)) { + await build({ + stdin: { contents, resolveDir: packageDir, loader: 'js' }, + outfile: resolve(packageDir, conditions[condition].default), + bundle: true, + external: [manifest.name], + format, + platform: 'node', + logLevel: 'warning', + }); + } +} From e54e741b8bccf8cf51a96fcf0aa53995f53398b0 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Tue, 3 Nov 2026 10:25:23 +0200 Subject: [PATCH 24/25] fix(sdk): leave a type re-exported without export type out of a subpath's re-export build-subpath-reexports read a barrel's export names without bundling it, so esbuild could not tell a type re-exported with a plain export { } from a value. The type was written into the subpath's files: importing the subpath then failed with "does not provide an export named", and require() gave undefined for it. The script now bundles the barrel with its packages external and reads the names from the bundled code, which keeps only values; a local export * is read the same way, and only an export * from another package fails the build. Adds scripts/build-subpath-reexports.test.mjs. --- scripts/build-subpath-reexports.mjs | 22 +++++- scripts/build-subpath-reexports.test.mjs | 93 ++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 scripts/build-subpath-reexports.test.mjs diff --git a/scripts/build-subpath-reexports.mjs b/scripts/build-subpath-reexports.mjs index c87c52fcc..ca43e3ce8 100644 --- a/scripts/build-subpath-reexports.mjs +++ b/scripts/build-subpath-reexports.mjs @@ -16,16 +16,30 @@ const manifest = JSON.parse(readFileSync(resolve(packageDir, 'package.json'), 'u const FORMAT_BY_CONDITION = { require: 'cjs', import: 'esm' }; async function exportedNames(sourcePath) { - const { metafile, outputFiles } = await build({ + const { outputFiles } = await build({ entryPoints: [sourcePath], + bundle: true, + packages: 'external', write: false, - metafile: true, format: 'esm', + platform: 'node', logLevel: 'warning', }); - if (/^export \* from /m.test(outputFiles[0].text)) { - throw new Error(`${sourcePath} re-exports with export *, so the names its subpath re-exports are unknown`); + const code = outputFiles[0].text; + if (/^export \* from /m.test(code)) { + throw new Error( + `${sourcePath} re-exports another package with export *, so the names its subpath re-exports are unknown`, + ); } + // The metafile of the bundle also lists a type re-exported without `export type`; the bundled code leaves it out + const { metafile } = await build({ + stdin: { contents: code, loader: 'js' }, + outfile: 'names.js', + write: false, + metafile: true, + format: 'esm', + logLevel: 'warning', + }); return Object.values(metafile.outputs)[0].exports; } diff --git a/scripts/build-subpath-reexports.test.mjs b/scripts/build-subpath-reexports.test.mjs new file mode 100644 index 000000000..6ea074284 --- /dev/null +++ b/scripts/build-subpath-reexports.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { after, test } from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const scriptPath = path.join(__dirname, 'build-subpath-reexports.mjs'); +const require = createRequire(import.meta.url); +const tempDirs = []; + +after(async () => { + for (const dir of tempDirs) await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined); +}); + +const MANIFEST = { + name: '@acme/tools', + exports: { + '.': { require: { default: './dist/index.js' }, import: { default: './dist/esm/index.mjs' } }, + './hand': { + development: './src/hand/index.ts', + require: { default: './dist/hand/index.js' }, + import: { default: './dist/esm/hand/index.mjs' }, + }, + }, +}; + +async function packageWithBarrel(barrel) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'build-subpath-reexports-')); + tempDirs.push(dir); + const packageDir = path.join(dir, 'node_modules', '@acme', 'tools'); + const files = { + 'package.json': JSON.stringify(MANIFEST), + 'src/hand/index.ts': barrel, + 'src/hand/hammer.ts': 'export class Hammer {}\nexport interface HammerOptions { weight: number }\n', + 'dist/index.js': 'class Hammer {}\nmodule.exports = { Hammer };\n', + 'dist/esm/index.mjs': 'export class Hammer {}\n', + }; + for (const [name, body] of Object.entries(files)) { + await fs.mkdir(path.dirname(path.join(packageDir, name)), { recursive: true }); + await fs.writeFile(path.join(packageDir, name), body); + } + return packageDir; +} + +const run = (packageDir) => spawnSync(process.execPath, [scriptPath, packageDir], { encoding: 'utf8' }); + +test("writes a subpath's require and import files as re-exports of the package root", async () => { + const packageDir = await packageWithBarrel( + "export { Hammer } from './hammer';\nexport type { HammerOptions } from './hammer';\n", + ); + + const res = run(packageDir); + + assert.equal(res.status, 0, res.stderr); + const root = require(path.join(packageDir, 'dist/index.js')); + const required = require(path.join(packageDir, 'dist/hand/index.js')); + assert.deepEqual(Object.keys(required), ['Hammer']); + assert.equal(required.Hammer, root.Hammer); + const rootModule = await import(pathToFileURL(path.join(packageDir, 'dist/esm/index.mjs')).href); + const imported = await import(pathToFileURL(path.join(packageDir, 'dist/esm/hand/index.mjs')).href); + assert.deepEqual(Object.keys(imported), ['Hammer']); + assert.equal(imported.Hammer, rootModule.Hammer); +}); + +test('leaves out a type the barrel re-exports without export type', async () => { + const packageDir = await packageWithBarrel("export { Hammer, HammerOptions } from './hammer';\n"); + + const res = run(packageDir); + + assert.equal(res.status, 0, res.stderr); + const imported = await import(pathToFileURL(path.join(packageDir, 'dist/esm/hand/index.mjs')).href); + assert.deepEqual(Object.keys(imported), ['Hammer']); +}); + +test("reads the names of a barrel's local export *", async () => { + const packageDir = await packageWithBarrel("export * from './hammer';\n"); + + const res = run(packageDir); + + assert.equal(res.status, 0, res.stderr); + assert.deepEqual(Object.keys(require(path.join(packageDir, 'dist/hand/index.js'))), ['Hammer']); +}); + +test('refuses a barrel that re-exports another package with export *', async () => { + const res = run(await packageWithBarrel("export * from 'saws';\n")); + + assert.notEqual(res.status, 0); + assert.match(res.stderr, /export \*/); +}); From 1d66a51c7d4314ead723bfe39f978336b355e7f8 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Tue, 3 Nov 2026 10:27:09 +0200 Subject: [PATCH 25/25] test(e2e): type the SDK subpaths and their root exports in the declaration consumer No dts-consumer fixture imported @frontmcp/sdk/transport, /job/tools or /workflow/tools, or the seven values the root now exports for them, so a broken types target or a root export that types as something else went unnoticed. subpath-exports-must-compile.ts compiles them under nodenext with skipLibCheck off, checks each subpath value against the root's type and that createEventStore is not any. It fails on the base, where the root lacks the seven values. --- .../e2e/dts-consumer.e2e.spec.ts | 5 +++ .../fixture/subpath-exports-must-compile.ts | 33 +++++++++++++++++++ .../fixture/tsconfig.json | 1 + 3 files changed, 39 insertions(+) create mode 100644 apps/e2e/demo-e2e-dts-consumer/fixture/subpath-exports-must-compile.ts diff --git a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts index 6dba7614c..661ba88c0 100644 --- a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts +++ b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts @@ -53,6 +53,7 @@ const fixtureFiles = [ 'build-manifest-must-compile.ts', 'persistent-questions-must-compile.ts', 'lazy-apps-must-compile.ts', + 'subpath-exports-must-compile.ts', ].map((fileName) => path.join(fixtureDir, fileName)); function compileFixture(withoutExpectErrors?: string): { diagnostics: FixtureDiagnostic[]; sourceFiles: string[] } { @@ -255,6 +256,10 @@ describe('@frontmcp/sdk declarations in a strict consumer project', () => { expect(describeEach(inFixtureFile('lazy-apps-must-compile.ts'))).toEqual([]); }); + it('types the transport, job/tools and workflow/tools subpaths as the root exports they re-export', () => { + expect(describeEach(inFixtureFile('subpath-exports-must-compile.ts'))).toEqual([]); + }); + it('ships declaration files that type-check with skipLibCheck disabled', () => { const sdkDeclarationDiagnostics = diagnostics.filter((diagnostic) => diagnostic.fileName.startsWith(sdkDeclarationsDir + path.sep), diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/subpath-exports-must-compile.ts b/apps/e2e/demo-e2e-dts-consumer/fixture/subpath-exports-must-compile.ts new file mode 100644 index 000000000..4c3ab5921 --- /dev/null +++ b/apps/e2e/demo-e2e-dts-consumer/fixture/subpath-exports-must-compile.ts @@ -0,0 +1,33 @@ +import { + createEventStore, + type buildScopedServerOptions, + type ExecuteJobTool, + type ExecuteWorkflowTool, + type MemoryEventStore, + type RecreateableSSEServerTransport, + type RecreateableStreamableHTTPServerTransport, + type RedisEventStore, + type SSEServerTransport, +} from '@frontmcp/sdk'; +import { ExecuteJobTool as SubpathExecuteJobTool } from '@frontmcp/sdk/job/tools'; +import * as transport from '@frontmcp/sdk/transport'; +import { ExecuteWorkflowTool as SubpathExecuteWorkflowTool } from '@frontmcp/sdk/workflow/tools'; + +export const jobTool: typeof ExecuteJobTool = SubpathExecuteJobTool; +export const workflowTool: typeof ExecuteWorkflowTool = SubpathExecuteWorkflowTool; +export const streamableTransport: typeof RecreateableStreamableHTTPServerTransport = + transport.RecreateableStreamableHTTPServerTransport; +export const sseTransport: typeof RecreateableSSEServerTransport = transport.RecreateableSSEServerTransport; +export const baseSseTransport: typeof SSEServerTransport = transport.SSEServerTransport; +export const memoryStore: typeof MemoryEventStore = transport.MemoryEventStore; +export const redisStore: typeof RedisEventStore = transport.RedisEventStore; +export const serverOptions: typeof buildScopedServerOptions = transport.buildScopedServerOptions; + +export function eventStoreOf(config: transport.EventStoreConfig): transport.EventStoreResult['eventStore'] { + return createEventStore(config).eventStore; +} + +export function notAConfig(): void { + // @ts-expect-error createEventStore takes an EventStoreConfig + transport.createEventStore('memory'); +} diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json b/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json index 07a4ff299..a562898e5 100644 --- a/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json +++ b/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json @@ -41,6 +41,7 @@ "http-routes-must-compile.ts", "persistent-questions-must-compile.ts", "lazy-apps-must-compile.ts", + "subpath-exports-must-compile.ts", "http-routes-must-compile.ts" ] }