From 1c72d48fc00833f87771d09ece0e6a58ac7f39f9 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 15:47:21 +0200 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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 ba26e358a0d26d079741693889e836a3e5330e6e Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 18:47:56 +0200 Subject: [PATCH 11/11] fix(sdk): read only the selected remote transport from the lazily loaded MCP client Destructuring both transports in createTransport read both lazy getters, so a CommonJS remote connection loaded the SSE client even when Streamable HTTP connected. The module-load e2e helper now prints every loaded module and the spec filters them, so no pattern comes from the command line. --- .../e2e/helpers/loaded-modules.mjs | 7 +-- .../e2e/node-esm-validator.e2e.spec.ts | 9 +-- ...mcp-client.service.lazy-transports.spec.ts | 63 +++++++++++++++++++ libs/sdk/src/remote-mcp/mcp-client.service.ts | 6 +- 4 files changed, 74 insertions(+), 11 deletions(-) create mode 100644 libs/sdk/src/remote-mcp/__tests__/mcp-client.service.lazy-transports.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 index 92b342b28..41c1b8cbd 100644 --- a/apps/e2e/demo-e2e-bundle-size/e2e/helpers/loaded-modules.mjs +++ b/apps/e2e/demo-e2e-bundle-size/e2e/helpers/loaded-modules.mjs @@ -1,13 +1,12 @@ -// Imports a module and prints, as JSON, the URLs of the modules that loaded with it and match a pattern: node loaded-modules.mjs +// Imports a module and prints, as JSON, the URLs of the modules that loaded with it: 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 [entry] = process.argv.slice(2); const loaded = []; registerHooks({ load(url, context, nextLoad) { - if (matching.test(url)) loaded.push(url); + loaded.push(url); return nextLoad(url, context); }, }); 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 index 8261e8d22..666044167 100644 --- 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 @@ -9,18 +9,19 @@ import { resolve } from 'node:path'; import { workspaceRoot } from './helpers/browser-bundle'; -function modulesLoadedBy(entry: string, pattern: string): string[] { +function modulesLoadedBy(entry: string, pattern: RegExp): string[] { const runner = resolve(__dirname, 'helpers', 'loaded-modules.mjs'); - return JSON.parse( - execFileSync(process.execPath, [runner, entry, pattern], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }), + const loaded: string[] = JSON.parse( + execFileSync(process.execPath, [runner, entry], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }), ); + return loaded.filter((url) => pattern.test(url)); } 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$', + /\/@modelcontextprotocol\/sdk\/dist\/(esm|cjs)\/(server\/index|validation\/ajv-provider)\.js$/, ); const buildsOf = (module: string) => diff --git a/libs/sdk/src/remote-mcp/__tests__/mcp-client.service.lazy-transports.spec.ts b/libs/sdk/src/remote-mcp/__tests__/mcp-client.service.lazy-transports.spec.ts new file mode 100644 index 000000000..e246297b4 --- /dev/null +++ b/libs/sdk/src/remote-mcp/__tests__/mcp-client.service.lazy-transports.spec.ts @@ -0,0 +1,63 @@ +// A remote connection reads only the MCP client exports it uses, so the CommonJS build loads no unused transport (#951) +import { createMockLogger } from '../../__test-utils__/fixtures/flow.fixtures'; +import { McpClientService } from '../mcp-client.service'; + +const mockReadExports: string[] = []; + +jest.mock('#protocol-client', () => { + const actual = jest.requireActual('@frontmcp/protocol/client'); + const recording = {}; + for (const name of ['Client', 'StreamableHTTPClientTransport', 'SSEClientTransport', 'StdioClientTransport']) { + Object.defineProperty(recording, name, { + enumerable: true, + get: () => { + mockReadExports.push(name); + return actual[name]; + }, + }); + } + return { loadProtocolClient: async () => recording }; +}); + +function fakeMcpServer(): void { + jest.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { + if (init?.method !== 'POST') return new Response(null, { status: 405 }); + const message = JSON.parse(String(init.body)) as { id?: number; method: string }; + if (message.id === undefined) return new Response(null, { status: 202 }); + const result = + message.method === 'initialize' + ? { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'u', version: '1' } } + : { tools: [], resources: [], resourceTemplates: [], prompts: [] }; + return new Response(JSON.stringify({ jsonrpc: '2.0', id: message.id, result }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }); +} + +describe('McpClientService — MCP client exports read on connect', () => { + let service: McpClientService; + + beforeEach(() => { + mockReadExports.length = 0; + service = new McpClientService(createMockLogger(), { enableHealthCheck: false, enableAutoReconnect: false }); + }); + + afterEach(async () => { + await service.dispose(); + jest.restoreAllMocks(); + }); + + it('reads the Streamable HTTP transport, not the SSE one, for an HTTP remote that connects', async () => { + fakeMcpServer(); + + await service.connect({ + appId: 'upstream', + name: 'upstream', + transportType: 'http', + url: 'http://remote.test/mcp', + }); + + expect(new Set(mockReadExports)).toEqual(new Set(['Client', 'StreamableHTTPClientTransport'])); + }); +}); diff --git a/libs/sdk/src/remote-mcp/mcp-client.service.ts b/libs/sdk/src/remote-mcp/mcp-client.service.ts index 64125a585..00c11eddd 100644 --- a/libs/sdk/src/remote-mcp/mcp-client.service.ts +++ b/libs/sdk/src/remote-mcp/mcp-client.service.ts @@ -882,7 +882,7 @@ export class McpClientService { } private createTransport( - { StreamableHTTPClientTransport, SSEClientTransport }: ProtocolClientModule, + protocolClient: ProtocolClientModule, request: McpConnectRequest, headers: Record | undefined, ): Transport { @@ -895,10 +895,10 @@ export class McpClientService { switch (transportType) { case 'http': // Start with Streamable HTTP - fallback to SSE happens during connect() if needed - return new StreamableHTTPClientTransport(new URL(url), options); + return new protocolClient.StreamableHTTPClientTransport(new URL(url), options); case 'sse': - return new SSEClientTransport(new URL(url), options); + return new protocolClient.SSEClientTransport(new URL(url), options); case 'worker': case 'npm':