Skip to content

perf(sdk): cut first-request and ESM startup costs that come from dependencies - #1007

Merged
frontegg-david merged 13 commits into
perf/p01-external-utilsfrom
perf/p02-dependency-costs
Nov 2, 2026
Merged

frontegg-david merged 13 commits into
perf/p01-external-utilsfrom
perf/p02-dependency-costs

Conversation

@frontegg-david

@frontegg-david frontegg-david commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

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.

  1. UTF-8 JSON bodies without iconv-lite's encodings (item 1). Express's json() decodes a body with iconv-lite, and iconv-lite loads all of its encodings (10 files plus safer-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 reads application/json bodies with no charset or charset=utf-8 with body-parser's own raw(). The limit, Content-Encoding inflation 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-mode SyntaxError, and a malformed one gets the JSON.parse error, each with the same expose, statusCode, status, body and type properties. 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 through json() as before. Parsing stays in the adapter's translation step, ahead of routing, and every request still runs http:request.
  2. The Streamable HTTP transport and Ajv on the first request (item 2). Both costs could move, so both changes ship:
    • 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 (elicitInput). FrontMCP never calls elicitInput, but built one Ajv per session, and per request on a stateless web-standard server. Every MCP server FrontMCP creates now comes from createMcpServer(), whose validator builds the same AjvJsonSchemaValidator the first time it validates. @frontmcp/protocol exports AjvJsonSchemaValidator for it, from the module McpServer comes 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.
    • perf(sdk,protocol): load the Streamable HTTP server transport with the first HTTP session #924 moved the transport, and with it @hono/node-server and Node's bundled undici, from startup to the first session, so a server's first initialize waited for it. A scope that serves Streamable HTTP (streamable, stateless or json in transport.protocol, which every preset enables) now loads it right after the server starts listening (onServerStarted, on the next setImmediate). 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.
  3. The MCP client under ESM (item 3). perf(protocol): serve the MCP client and its transports from @frontmcp/protocol/client #942 moved the MCP client to @frontmcp/protocol/client, whose require() 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. Its import variant, 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-client external 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 first connect() or remote app, as a CommonJS one does.
  4. zod's locales (item 4): not fixed upstream, no change here. zod 4.6.7, the newest release, still loads all 64 locale files under both require('zod') and import 'zod': v4/classic/external and v4/core re-export ../locales/index as 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 only en) was closed in April, pointing to a Turbopack fix. #6633 was closed as not planned with "not fixable on our side without removing z.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 with node, against npm packed builds of origin/main (42b36fa1b, 1.10.16) and of this branch at each item's commit, installed into scratch copies of the implementation and built with npm run build. The ESM server is the same add tool 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:

  • First request: spawn the server, wait until its port accepts a TCP connection, wait 1 s, then time initialize. The first tools/call is timed after notifications/initialized.
  • Cold start: the benchmark's own measure: spawn to the first tools/call response, initialize included, with the client retrying from the moment the process starts.
  • Spawn to listening is the time until the port accepts a connection.
  • RSS is the process tree's, read 1 s after listening and again after two tools/calls. MB is 10^6 bytes.
  • "Per round" is the median, over rounds, of the difference between two builds' medians in the same round.

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/main + UTF-8 JSON (item 1) + Ajv on first use (item 2) + transport on listen (item 2) = this PR
First request (initialize), p50 / p90 41.4 / 51.9 ms 38.1 / 53.3 ms 32.7 / 45.0 ms 20.2 / 26.8 ms
… per round, against the column before −3.2 ms −2.9 ms −13.1 ms
… per round, against origin/main −19.3 ms (−28.7 to −14.2)
First tools/call, p50 4.7 ms 5.6 ms 4.8 ms 4.9 ms
Cold start, p50 / p90 395.2 / 585.6 ms 384.0 / 626.7 ms 427.0 / 608.7 ms 405.3 / 591.2 ms
… per round, against the column before −25.7 ms −13.4 ms −18.2 ms
Spawn to listening, p50 324.9 ms 335.7 ms 324.6 ms 372.2 ms
RSS listening / after two calls 129.3 / 135.8 MB 129.3 / 135.6 MB 129.8 / 135.9 MB 135.3 / 137.5 MB

Item 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 --require preload 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/main + UTF-8 JSON (item 1) + MCP client on connect (item 3) this PR
Cold start, p50 / p90 435.5 / 887.1 ms 437.0 / 683.0 ms 397.9 / 626.6 ms 377.8 / 933.5 ms
… per round, against the column before −14.6 ms −17.5 ms +0.9 ms
… per round, against origin/main −45.8 ms (−257 to +287)
Spawn to listening, p50 397.5 ms 394.9 ms 368.1 ms 372.2 ms
First request (initialize), p50 26.7 ms 22.7 ms 23.5 ms 20.3 ms
… per round, against the column before −3.6 ms +0.7 ms −5.1 ms
RSS listening / after two calls 153.1 / 156.2 MB 152.8 / 155.9 MB 149.1 / 151.8 MB 149.9 / 152.5 MB

Under 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.registerHooks preload 0.8 s after the server listens and again after its first initialize and tools/call; exact, so no load average applies):

