Repository navigation
perf(sdk): set up jobs, workflows, channels, remote MCP, the ESM loader and OAuth flows on first use - #1016
Conversation
…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
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.
…ncoding
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
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
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
… 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.
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
…tens 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
… 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`.
… 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.
…rf/p02-dependency-costs
…under require() and import
… on first use, with their hooks still running
…oader when a CommonJS process first reads their exports
…one with the first request that reaches it
…, so an ESM import of that build still sees them
…under the SDK's CommonJS build
…lso has a static canActivate
…ad with a 500 canActivate awaited the class load outside the Express middleware's error handling and before the Web fetch handler's, so a load that failed escaped as an unhandled rejection (Express 4) or a thrown fetch handler. The flow now claims its route, and its run throws the load error, which both adapters answer with a 500.
…systems # Conflicts: # apps/e2e/demo-e2e-bundle-size/e2e/helpers/loaded-modules.mjs # apps/e2e/demo-e2e-bundle-size/e2e/node-esm-validator.e2e.spec.ts # libs/sdk/project.json # libs/sdk/src/remote-mcp/mcp-client.service.ts # libs/sdk/src/transport/web-standard-mcp.ts
Regenerated from main's own build (91531eb): 42 names added by the feature stack, none removed, no kind changed. The merged build exports the same 966 names and kinds under require() and import.
|
No actionable comments were generated in the recent review. Walkthrough
Merge Risk:
|
Performance Test ResultsStatus: ✅ All tests passed Summary
Total: 101 tests across 21 projects 📊 View full report in workflow run Generated at: 2026-11-03T07:52:30.506Z |
main gained JobRunResource and CancelJobTool. They go through ./subsystems like the other job exports, so the CommonJS build still loads the job subsystem the first time one of them is read.
Regenerated from main's own build (998ca63): 76 names added, none removed, no kind changed. This branch's CommonJS and ESM builds export exactly the same names and kinds.
… over #1016 On main 998ca63 with #1016, the lazy stand-ins add 457 bytes to create() + WebMcpPlugin + one tool (1,346,697 -> 1,347,154) and 437 to Vite's initial chunk (964,258 -> 964,695). Those budgets rise by exactly that: 1,346,921 -> 1,347,378 and 964,390 -> 964,827. create() + one tool (1,339,977) still fits 1,340,056.
…1021) * perf(lazy-zod): keep what a stand-in is built from in fields, not closures internalZ made two closures and their contexts per stand-in, which cost more than the stand-in itself. A stand-in now keeps zod's factory, its namespace and the arguments (or the parent and method it was chained from) and drops the arguments once built. The public z's stand-ins make one closure instead of two. * fix(lazy-zod): drop a stand-in's arguments and parent only once it has built A stand-in cleared its arguments before building, so a build that threw left it empty: the next parse built the schema without them (an internalZ.object whose build threw became z.object() and accepted any object). It now drops them after a successful build, as the closure was dropped before. A chained stand-in also kept its parent stand-in, and the parent's built schema, for as long as it lived. It now drops the parent once built. * test(e2e): raise two browser budgets by this branch's measured growth over #1016 On main 998ca63 with #1016, the lazy stand-ins add 457 bytes to create() + WebMcpPlugin + one tool (1,346,697 -> 1,347,154) and 437 to Vite's initial chunk (964,258 -> 964,695). Those budgets rise by exactly that: 1,346,921 -> 1,347,378 and 964,390 -> 964,827. create() + one tool (1,339,977) still fits 1,340,056.
Closes #950
What and why
Follow-up to the 1.10.9 performance work (#914, #919 to #925, #932, #934 to #943). #950 measured that the SDK's CommonJS bundle sets up every subsystem when it loads, used or not, and gave two upper bounds, measured with hacks that broke public behavior: −1.9 ms (HTTP) / −1.8 ms (stdio) for skipping jobs, workflows, channels, remote MCP and the ESM loader, and −3.1 / −3.6 ms for skipping the 8 OAuth flows of a public-mode server. This PR takes both without changing what the SDK exports or which flows a hook can target.
src/subsystems.ts, andsrc/index.tsstill names each of them. The CommonJS build (build-cjs) takessrc/lazy-subsystems.tsfor that module. It defines each name as a getter thatrequire()s its subsystem's barrel the first time it is read, inside the same bundle, so the class or function is the one every other module of the SDK uses.libs/sdk/esbuild.cjs.config.js.build-cjstakes its options from that file now (the sameminifyWhitespace,outExtensionandexternallist it had inproject.json), and the build fails ifsrc/index.tsstops importing./subsystems. The ESM, browser and browser-module builds, the Worker bundles made from them and the declaration files all keep the static module, so they are as before.#subpath import with a build condition would put that#specifier intodist/index.d.ts, whichmoduleResolution: node(whatfrontmcp createwrites for TypeScript 5) can't resolve. The issue rules out rewriting esbuild's output.esm-loader/package-specifier.tsandmodule-specifiers.ts, which the decorators'Tool.esm()and friends call) and three small helpers (job/job.utils.ts,channel/channel.utils.ts,channel/sources/completion-events.ts). Together they took 0.15 ms to set up (instrumented build, below).LocalPrimaryAuthregistersoauthFlows(auth/flows/oauth-flows.ts): onedeferredFlow({ name, middleware: { method, path } }, load)per flow, in the order the classes were registered before, so the Express routes keep their order.deferredFlow(flows/deferred-flow.ts) returns a stand-in flow class with that name and route.FlowInstanceregisters it like any flow, mounts its route, and the first time a request matches its method and path (canActivate, whichmatchHttpand the Express middleware both call) or the flow runs, it loads the class and takes its metadata, plan and stages. Hooks are read per run, so a plugin'sFlowHooksOf('oauth:token')hooks run as before. A request to another route or with another method leaves the class unloaded. This applies to every modeLocalPrimaryAuthserves (public, static, orchestrated local and remote): in the modes that use the OAuth flows, the first request to each route loads its flow.Unhandled error in flow). Before, the same failure stopped the server at startup.export { OauthTokenFlow };and so on, because underNodeNextTypeScript types a dynamicimport()of a CommonJS-format module's default export as the whole module, while esbuild and Jest hand back the class.middleware.canActivateand a staticcanActivate:canActivateappended the static guard to the metadata's array. It now builds the list per request. Found by the review below; a loaded OAuth flow's metadata goes through the same path.The
#subpath imports of@frontmcp/utilsand@frontmcp/protocolthat make an ESM server load zod and part of the MCP SDK twice (found in #1007) are untouched here: this PR doesn't change thoseimportsmaps. It stays a follow-up.Found while working, also unchanged: the
./transport,./job/toolsand./workflow/toolssubpath exports of@frontmcp/sdkpoint at files the build doesn't emit. In the published 1.10.16,require()of each fails withMODULE_NOT_FOUND(transport/index.js,job/tools/index.js,workflow/tools/index.js) andimportwithERR_MODULE_NOT_FOUND(esm/transport/index.mjsand so on); only their.d.tsfiles ship, so TypeScript compiles the imports. The fix belongs onrelease/1.10.x.Results
Apple M4 Pro (14 cores, 48 GiB), macOS 15.6.1, Node.js 24.22.1, on AC power, with other jobs running on the machine (1-minute load average given per run). The server is the benchmark's FrontMCP implementation (
frontmcp build --target node), run withnode, againstnpm packed builds ofaae293336(main1.10.16 with #1002 and #1007) and of this branch at7e0276974, each installed into a scratch copy of the implementation and built withnpm run build(the twodist/node/bench.bundle.jsare byte-identical; only@frontmcp/sdkdiffers). Every run uses the benchmark's harness functions (startServer,coldStartOnce,HttpSession,connectStdio,rssOfTree) in randomized, interleaved rounds. "Per round" is the median, over rounds, of the difference between the two builds' medians in the same round, with its range.Run A: 16 rounds of 4 starts per build and scenario. 1-minute load average 33.0 to 59.6, median 41.5.
aae293336tools/calls, HTTPtools/calls, stdiotools/callanswer, HTTPtools/callanswer, stdiotools/callanswer, HTTPtools/callanswer, stdiops), MB is 10^6 bytes. Heap and CPU time are the server's ownprocess.memoryUsage().heapUsedandprocess.cpuUsage()(user + system, all threads), read in the server by a--requirepreload when the client has the firsttools/callanswer (initializeincluded). Cold start is the benchmark's measure: spawn to the firsttools/callanswer.Run B, module setup, instrumented: a copy of each build whose esbuild
__esminit helper records how long each source module took to set up (its own code, without the modules it pulls in and withoutrequire()s of other packages), and how longFlowRegistrytook to register each flow. 15 starts per build and transport, run one build after the other. 1-minute load average 38.7 to 51.2. Medians:aae293336→ this PRaae293336→ this PRoauth:*flowsrequire('@frontmcp/sdk')initialize(exact)The issue's upper bounds were −1.9 / −1.8 ms for the five subsystems and −3.1 / −3.6 ms for loading and registering the OAuth flows, measured against 1.10.9-rc.4. Here the setup the five subsystems no longer do at startup is 3.6 to 4.0 ms, and the OAuth flows' 1.5 ms, while registering them costs what it did. Part of what leaves "every SDK module" is a one-time cost that moves rather than goes away: the first
@Tooldecorated class pays for compiling the tool metadata schema, which was the job-management tools' and is now the server's first tool's. Run B ran at a high load and not interleaved, so treat its differences as estimates; the module counts are exact.The branch will be measured again against
main(91531eb, with #1011 and #1010) once it is merged in.User-facing change
@frontmcp/sdkare the same 924 names underrequire()andimport, each of the same kind, anddist/index.d.tsexports the same 1,852 names. Three things can be observed:require(), reading one of the 80 root exports of jobs, workflows, channels, remote MCP or the ESM loader (JobRegistry,McpClientService,EsmModuleLoader, …) for the first time sets up its subsystem then, not when the SDK loads.LocalPrimaryAuth(public, static and orchestrated modes) loads each OAuth flow's class with the first request to its route. Hooks on those flows run as before. If a class fails to load, requests to its route get a 500 instead of the server failing to start.@frontmcp/sdk's CommonJS file is 18 KB larger (2,036,345 → 2,054,449 bytes): the export objects esbuild builds for the five subsystem barrels it nowrequire()s.main(998ca63, with the M10–M21 feature stack) and on this branch merged with it:create()+ one tool 1,339,102 → 1,339,520 bytes minified (+418), withWebMcpPlugin1,346,279 → 1,346,697 (+418); Vite's initial chunk is unchanged at 964,258 (the OAuth flows become separate chunks). Both stay within the current budgets, so no budget changes.Tests
apps/e2e/demo-e2e-bundle-size/e2e/sdk-root-exports.e2e.spec.tswithfixtures/sdk-root-exports.json, the 924 root export names and the kind of each (class, function, object, array, string, number, symbol) asrequire()ofdist/index.jsandimportofdist/esm/index.mjssee them. A third case importsdist/index.jsfrom ESM, so Node's export detection must still list every name: an ESM import saw 845 names whilesrc/index.tsre-exported./subsystemswithexport *, which is why the root names each export.dist/index.d.tsexports the same 1,852 names before and after, each a value or a type as before and declared in the same file.sdk-lazy-subsystems.e2e.spec.ts(withhelpers/initialized-modules.cjs, which loadsdist/index.jswith esbuild's__esmhelper wrapped to record the source modules it sets up). Loading the CommonJS build sets up none of the modules of the five subsystems besides the five startup helpers named above; readingJobRegistry,WorkflowRegistry,ChannelRegistry,McpClientServiceorEsmModuleLoadersets up its module; a public-mode server built withcreateFetchHandler()sets up none of the 8 OAuth flow modules, and a request to/oauth/authorizethen to/oauth/tokensets up only that flow's module each time.sdk-subsystem-hooks.e2e.spec.ts(withhelpers/subsystem-hooks.mjs). Underrequire()ofdist/index.js, a server-level plugin's hooks see a job run (jobs:execute-job), a workflow step, a channel notification (channels:send-notification) and the channel list (channels:list), a remote app's tool and an ESM package's tool (tools:call-tool, against a second FrontMCP server over HTTP and a local package registry), and a request to each of the 8 OAuth routes of a public-mode server (Will('checkIpFilter')on eachoauth:*flow). Passes on the base build and on this branch.src/__tests__/lazy-subsystems.spec.ts(the CommonJS variant exports the same names and the same values assubsystems.ts, and loads a subsystem only when one of its names is read),src/flows/__tests__/deferred-flow.spec.ts(a deferred flow is registered without loading its class; it loads once for concurrent runs, runs its stages and a plugin's hook, validates input with the loaded class's schema, stays unloaded for another route or method, loads for a request to its route, runs each route guard once per request, and answers a request to its route with a 500, through the fetch handler and through the Express middleware, when its class fails to load; 3 of its cases failed without theFlowInstancechange, the guard case failed before the fix, and the two load-failure cases failed before the last commit, the load error escaping both handlers),src/auth/flows/__tests__/oauth-flows.spec.ts(each declaration has the name, method and path of the class it loads, in the old route order, and a public-mode server runs a plugin's hook on all 8 flows).mainat 998ca63:nx run sdk:testpasses 9,257 tests in 660 suites, and bundle-size passes 23. The pinned root exports were regenerated frommain's own build (1,042 names, 76 new, none removed, no kind changed); this branch's CommonJS and ESM builds export exactly those.mainaddedJobRunResourceandCancelJobToolto the job exports, so they go through./subsystemstoo.nx run sdk:test(--maxWorkers=50%) passes 7,514 tests in 582 suites, andnx run sdk:lintreports 0 errors and no warning in a file this PR adds.node --test scripts/*.test.mjspasses 84, among them the Worker export-condition and browser build guards. e2e: bundle-size 23 (with the three new specs, 6 tests) and itstypecheck-e2e, dts-consumer 11, cloudflare 16 (5 skipped), local-auth 73, orchestrated 45, hooks 23, jobs 43, channels 44 and remote 31 pass. public passes 11 of 12:should connect without authorization headercan't start its server because the IDE on this machine listens on 127.0.0.1:50000 to 50002, the first ports of that suite's range (EADDRINUSE).node scripts/fix-unused-imports.mjschanges nothing, and Prettier passes on every changed file.tsc --noEmit -p libs/sdk/tsconfig.spec.jsonreports no error. The build type-checks the library code.Summary by CodeRabbit