From 1c72d48fc00833f87771d09ece0e6a58ac7f39f9 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 15:47:21 +0200 Subject: [PATCH 01/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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/20] 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 dae7555267dc4977d71453995a095a7f367bf2a1 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Tue, 3 Nov 2026 09:37:57 +0200 Subject: [PATCH 20/20] 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",