origin/main This PR
CommonJS HTTP server, listening 405 416
… after its first tools/call 426 416
… of which loaded by the first JSON body: iconv-lite's encodings and safer-buffer 11 0
CommonJS stdio server, after its first tools/call 275 276, no HTTP transport
ESM HTTP server, listening 641 547
… of which the MCP client modules (client/index, streamableHttp, sse, auth, stdio) 5 0
zod locale files, CommonJS / ESM server 64 / 128 64 / 128

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, sseKeepAlive and shared/mediaType, @hono/node-server's two files, a hono helper, content-type and node:http2), plus third-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 and safer-buffer. The stdio server gains only protocol-client.js. An ESM server loads 94 fewer modules at startup: the MCP client and what comes only with it (cross-spawn and its dependencies, eventsource, pkce-challenge, the OAuth client, and zod-to-json-schema with 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/index and its 63 locales after v4/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.js from both builds). The # subpath imports of @frontmcp/utils and @frontmcp/protocol (#mcp-server, #mcp-streamable-http, #env, …) resolve to their CommonJS files under import, and those require the 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)

Title: Importing zod in Node loads every locale module

require('zod') and import 'zod' load v4/locales/index and all 63 locales (zod 4.6.7: 64 of the 95 files require('zod') loads), because v4/classic/external and v4/core/index re-export ../locales/index.js as a namespace (export * as locales). A server that runs zod from node_modules, without a bundler, pays for all of them at startup, though only en is used unless z.config(z.locales.xx()) is called. #6384 fixed this for Rollup and Webpack, but Node evaluates every module a namespace re-export names. Loading them on demand would fix it: lazy getters on locales in the CommonJS build, a zod/locales/<name> entry with locales dropped from the root in a future major, or en imported directly by the classic entry.

User-facing change

  • What changed: no API, option or default. @frontmcp/protocol, which is internal, exports AjvJsonSchemaValidator. Two things can be observed:
    • An HTTP server that serves Streamable HTTP now loads the transport right after it starts listening, not with its first session. Its RSS before the first session is 4 to 6 MB higher; after the first calls it is 1.4 to 2.4 MB higher (medians across the runs below).
    • In development mode (NODE_ENV other than production), Express's error page for a malformed or non-object UTF-8 JSON body prints the same message line with stack frames in @frontmcp/sdk instead of body-parser/lib/types/json.js.
  • Before → after: the production answers to every JSON, form and other body are byte-for-byte the same: status, content type and body, including the 413 envelope and the HTML error pages. A later error handler also receives the same error: name, message, own keys and their values.
  • frontmcp.dev pages affected (if known): none known.
  • Skills catalog entries updated: none; no entry describes when these modules load or how bodies are decoded.

