Repository navigation
perf(sdk): cut first-request and ESM startup costs that come from dependencies - #1007
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
|
@coderabbitai review |
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Protocol-client loader and build wiring libs/sdk/package.json, libs/sdk/project.json, libs/sdk/src/third-party/*, tsconfig.base.json, jest.imports-mapper.js, eslint.config.mjs |
The SDK adds the #protocol-client alias and ESM and shared loaders. Build entries and tests cover both loader variants. |
Direct and remote client integration libs/sdk/src/direct/direct-client.ts, libs/sdk/src/remote-mcp/mcp-client.service.ts |
Direct and legacy remote connection paths load the protocol-client module when creating clients and transports. |
Client module-load E2E coverage apps/e2e/demo-e2e-bundle-size/e2e/* |
The E2E helpers record matching module loads. Tests check that the client loads on connection and that the greeting result has the expected value. |
MCP server construction and validation
| Layer / File(s) | Summary |
|---|---|
Validator exports and server factory libs/protocol/src/*, libs/sdk/src/transport/mcp-server.factory.ts, libs/sdk/src/transport/__tests__/mcp-server.factory.spec.ts, apps/e2e/demo-e2e-bundle-size/e2e/node-esm-validator.e2e.spec.ts |
The protocol package re-exports the SDK Ajv validator. The server factory creates and reuses the validator on demand. Tests cover validator identity, construction timing, and elicitation validation. |
Server factory adoption libs/sdk/src/front-mcp/front-mcp.ts, libs/sdk/src/transport/adapters/transport.local.adapter.ts, libs/sdk/src/transport/in-memory-server.ts, libs/sdk/src/transport/web-standard-mcp.ts |
Stdio, local transport, in-memory, and web-standard server paths use the shared factory. |
Streamable HTTP startup loading
| Layer / File(s) | Summary |
|---|---|
Startup hook and scope wiring libs/sdk/src/transport/streamable-http-preload.ts, libs/sdk/src/scope/http-scope.helper.ts, libs/sdk/src/scope/scope.instance.ts |
HTTP-enabled scopes register a startup hook. The hook schedules transport loading after server startup and logs load failures. |
Startup and entry-point tests libs/sdk/src/transport/__tests__/streamable-http-preload.spec.ts, libs/sdk/src/front-mcp/__tests__/streamable-http-preload.entry-points.spec.ts |
Tests cover protocol-specific preload behavior, failures, and entry points that do or do not load the transport. |
UTF-8 JSON request parsing
| Layer / File(s) | Summary |
|---|---|
Parser implementation and adapter wiring libs/sdk/src/server/adapters/utf8-json.parser.ts, libs/sdk/src/server/adapters/express.host.adapter.ts |
The adapter installs a raw-byte UTF-8 parser before the existing JSON parser. The new parser handles BOMs and empty bodies and forwards parse errors. |
Request parsing and compatibility tests libs/sdk/src/server/adapters/__tests__/* |
Tests cover request and error responses, UTF-8 and UTF-16LE input, chunked bodies, prior body reads, and body-size limits. |
Estimated code review effort: 4 (Complex) | ~45 minutes
Possibly related issues
- agentfront/frontmcp#950: This PR defers Streamable HTTP transport loading and ESM MCP client loading, addressing part of the issue’s startup setup scope. It does not implement the proposed bundle split.
- agentfront/frontmcp#948: This PR defers transport, Ajv, and MCP client dependency work. The summaries do not show that the issue’s heap or RSS targets are met.
- agentfront/frontmcp#951: This PR implements selective UTF-8 JSON parsing, deferred transport and Ajv work, and dynamic ESM MCP client loading. Zod locale loading is outside the described changes.
Possibly related PRs
- agentfront/frontmcp#941: This PR continues the lazy dependency work in the direct-client path and adds the
#protocol-clientloader. - agentfront/frontmcp#942: This PR uses the
@frontmcp/protocol/cliententry point introduced by PR#942. - agentfront/frontmcp#947: This PR uses the protocol-client subpath and adds ESM loaded-module E2E coverage.
Suggested reviewers: alexmercerpo
Merge Risk: 
Minimal · up to 51bae
-
Compressed JSON remains supported, and no merge-blocking risk was established.
Pre-merge checks: 
5
-
Full details: Description check
-
Explanation
The description includes the required What and why, User-facing change, and Tests sections. It explains the implementation, measurable results, compatibility impact, known limitation, and validation performed.
Full details: Docstring Coverage
-
Explanation
Docstring coverage is 71.43% which is sufficient. The required threshold is 65.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 7 functions across 32 files. (1 skipped: 1 unsupported.)
-
Finishing Touches
-
Generate docstrings
-
- Commit to this branch
- Create a new PR
Generate unit tests (beta)
-
- Commit to this branch
- Create a new PR
-
- Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts
Action not completed
|
Performance Test ResultsStatus: ✅ All tests passed Summary
Total: 101 tests across 21 projects 📊 View full report in workflow run Generated at: 2026-11-02T17:37:05.914Z |
…ded 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.
…rf/p02-dependency-costs # Conflicts: # libs/sdk/src/transport/web-standard-mcp.ts
Stacked on #1002
Refs #951
Items 1 to 3 of #951 are done here. Item 4 (zod's locales) stays open: zod has not changed how it loads them (see below).
What and why
Follow-up to the 1.10.9 performance work (#914, #919 to #925, #932, #934 to #943). #951 lists four startup and first-request costs that come from dependencies.
json()decodes a body with iconv-lite, and iconv-lite loads all of its encodings (10 files plussafer-buffer; 418 encoding names in iconv-lite 0.7.3) the first time it decodes anything, UTF-8 included: on a server's first JSON body. The Express host now readsapplication/jsonbodies with no charset orcharset=utf-8with body-parser's ownraw(). The limit,Content-Encodinginflation and every read error (413, aborted, corrupt gzip, unsupported encoding) therefore stay body-parser's. It then decodes them as iconv-lite does (Node's UTF-8 decoder, one leading byte order mark dropped) and parses them with body-parser's strict-JSON rules. An empty body is{}. A body that doesn't start with{or[gets body-parser's strict-modeSyntaxError, and a malformed one gets theJSON.parseerror, each with the sameexpose,statusCode,status,bodyandtypeproperties. Bodies in another charset (utf-16le,latin1,utf8, …), other JSON types (application/json-patch+json), comma-listed types, and a body something read before the parser all still go throughjson()as before. Parsing stays in the adapter's translation step, ahead of routing, and every request still runshttp:request.Serverbuilds an Ajv instance with ajv-formats in its constructor, to check the results of the elicitations it requests itself (elicitInput). FrontMCP never callselicitInput, but built one Ajv per session, and per request on a stateless web-standard server. Every MCP server FrontMCP creates now comes fromcreateMcpServer(), whose validator builds the sameAjvJsonSchemaValidatorthe first time it validates.@frontmcp/protocolexportsAjvJsonSchemaValidatorfor it, from the moduleMcpServercomes from (#mcp-server), so a process takes both from the same MCP SDK build and an ESM server doesn't load the validator module a second time.@hono/node-serverand Node's bundled undici, from startup to the first session, so a server's firstinitializewaited for it. A scope that serves Streamable HTTP (streamable,statelessorjsonintransport.protocol, which every preset enables) now loads it right after the server starts listening (onServerStarted, on the nextsetImmediate). The load is therefore off the path to listening, and usually done before a client arrives. A stdio server, a serverless handler and a Worker never start listening, so they load it as before. A server that serves only legacy SSE never loads it.@frontmcp/protocol/client, whoserequire()variant loads on first read, but the SDK's single-file ESM bundle imported it statically for the direct client and remote apps. Both now take it from#protocol-client, a new subpath import in@frontmcp/sdk. Itsimportvariant, which only the ESM build resolves, is() => import('@frontmcp/protocol/client'). Every other variant is the static module, so the CommonJS build (which keeps#protocol-clientexternal and ships both files, like#js-yaml), the browser builds and Worker bundles load as before. An ESM process now loads the client with the firstconnect()or remote app, as a CommonJS one does.require('zod')andimport 'zod':v4/classic/externalandv4/corere-export../locales/indexas a namespace. zod's work so far is for bundlers only. fix(v4): let bundlers tree-shake locales out of the default import colinhacks/zod#6384 (merged 2026-08-14) lets Rollup and Webpack drop the locales from the default import, and #6050 was closed with it. #5665 (an entry point with onlyen) was closed in April, pointing to a Turbopack fix. #6633 was closed as not planned with "not fixable on our side without removingz.locales". No issue or PR covers loading them on demand in Node. This needs an upstream issue (draft below); item 4 stays open on 1.10: startup and first-request costs that come from dependencies #951.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 CommonJS server is the benchmark's FrontMCP implementation (
frontmcp build --target node), run withnode, againstnpm packed builds oforigin/main(42b36fa1b, 1.10.16) and of this branch at each item's commit, installed into scratch copies of the implementation and built withnpm run build. The ESM server is the sameaddtool written as plain ESM (tool(),App(),FrontMcp()applied as functions,"type": "module") on the same packages. All runs use the benchmark's harness functions (startServer,HttpSession,coldStartOnce) in randomized, interleaved rounds:initialize. The firsttools/callis timed afternotifications/initialized.tools/callresponse,initializeincluded, with the client retrying from the moment the process starts.tools/calls. MB is 10^6 bytes.CommonJS server, run A2: 12 rounds of 5 starts per build and scenario. 1-minute load average 27.6 to 71.9, median 35.6. Columns are the commits of this branch: item 3 doesn't change a CommonJS server beyond one small file, so the third column is items 1 and 3 plus the Ajv change.
origin/maininitialize), p50 / p90origin/maintools/call, p50Item 1 measured about 3 ms and Ajv about 3 ms; the issue estimated 5 ms and part of 12 ms. Loading the transport on listen takes 13 ms off the first request. Cold start and spawn-to-listening swing by up to ±200 ms from round to round at this load (spawn to listening ranges from −198 to +468 ms per round between the last two columns), so their medians don't separate the builds. The transport loads after
listen()returns, so it cannot delay listening. RSS before the first session is 5 to 6 MB higher with the transport loaded (4 to 6 MB across the runs below); after the first calls the difference is 1.8 MB (−0.0 to 3.8 per round).Before writing the transport change, I measured it with a prototype (a
--requirepreload that loads the MCP SDK's transport on'listening') against the same build without it. In 12 rounds of 6 (load 29.8 to 50.7), the first request went from 33.0 to 18.8 ms (−14.3 ms per round, −24.8 to −10.2), spawn to listening from 301.9 to 303.2 ms, and RSS while listening rose 6.3 MB. A cold-start-only run of 20 rounds of 8 (load 29.6 to 36.9) gave 323.7 and 322.2 ms (−10.9 ms per round, −53.6 to +34.0): no slowdown, so it shipped.The shipped change against the commit before it, with the first request sent as soon as the port accepts a connection (the case where the load can still be running when the client arrives): 16 rounds of 6 (load 29.3 to 55.6, median 33.6). Spawn to listening was 275.1 and 276.2 ms (+1.0 ms per round, −41.2 to +70.9). Cold start was 313.5 and 310.2 ms (+0.0 ms per round, −47.4 to +47.2). The first request went from 29.8 to 17.5 ms (−12.6 ms per round, −22.6 to −9.2). RSS was +3.9 MB right after listening and +1.7 MB after two calls.
ESM server, run B: 12 rounds of 5 per build and scenario. 1-minute load average 31.2 to 91.4, median 43.6.
origin/mainorigin/maininitialize), p50Under ESM the transport already loads at startup (the single-file ESM bundle imports it statically), so its first request was already cheaper and the preload changes nothing there. Item 3 takes about 17 ms and 3.5 MB of RSS off an ESM server's startup (−3.5 MB per round, −4.3 to −1.4).
Modules loaded (unique files, recorded with a
module.registerHookspreload 0.8 s after the server listens and again after its firstinitializeandtools/call; exact, so no load average applies):origin/maintools/callsafer-buffertools/callclient/index,streamableHttp,sse,auth,stdio)A CommonJS HTTP server now has 11 more modules by the time it listens: the 10 its first session loaded before (the MCP SDK's
streamableHttp,webStandardStreamableHttp,requestBody,sseKeepAliveandshared/mediaType,@hono/node-server's two files, ahonohelper,content-typeandnode:http2), plusthird-party/protocol-client.js. After its first call it has 10 fewer, because the first JSON body no longer loads iconv-lite's 10 encoding files andsafer-buffer. The stdio server gains onlyprotocol-client.js. An ESM server loads 94 fewer modules at startup: the MCP client and what comes only with it (cross-spawnand its dependencies,eventsource,pkce-challenge, the OAuth client, andzod-to-json-schemawith the zod v3 files it uses).zod 4.6.7 (what a fresh install of the SDK resolves; this PR doesn't change it), 30 fresh processes each, at a 1-minute load of 65 to 68. Loading
v4/locales/indexand its 63 locales afterv4/core/util, the one module they need, took a median of 22.9 ms (16.5 ms minimum).require('zod')as a whole took a median of 39.0 ms (33.6 ms minimum). The locales are about half of zod's load time, and 64 of the 95 files it loads.Found while measuring, outside this issue, and already so on
origin/main(checked on the packed 1.10.16 build): an ESM server loads zod twice (116 CommonJS and 97 ESM files, so 128 locale files) and part of the MCP SDK twice (types.jsfrom both builds). The#subpath imports of@frontmcp/utilsand@frontmcp/protocol(#mcp-server,#mcp-streamable-http,#env, …) resolve to their CommonJS files underimport, and thoserequirethe MCP SDK's CommonJS server and@frontmcp/lazy-zod's CommonJS build. That belongs with #949 or #950.Draft of the upstream zod issue (not filed)
User-facing change
@frontmcp/protocol, which is internal, exportsAjvJsonSchemaValidator. Two things can be observed:NODE_ENVother thanproduction), Express's error page for a malformed or non-object UTF-8 JSON body prints the same message line with stack frames in@frontmcp/sdkinstead ofbody-parser/lib/types/json.js.Tests
libs/sdk/src/server/adapters/__tests__/express.host.adapter.body.spec.ts(52 cases, 57 with the five added in review). It pins the status, content type and exact body of the answer to 42 requests in production mode, and the error a later error handler receives for 15 of them. The requests cover a JSON object,charset=utf-8in three spellings, an upper-case type, one and two byte order marks, leading whitespace, an empty body, whitespace only, string, number andtrueliterals, a bare word, truncated JSON, an unquoted key, trailing garbage, a__proto__key, invalid UTF-8, a lone continuation byte and a body sent one byte per chunk. They also coverutf-16le,latin1,utf8,utf-9, a parameter other thancharset,application/json-patch+json,application/vnd.api+json,application/json, text/plain, a hybrid form type, a form,text/plain, no content type, gzip, deflate, brotli, corrupt gzip, an unsupported encoding, and bodies at, over and chunked over the limit, plus gzip that inflates over it. The same 57 pass after the change, and with the UTF-8 parser taken out again.express.host.adapter.utf8-json.spec.ts. A UTF-8 JSON body leaves iconv-lite's encodings unloaded, which failed before the change; a UTF-16 body still decodes through iconv-lite.utf8-json.parser.spec.ts. It compares the parser followed byjson()withjson()alone over 300 generated bodies (valid, malformed, byte order marks, invalid UTF-8, chunked) for two content types, and they match: parsed value or error,req.bodyafter an error, and status. Dropping the byte order mark handling or the strict-mode message rewrite fails it. It also covers a body read by an earlier middleware (red without the guard), a UTF-16 body and a 413. Coverage ofutf8-json.parser.ts: 97.7% of statements, 100% of branches and functions (the uncovered line is a return afterJSON.parseof invalid JSON, which cannot be reached).req.bodyfor 52 requests like the pinned ones (among themnull, a lone byte order mark,charset=utf-8; foo=bar,application/json ; charset=utf-8, a tab beforecharset,GZIP,identity, gzip of malformed JSON, invalid UTF-8 split across chunks and aconstructor.prototypekey), and for bodies an earlier middleware already read, flagged as read or replaced.Buffer#toString('utf8')does with one leading byte order mark dropped. body-parser 1.20.9 and 2.x share the same JSON parse code.mcp-server.factory.spec.ts. A server fromcreateMcpServer()builds no Ajv until it validates an elicitation result and then builds one, while a plainMcpServerbuilds one in its constructor. It accepts a matching result, and rejects a mismatching one with the sameMcpErroras the MCP SDK's default.front-mcp/__tests__/streamable-http-preload.entry-points.spec.ts. With the real entry points, the transport loads afterstart()has returned and written thefrontmcp devready line, and not at all forcreateHandler(),createFetchHandler()orrunStdio(). Loading it as soon as the scope is built fails all four.streamable-http-preload.spec.ts. The transport loads once the server listens, for the default,legacy,modern,stateless-apiandfullpresets and forstreamable,statelessorjsonalone. It does not load before the server listens, nor for a legacy-SSE-only server, and a failed load is logged and does not stop the server.third-party/__tests__/protocol-client.spec.ts(both variants hand back@frontmcp/protocol/client's classes).libs/protocolexports.spec.ts: the root and both#mcp-servervariants export the MCP SDK'sAjvJsonSchemaValidator.apps/e2e/demo-e2e-bundle-size/e2e/node-esm-client.e2e.spec.ts(withhelpers/connect-direct-client.mjs). An ESM process that imports the built SDK loads no MCP client module untilconnect(), then loads it, and the tool call answers. Against theorigin/mainbuild the helper sees 5 client modules beforeconnect().apps/e2e/demo-e2e-bundle-size/e2e/node-esm-validator.e2e.spec.ts(withhelpers/loaded-modules.mjs). An ESM process that imports the built SDK loads the MCP SDK'svalidation/ajv-provider.jsonly from the build itsserver/index.jscomes from. It failed while@frontmcp/protocolexported the validator from the MCP SDK directly: the ESM build loaded both builds' copies.nx run-many -t test -p sdk protocol(--maxWorkers=50%): SDK 7,502 tests in 579 suites and protocol 9 pass.nx run-many -t lint -p sdk protocolreports 0 errors and no warning in a file this PR adds.node --test scripts/*.test.mjs: 84 pass, among them the Worker export-condition and browser build guards.@frontmcp/protocoltakesAjvJsonSchemaValidatorfrom and add tests.node scripts/fix-unused-imports.mjs origin/mainchanges nothing, and Prettier passes on the changed files.tsc --noEmitover the new specs reports only the known module-augmentation error incommon/metadata/flow.metadata.ts. The build type-checks the library code.{"a":has the same first line before and after. The CommonJS, browser and browser-ESM builds resolve#protocol-clientto the static module, and the ESM bundle containsimport("@frontmcp/protocol/client"). In the builtdist/package.json,#protocol-clientresolves to the static module underrequire(with any condition),browserandworker, and to theimport()one underimportalone or withworkerd,edge-lightormodule-sync; only the CommonJS build resolves it at run time, withrequire. A CommonJS process loads no MCP client module at startup.Summary by CodeRabbit
New Features
Bug Fixes