Tests

  • Pinned first, in their own commit, against the unchanged code: 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-8 in three spellings, an upper-case type, one and two byte order marks, leading whitespace, an empty body, whitespace only, string, number and true literals, 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 cover utf-16le, latin1, utf8, utf-9, a parameter other than charset, 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.
  • New: 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.
  • New: utf8-json.parser.spec.ts. It compares the parser followed by json() with json() 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.body after 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 of utf8-json.parser.ts: 97.7% of statements, 100% of branches and functions (the uncovered line is a return after JSON.parse of invalid JSON, which cannot be reached).
  • The monorepo resolves body-parser 2.2.2 for Express 4 too, so the Express 4 case was checked by hand: in a scratch install of Express 4.22.1 (body-parser 1.20.9, iconv-lite 0.4.24) and of Express 5, the adapter's parsers with and without the UTF-8 parser give the same status, content type, body, error (name, message, first stack line, own keys and values) and req.body for 52 requests like the pinned ones (among them null, a lone byte order mark, charset=utf-8; foo=bar, application/json ; charset=utf-8, a tab before charset, GZIP, identity, gzip of malformed JSON, invalid UTF-8 split across chunks and a constructor.prototype key), and for bodies an earlier middleware already read, flagged as read or replaced.
  • Before writing the parser, I checked UTF-8 decoding equivalence on 200,000 random byte strings split into random chunks: iconv-lite 0.7.3 and 0.4.24 (body-parser 2 and 1) decode chunk by chunk exactly as 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.
  • New: mcp-server.factory.spec.ts. A server from createMcpServer() builds no Ajv until it validates an elicitation result and then builds one, while a plain McpServer builds one in its constructor. It accepts a matching result, and rejects a mismatching one with the same McpError as the MCP SDK's default.
  • New: front-mcp/__tests__/streamable-http-preload.entry-points.spec.ts. With the real entry points, the transport loads after start() has returned and written the frontmcp dev ready line, and not at all for createHandler(), createFetchHandler() or runStdio(). Loading it as soon as the scope is built fails all four.
  • New: streamable-http-preload.spec.ts. The transport loads once the server listens, for the default, legacy, modern, stateless-api and full presets and for streamable, stateless or json alone. 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.
  • New: third-party/__tests__/protocol-client.spec.ts (both variants hand back @frontmcp/protocol/client's classes). libs/protocol exports.spec.ts: the root and both #mcp-server variants export the MCP SDK's AjvJsonSchemaValidator.
  • New e2e: apps/e2e/demo-e2e-bundle-size/e2e/node-esm-client.e2e.spec.ts (with helpers/connect-direct-client.mjs). An ESM process that imports the built SDK loads no MCP client module until connect(), then loads it, and the tool call answers. Against the origin/main build the helper sees 5 client modules before connect().
  • New e2e: apps/e2e/demo-e2e-bundle-size/e2e/node-esm-validator.e2e.spec.ts (with helpers/loaded-modules.mjs). An ESM process that imports the built SDK loads the MCP SDK's validation/ajv-provider.js only from the build its server/index.js comes from. It failed while @frontmcp/protocol exported the validator from the MCP SDK directly: the ESM build loaded both builds' copies.
  • On the final commit, 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 protocol reports 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.
  • e2e, one at a time, on the final commit: bundle-size 12, standalone 53, direct 88, remote 31, cloudflare 16 (5 skipped), elicitation 38, stdio-transport 27, dts-consumer 11, security 28 and serverless 17 pass. transport-recreation 40 and errors 14 passed before the last two commits, which change only where @frontmcp/protocol takes AjvJsonSchemaValidator from and add tests.
  • node scripts/fix-unused-imports.mjs origin/main changes nothing, and Prettier passes on the changed files. tsc --noEmit over the new specs reports only the known module-augmentation error in common/metadata/flow.metadata.ts. The build type-checks the library code.
  • By hand, on the built packages: a stdio server loads 276 modules (275 before) and no HTTP transport. The development-mode error page for {"a": has the same first line before and after. The CommonJS, browser and browser-ESM builds resolve #protocol-client to the static module, and the ESM bundle contains import("@frontmcp/protocol/client"). In the built dist/package.json, #protocol-client resolves to the static module under require (with any condition), browser and worker, and to the import() one under import alone or with workerd, edge-light or module-sync; only the CommonJS build resolves it at run time, with require. A CommonJS process loads no MCP client module at startup.

Summary by CodeRabbit

  • New Features

    • SDK clients load protocol client components on demand.
    • Schema validation is available through protocol package exports and is reused by MCP servers.
    • Streamable HTTP transport can be prepared when a server starts.
  • Bug Fixes

    • JSON request bodies support UTF-8 parsing, including leading byte-order marks.
    • Improved handling of malformed and oversized JSON request bodies.

…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.
@frontegg-david
frontegg-david marked this pull request as ready for review November 2, 2026 16:18
@frontegg-david

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between aae2933 and 51bae54.

Files selected for processing: 9
  • 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/src/front-mcp/front-mcp.ts
  • libs/sdk/src/remote-mcp/__tests__/mcp-client.service.lazy-transports.spec.ts
  • libs/sdk/src/remote-mcp/mcp-client.service.ts
  • libs/sdk/src/scope/scope.instance.ts
  • libs/sdk/src/transport/in-memory-server.ts
  • libs/sdk/src/transport/web-standard-mcp.ts
  • tsconfig.base.json

Included review availability: This review used your included allowance. 1 included review remains after this review. Your included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.

Walkthrough

The SDK now loads protocol clients and Streamable HTTP transport at selected use points. MCP server creation uses a factory with lazy, reusable Ajv validation. The Express adapter adds UTF-8 JSON parsing. Tests cover module loading, startup timing, validation, and request parsing.

Changes: Protocol client loading

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

Suggested reviewers: alexmercerpo

Merge Risk: Minimal · up to 51bae

Compressed JSON remains supported, and no merge-blocking risk was established.

Pre-merge checks: Passed 5
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main performance changes: reducing first-request and ESM startup costs caused by dependencies.
Description check Passed 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 p…
Docstring Coverage Passed 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. Documentati…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
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

@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor
Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Fix comments: Fix CodeRabbit comments on this PR

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Performance Test Results

Status: ✅ All tests passed

Summary

Project Tests Passed Warnings Failed Leaks
✅ demo-e2e-agents 4 4 0 0 0
✅ demo-e2e-cache 11 11 0 0 0
✅ demo-e2e-codecall 4 4 0 0 0
✅ demo-e2e-config 4 4 0 0 0
✅ demo-e2e-direct 3 3 0 0 0
✅ demo-e2e-elicitation 1 1 0 0 0
✅ demo-e2e-errors 4 4 0 0 0
✅ demo-e2e-hooks 3 3 0 0 0
✅ demo-e2e-multiapp 4 4 0 0 0
✅ demo-e2e-notifications 3 3 0 0 0
✅ demo-e2e-openapi 2 2 0 0 0
✅ demo-e2e-providers 4 4 0 0 0
✅ demo-e2e-public 4 4 0 0 0
✅ demo-e2e-redis 15 15 0 0 0
✅ demo-e2e-remember 4 4 0 0 0
✅ demo-e2e-remote 5 5 0 0 0
✅ demo-e2e-serverless 2 2 0 0 0
✅ demo-e2e-skills 15 15 0 0 0
✅ demo-e2e-standalone 2 2 0 0 0
✅ demo-e2e-transport-recreation 3 3 0 0 0
✅ demo-e2e-ui 4 4 0 0 0

Total: 101 tests across 21 projects

📊 View full report in workflow run


Generated at: 2026-11-02T17:37:05.914Z
Commit: 16d3d37a

…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.
@frontegg-david
frontegg-david marked this pull request as ready for review November 2, 2026 16:48
…rf/p02-dependency-costs

# Conflicts:
#	libs/sdk/src/transport/web-standard-mcp.ts
@frontegg-david
frontegg-david merged commit 83842a5 into main Nov 2, 2026
46 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants