Repository navigation
feat(sdk): jobs.store: false, and no Redis or SQLite store in a browser build - #1028
Conversation
…uild
jobs.store: false keeps job runs and dynamic definitions in memory although
the server has a top-level redis or sqlite. tasks.store: false and
elicitation.store: false do the same for the two other stores that had no
memory switch; transport.persistence: false and
elicitation.persistent.storage: { type: 'memory' } stay as they were.
In the browser build a store that would open Redis over TCP or a SQLite
file, its own or the top-level one, fails startup before any store opens
with BrowserStoreUnavailableError, which names each store's switch. The
top-level sqlite is not resolved there (no default path, no mkdir).
… keeps its tasks off the top-level sqlite The lite config parser of CLI mode had no tasks key, so every tasks option was dropped there: tasks.store: false, tasks.sqlite, tasks.redis, tasks.runner and tasks.enabled. It now validates tasks with the full server schema.
…the job stores open with the Redis client The job stores take no provider: they open jobs.store.redis with ioredis. In the browser build a Vercel KV there was not refused, and the stores fell back to memory with a warning instead of failing startup.
…is its config keeps A page is one instance and reads no VAULT_SECRET, yet with the store switches set and the top-level redis kept for its Node server, it warned that multi-round tools restart on another instance. The browser build of create() now starts with no warning. The catalog also says what a browser did up to 1.10: Redis stores fell back to memory with a warning, SQLite stores and persistent questions failed.
…asks schema and the page's quiet start add
…ores-browser The browser budgets keep this branch's values: they are main's budgets plus this branch's measured growth, and #F01 adds none.
|
Warning Review limit reachedYour organization has reached its usage spending cap. Adjust your spending cap in the billing tab. Next included review available in 33 minutes. View limit details
Walkthrough
Merge Risk:
|
|
@coderabbitai review |
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-03T09:31:47.066Z |
…o feat/f02-memory-stores-browser
) * perf(sdk,utils,auth): keep server-only code out of browser builds @frontmcp/utils and @frontmcp/auth now ship a browser build of one ES module per source file, under a `browser` export condition, built like the SDK's. A bundler keeps only the modules a page reaches, so the stdio bridge, process supervision, the change feed, people sign-in, the auth pages and the Redis session stores stay out of it. A `worker` condition listed first keeps Workers on the ESM bundle; Node resolves as before. utils' rsaVerify imports its WebCrypto path instead of require('./browser'). The SDK loads CliTaskRunner only with tasks.runner: 'cli', and the file log transport only in createForCli() and runStdio(). create() + one tool: 1,339,977 -> 1,292,553 bytes (esbuild, minified); with WebMcpPlugin 1,347,154 -> 1,299,736; Vite initial chunk 964,695 -> 761,067. The budgets follow, and the bundle-size e2e checks that none of these modules reach either bundle or the initial chunk. * test(e2e,scripts): pin the CommonJS single-file utils and auth out of browser bundles A require() of @frontmcp/utils or @frontmcp/auth in a bundler with the browser condition and without `module` (the bundle-size e2e's esbuild config) resolves dist/index.js. The never-bundled list only named dist/esm/index.mjs, so a second copy pulled in that way went unnoticed. It now names both single-file builds. The export-condition check also pins the require() side: a browser require() without `module` (jest-environment-jsdom) keeps the CommonJS build, and a worker gets the same files as Node. * test(e2e): set the browser budgets from this branch's sizes on top of #1028 With #1028 merged in, create() + one tool is 1,294,971 bytes, with WebMcpPlugin 1,302,155, and Vite's initial chunk 761,771. Each budget is that size plus the headroom it was set with (536, 224 and 132 bytes): 1,295,507, 1,302,379 and 761,903.
… stores, which the server validates when it starts (#1042) * 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 * 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. * 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 * 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 * 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 * 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. * 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 * 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 * 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`. * 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. * test(bundle-size): pin the names and kinds of the SDK's root exports under require() and import * test(bundle-size): set up the SDK's unused subsystems and OAuth flows on first use, with their hooks still running * perf(sdk): set up jobs, workflows, channels, remote MCP and the ESM loader when a CommonJS process first reads their exports * perf(sdk): register the OAuth flows by name and route, and load each one with the first request that reaches it * fix(sdk): name the root exports the CommonJS build loads on first use, so an ESM import of that build still sees them * test(bundle-size): hook the tools of a remote app and an ESM package under the SDK's CommonJS build * fix(sdk): run a flow's route guards once per request when its class also has a static canActivate * fix(sdk): answer a request to a deferred flow whose class fails to load with a 500 canActivate awaited the class load outside the Express middleware's error handling and before the Web fetch handler's, so a load that failed escaped as an unhandled rejection (Express 4) or a thrown fetch handler. The flow now claims its route, and its run throws the load error, which both adapters answer with a 500. * test(e2e): pin the root exports main gained since this branch was cut 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. * 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): pin the root exports main gained from the feature stack 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. * fix(sdk): ship the JavaScript of the transport, job/tools and workflow/tools subpath exports The published exports map offers @frontmcp/sdk/transport, /job/tools and /workflow/tools, but the build emitted only their declarations, so require() threw MODULE_NOT_FOUND and import threw ERR_MODULE_NOT_FOUND. The build now writes each subpath's require and import files as a re-export of the package root, with the names its source barrel exports, so a subpath hands back the root's own classes from whichever root build the importer resolves, and never a second copy of the SDK. The root entry exports the seven values only ./transport had: the recreateable and SSE transports and the event stores, which the CommonJS build loads on first read, and buildScopedServerOptions. * feat(sdk): jobs.store: false, and no Redis or SQLite in the browser build jobs.store: false keeps job runs and dynamic definitions in memory although the server has a top-level redis or sqlite. tasks.store: false and elicitation.store: false do the same for the two other stores that had no memory switch; transport.persistence: false and elicitation.persistent.storage: { type: 'memory' } stay as they were. In the browser build a store that would open Redis over TCP or a SQLite file, its own or the top-level one, fails startup before any store opens with BrowserStoreUnavailableError, which names each store's switch. The top-level sqlite is not resolved there (no default path, no mkdir). * perf(sdk,utils,auth): keep server-only code out of browser builds @frontmcp/utils and @frontmcp/auth now ship a browser build of one ES module per source file, under a `browser` export condition, built like the SDK's. A bundler keeps only the modules a page reaches, so the stdio bridge, process supervision, the change feed, people sign-in, the auth pages and the Redis session stores stay out of it. A `worker` condition listed first keeps Workers on the ESM bundle; Node resolves as before. utils' rsaVerify imports its WebCrypto path instead of require('./browser'). The SDK loads CliTaskRunner only with tasks.runner: 'cli', and the file log transport only in createForCli() and runStdio(). create() + one tool: 1,339,977 -> 1,292,553 bytes (esbuild, minified); with WebMcpPlugin 1,347,154 -> 1,299,736; Vite initial chunk 964,695 -> 761,067. The budgets follow, and the bundle-size e2e checks that none of these modules reach either bundle or the initial chunk. * fix(sdk): no unused-sqlite warning in the browser build, which opens no SQLite file * fix(sdk): leave a type re-exported without export type out of a subpath's re-export build-subpath-reexports read a barrel's export names without bundling it, so esbuild could not tell a type re-exported with a plain export { } from a value. The type was written into the subpath's files: importing the subpath then failed with "does not provide an export named", and require() gave undefined for it. The script now bundles the barrel with its packages external and reads the names from the bundled code, which keeps only values; a local export * is read the same way, and only an export * from another package fails the build. Adds scripts/build-subpath-reexports.test.mjs. * test(e2e): type the SDK subpaths and their root exports in the declaration consumer No dts-consumer fixture imported @frontmcp/sdk/transport, /job/tools or /workflow/tools, or the seven values the root now exports for them, so a broken types target or a root export that types as something else went unnoticed. subpath-exports-must-compile.ts compiles them under nodenext with skipLibCheck off, checks each subpath value against the root's type and that createEventStore is not any. It fails on the base, where the root lacks the seven values. * fix(sdk): the CLI host reads the tasks options, so tasks.store: false keeps its tasks off the top-level sqlite The lite config parser of CLI mode had no tasks key, so every tasks option was dropped there: tasks.store: false, tasks.sqlite, tasks.redis, tasks.runner and tasks.enabled. It now validates tasks with the full server schema. * fix(sdk): a browser refuses jobs.store.redis on Vercel KV too, which the job stores open with the Redis client The job stores take no provider: they open jobs.store.redis with ioredis. In the browser build a Vercel KV there was not refused, and the stores fell back to memory with a warning instead of failing startup. * fix(sdk): a page warns of no per-process requestState key for the redis its config keeps A page is one instance and reads no VAULT_SECRET, yet with the store switches set and the top-level redis kept for its Node server, it warned that multi-round tools restart on another instance. The browser build of create() now starts with no warning. The catalog also says what a browser did up to 1.10: Redis stores fell back to memory with a warning, SQLite stores and persistent questions failed. * test(e2e): raise the browser bundle budgets by the 47 bytes the CLI tasks schema and the page's quiet start add * docs(skills): the generated CLI reads the tasks options * test(e2e,scripts): pin the CommonJS single-file utils and auth out of browser bundles A require() of @frontmcp/utils or @frontmcp/auth in a bundler with the browser condition and without `module` (the bundle-size e2e's esbuild config) resolves dist/index.js. The never-bundled list only named dist/esm/index.mjs, so a second copy pulled in that way went unnoticed. It now names both single-file builds. The export-condition check also pins the require() side: a browser require() without `module` (jest-environment-jsdom) keeps the CommonJS build, and a worker gets the same files as Node. * test(e2e): set the browser budgets from this branch's sizes on top of #1028 With #1028 merged in, create() + one tool is 1,294,971 bytes, with WebMcpPlugin 1,302,155, and Vite's initial chunk 761,771. Each budget is that size plus the headroom it was set with (536, 224 and 132 bytes): 1,295,507, 1,302,379 and 761,903. * fix(sdk): CLI mode validates jobs and the top-level sqlite as the server does The lite config parser of CLI mode passed jobs and sqlite through as z.any(), so the generated CLI took an invalid jobs.store for unset and kept the runs in the top-level redis or sqlite, ignored a wrong runner, defaultTimeoutMs or admission, took an empty sqlite.path for the default path and kept its tasks unencrypted on a missing sqlite.encryption.secret. Both keys now use the server's own schemas, so the same configs fail at startup with the same messages. Fixes #1032 * fix(utils,auth,protocol): give a browser Web Worker the browser variants webpack's target: 'webworker', Parcel's workers, and esbuild or Rollup given the `worker` condition resolve `worker` and `browser`. @frontmcp/utils mapped `worker` to its Node variants in its imports (#crypto-provider, #async-context, #event-emitter, #env, #runtime-context, #path) and its root export, @frontmcp/auth in its root export, and @frontmcp/protocol in #mcp-streamable-http, #mcp-server and #server-types, so the SDK failed in a browser Web Worker with `process is not defined` or a missing Node built-in. The Node variants now take the `workerd` condition, which wrangler and @cloudflare/vite-plugin pass with `worker` and `browser`, so a Cloudflare Worker resolves the same files as before (a wrangler dry run bundles the same 1,652 inputs into a byte-identical worker.js) and a browser Web Worker gets the browser variants, as a page does. The `worker` keys that already pointed at stubs stay. Fixes #1031 * fix(sdk,plugins): refuse plugin stores on Redis or SQLite in a browser build CachePlugin and RememberPlugin on global-store took the top-level redis in a page, which has no TCP socket: the store fell back to memory with a warning or failed on its first call. refusePluginStoreInBrowser(), from @frontmcp/sdk, throws BrowserStoreUnavailableError in a browser for a plugin store that would open Redis or SQLite, naming the plugin option that keeps it in memory. CachePlugin, RememberPlugin, ApprovalPlugin and ResultHandlesPlugin call it where they build their store. Node servers are unchanged. Fixes #1033 * fix(sdk): name a plugin's memory option when the plugin is named like an SDK store * test(e2e): read the CLI's sqlite keys with a helper of demo-e2e-cli-exec, and build the CLI its tests drive Importing the demo-e2e-jobs helper made demo-e2e-cli-exec depend on that app in the Nx graph, so its tests built it first. * docs(skills): a jobs block needs enabled, which the server and now the CLI refuse without One create-job example and its comment said enabled was implied, but the decorator refused the block. * test(scripts): check that every package gives a browser Web Worker the files it gives a page The guard listed the splits by hand, so it missed the SDK's #protocol-client and would not catch a new split that maps `worker` to a Node file. It now resolves every imports and exports entry of every workspace package with and without `worker`, for import and require. * docs(skills): only utils and protocol gave `worker` their Node builds up to 1.10 @frontmcp/auth's root export had no `worker` key on release/1.10.x; it came with the browser builds. * chore: name `workerd`, not `worker`, where comments and fixtures map the Node variants * docs(skills): approval and result-handles fell back to memory in a page, they never opened a SQLite file * test(e2e): type PluginStoreInBrowser and BrowserStoreUnavailableError's plugin memory options in a strict consumer * fix(sdk): CLI mode keeps its tasks in the Redis a redis url names, as the server does The lite config parser of CLI mode passed the top-level redis through as z.any(), so a redis: { url } reached createTaskStore raw. The factory mapped redis into a storage config only when it had a host, and fell back to REDIS_URL / REDIS_HOST or memory otherwise, so the generated CLI kept its tasks where its detached workers could not find them. The lite parser now validates redis with the server's schema, and createTaskStore and createElicitationStore normalize their redis option with that schema, so every form reaches the store with its password, database and TLS, and an unusable one fails at startup. A build still reads a config whose redis url comes from an unset variable. Fixes #1035 * fix(sdk): keep Node built-ins out of the browser build The SDK's browser build imported node:cluster, node:net, node:path, node:http, node:stream and node:os from the shared local server and Unix socket code, so a webpack 5 build of create() + one tool, for a page or a Web Worker, stopped with UnhandledSchemeError unless the app stubbed Node built-ins. Two chains reached them: duty.lifecycle and the persistent question store import findReplaceableDirectory from unix-socket-guard, and runShared() imports the shared server with import(), which webpack follows. findReplaceableDirectory moves to its own module, which uses @frontmcp/utils' dirname, so the socket guard is left to the Express host. runShared() loads the shared server through a #shared-server subpath import whose browser and worker variant is a stub: the browser builds no longer contain the shared server, and runShared() in a page rejects with BrowserEntryPointUnavailableError (BROWSER_ENTRY_POINT_UNAVAILABLE) instead of "process is not defined". runUnixSocket() refuses in a browser with the same error before it builds a server. Node builds are unchanged. Fixes #1034 * test(e2e): close the Redis client the monitor connection was opened from * fix(sdk): a build reads a server without any of its stores, so it opens no task store and needs none of their secrets The manifest boot left out the top-level redis before validating the config, but still validated sqlite, jobs.store and tasks.redis / tasks.sqlite, so a config object whose sqlite.encryption.secret or tasks.redis.url comes from an unset variable stopped frontmcp build, and it opened tasks.redis and created the tasks.sqlite file. Every store is now left out before the config is validated: redis, pubsub, sqlite, jobs.store, tasks.redis and tasks.sqlite. * test(e2e): type BrowserEntryPointUnavailableError in the declarations consumer The new error and BrowserRefusedEntryPoint had no consumer coverage, unlike BrowserStoreUnavailableError. The shared server fixture now narrows to the error, reads its code and entryPoint, and rejects an entry point a browser does not refuse. * perf(sdk): load BrowserEntryPointUnavailableError only when runUnixSocket() refuses runUnixSocket()'s browser check imported the error class statically, so every create() in a page carried it. The class now loads on the refusal path, as runShared()'s browser stub already does, and create() + one tool shrinks by about 300 bytes. Budgets are the new sizes plus the same headroom. * fix(sdk,cli): a build reads a @FrontMcp class without validating its stores, which the server validates when it starts The decorator validated the whole config when the entry loaded, before the build could leave the stores out, so a @FrontMcp class whose redis.url or sqlite.encryption.secret comes from a variable unset on the build machine failed --target cli and --target mcpb, and the other targets wrote no manifest. The build now loads the entry with FRONTMCP_BUILD_EXTRACT beside FRONTMCP_SCHEMA_EXTRACT. Under it the decorator validates the config without its store settings (redis, pubsub, sqlite, jobs.store, tasks.redis, tasks.sqlite, transport.persistence, throttle.storage) and records them as given; the manifest boot leaves them out with the same list. The generated CLI sets only FRONTMCP_SCHEMA_EXTRACT while it loads the server, so it, and every other way of running the server, validates them at startup as before. Fixes #1041 * fix(sdk): leave a store setting's parent that is an array as given, for the schema to refuse * fix(sdk): a CLI host run with FRONTMCP_BUILD_EXTRACT set validates the stores with the server's schema A class loaded under the build flag records its store settings unvalidated, and the CLI host's lite schema passes transport.persistence through, so a generated CLI whose environment had the flag opened a transport session store the server would refuse, such as an SQLite file whose encryption secret was unset. * fix(sdk): a build also reads a server without its event store, elicitation Redis and auth stores transport.eventStore, elicitation.redis, auth.tokenStorage and auth.secureStore join the shared list of store settings. A @FrontMcp class whose elicitation or event store Redis, or whose auth token storage, takes a variable the build machine had not set stopped the build, and the manifest boot wrote the SQLite files of auth.tokenStorage and auth.secureStore, for a config object too. The server validates them when it starts, as before. * test(cli): setBuildExtractFlags puts back a flag's previous value * test(e2e): raise the WebMcpPlugin bundle budget by the 143 bytes the review fixes add The CLI host's check under FRONTMCP_BUILD_EXTRACT and the four added store settings grow both create() bundles by 143 bytes: create() + one tool is 1,295,491 (budget 1,295,679, unchanged), create() + WebMcpPlugin + one tool is 1,302,674 (budget 1,302,551 + 143 = 1,302,694).
…rowser store refusal, no stores opened by a build, steadier tests (#1043) * 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 * 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. * 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 * 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 * 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 * 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. * 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 * 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 * 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`. * 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. * test(bundle-size): pin the names and kinds of the SDK's root exports under require() and import * test(bundle-size): set up the SDK's unused subsystems and OAuth flows on first use, with their hooks still running * perf(sdk): set up jobs, workflows, channels, remote MCP and the ESM loader when a CommonJS process first reads their exports * perf(sdk): register the OAuth flows by name and route, and load each one with the first request that reaches it * fix(sdk): name the root exports the CommonJS build loads on first use, so an ESM import of that build still sees them * test(bundle-size): hook the tools of a remote app and an ESM package under the SDK's CommonJS build * fix(sdk): run a flow's route guards once per request when its class also has a static canActivate * fix(sdk): answer a request to a deferred flow whose class fails to load with a 500 canActivate awaited the class load outside the Express middleware's error handling and before the Web fetch handler's, so a load that failed escaped as an unhandled rejection (Express 4) or a thrown fetch handler. The flow now claims its route, and its run throws the load error, which both adapters answer with a 500. * test(e2e): pin the root exports main gained since this branch was cut 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. * 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): pin the root exports main gained from the feature stack 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. * fix(sdk): ship the JavaScript of the transport, job/tools and workflow/tools subpath exports The published exports map offers @frontmcp/sdk/transport, /job/tools and /workflow/tools, but the build emitted only their declarations, so require() threw MODULE_NOT_FOUND and import threw ERR_MODULE_NOT_FOUND. The build now writes each subpath's require and import files as a re-export of the package root, with the names its source barrel exports, so a subpath hands back the root's own classes from whichever root build the importer resolves, and never a second copy of the SDK. The root entry exports the seven values only ./transport had: the recreateable and SSE transports and the event stores, which the CommonJS build loads on first read, and buildScopedServerOptions. * feat(sdk): jobs.store: false, and no Redis or SQLite in the browser build jobs.store: false keeps job runs and dynamic definitions in memory although the server has a top-level redis or sqlite. tasks.store: false and elicitation.store: false do the same for the two other stores that had no memory switch; transport.persistence: false and elicitation.persistent.storage: { type: 'memory' } stay as they were. In the browser build a store that would open Redis over TCP or a SQLite file, its own or the top-level one, fails startup before any store opens with BrowserStoreUnavailableError, which names each store's switch. The top-level sqlite is not resolved there (no default path, no mkdir). * perf(sdk,utils,auth): keep server-only code out of browser builds @frontmcp/utils and @frontmcp/auth now ship a browser build of one ES module per source file, under a `browser` export condition, built like the SDK's. A bundler keeps only the modules a page reaches, so the stdio bridge, process supervision, the change feed, people sign-in, the auth pages and the Redis session stores stay out of it. A `worker` condition listed first keeps Workers on the ESM bundle; Node resolves as before. utils' rsaVerify imports its WebCrypto path instead of require('./browser'). The SDK loads CliTaskRunner only with tasks.runner: 'cli', and the file log transport only in createForCli() and runStdio(). create() + one tool: 1,339,977 -> 1,292,553 bytes (esbuild, minified); with WebMcpPlugin 1,347,154 -> 1,299,736; Vite initial chunk 964,695 -> 761,067. The budgets follow, and the bundle-size e2e checks that none of these modules reach either bundle or the initial chunk. * fix(sdk): no unused-sqlite warning in the browser build, which opens no SQLite file * fix(sdk): leave a type re-exported without export type out of a subpath's re-export build-subpath-reexports read a barrel's export names without bundling it, so esbuild could not tell a type re-exported with a plain export { } from a value. The type was written into the subpath's files: importing the subpath then failed with "does not provide an export named", and require() gave undefined for it. The script now bundles the barrel with its packages external and reads the names from the bundled code, which keeps only values; a local export * is read the same way, and only an export * from another package fails the build. Adds scripts/build-subpath-reexports.test.mjs. * test(e2e): type the SDK subpaths and their root exports in the declaration consumer No dts-consumer fixture imported @frontmcp/sdk/transport, /job/tools or /workflow/tools, or the seven values the root now exports for them, so a broken types target or a root export that types as something else went unnoticed. subpath-exports-must-compile.ts compiles them under nodenext with skipLibCheck off, checks each subpath value against the root's type and that createEventStore is not any. It fails on the base, where the root lacks the seven values. * fix(sdk): the CLI host reads the tasks options, so tasks.store: false keeps its tasks off the top-level sqlite The lite config parser of CLI mode had no tasks key, so every tasks option was dropped there: tasks.store: false, tasks.sqlite, tasks.redis, tasks.runner and tasks.enabled. It now validates tasks with the full server schema. * fix(sdk): a browser refuses jobs.store.redis on Vercel KV too, which the job stores open with the Redis client The job stores take no provider: they open jobs.store.redis with ioredis. In the browser build a Vercel KV there was not refused, and the stores fell back to memory with a warning instead of failing startup. * fix(sdk): a page warns of no per-process requestState key for the redis its config keeps A page is one instance and reads no VAULT_SECRET, yet with the store switches set and the top-level redis kept for its Node server, it warned that multi-round tools restart on another instance. The browser build of create() now starts with no warning. The catalog also says what a browser did up to 1.10: Redis stores fell back to memory with a warning, SQLite stores and persistent questions failed. * test(e2e): raise the browser bundle budgets by the 47 bytes the CLI tasks schema and the page's quiet start add * docs(skills): the generated CLI reads the tasks options * test(e2e,scripts): pin the CommonJS single-file utils and auth out of browser bundles A require() of @frontmcp/utils or @frontmcp/auth in a bundler with the browser condition and without `module` (the bundle-size e2e's esbuild config) resolves dist/index.js. The never-bundled list only named dist/esm/index.mjs, so a second copy pulled in that way went unnoticed. It now names both single-file builds. The export-condition check also pins the require() side: a browser require() without `module` (jest-environment-jsdom) keeps the CommonJS build, and a worker gets the same files as Node. * test(e2e): set the browser budgets from this branch's sizes on top of #1028 With #1028 merged in, create() + one tool is 1,294,971 bytes, with WebMcpPlugin 1,302,155, and Vite's initial chunk 761,771. Each budget is that size plus the headroom it was set with (536, 224 and 132 bytes): 1,295,507, 1,302,379 and 761,903. * fix(sdk): CLI mode validates jobs and the top-level sqlite as the server does The lite config parser of CLI mode passed jobs and sqlite through as z.any(), so the generated CLI took an invalid jobs.store for unset and kept the runs in the top-level redis or sqlite, ignored a wrong runner, defaultTimeoutMs or admission, took an empty sqlite.path for the default path and kept its tasks unencrypted on a missing sqlite.encryption.secret. Both keys now use the server's own schemas, so the same configs fail at startup with the same messages. Fixes #1032 * fix(utils,auth,protocol): give a browser Web Worker the browser variants webpack's target: 'webworker', Parcel's workers, and esbuild or Rollup given the `worker` condition resolve `worker` and `browser`. @frontmcp/utils mapped `worker` to its Node variants in its imports (#crypto-provider, #async-context, #event-emitter, #env, #runtime-context, #path) and its root export, @frontmcp/auth in its root export, and @frontmcp/protocol in #mcp-streamable-http, #mcp-server and #server-types, so the SDK failed in a browser Web Worker with `process is not defined` or a missing Node built-in. The Node variants now take the `workerd` condition, which wrangler and @cloudflare/vite-plugin pass with `worker` and `browser`, so a Cloudflare Worker resolves the same files as before (a wrangler dry run bundles the same 1,652 inputs into a byte-identical worker.js) and a browser Web Worker gets the browser variants, as a page does. The `worker` keys that already pointed at stubs stay. Fixes #1031 * fix(sdk,plugins): refuse plugin stores on Redis or SQLite in a browser build CachePlugin and RememberPlugin on global-store took the top-level redis in a page, which has no TCP socket: the store fell back to memory with a warning or failed on its first call. refusePluginStoreInBrowser(), from @frontmcp/sdk, throws BrowserStoreUnavailableError in a browser for a plugin store that would open Redis or SQLite, naming the plugin option that keeps it in memory. CachePlugin, RememberPlugin, ApprovalPlugin and ResultHandlesPlugin call it where they build their store. Node servers are unchanged. Fixes #1033 * fix(sdk): name a plugin's memory option when the plugin is named like an SDK store * test(e2e): read the CLI's sqlite keys with a helper of demo-e2e-cli-exec, and build the CLI its tests drive Importing the demo-e2e-jobs helper made demo-e2e-cli-exec depend on that app in the Nx graph, so its tests built it first. * docs(skills): a jobs block needs enabled, which the server and now the CLI refuse without One create-job example and its comment said enabled was implied, but the decorator refused the block. * test(scripts): check that every package gives a browser Web Worker the files it gives a page The guard listed the splits by hand, so it missed the SDK's #protocol-client and would not catch a new split that maps `worker` to a Node file. It now resolves every imports and exports entry of every workspace package with and without `worker`, for import and require. * docs(skills): only utils and protocol gave `worker` their Node builds up to 1.10 @frontmcp/auth's root export had no `worker` key on release/1.10.x; it came with the browser builds. * chore: name `workerd`, not `worker`, where comments and fixtures map the Node variants * docs(skills): approval and result-handles fell back to memory in a page, they never opened a SQLite file * test(e2e): type PluginStoreInBrowser and BrowserStoreUnavailableError's plugin memory options in a strict consumer * fix(sdk): CLI mode keeps its tasks in the Redis a redis url names, as the server does The lite config parser of CLI mode passed the top-level redis through as z.any(), so a redis: { url } reached createTaskStore raw. The factory mapped redis into a storage config only when it had a host, and fell back to REDIS_URL / REDIS_HOST or memory otherwise, so the generated CLI kept its tasks where its detached workers could not find them. The lite parser now validates redis with the server's schema, and createTaskStore and createElicitationStore normalize their redis option with that schema, so every form reaches the store with its password, database and TLS, and an unusable one fails at startup. A build still reads a config whose redis url comes from an unset variable. Fixes #1035 * fix(sdk): keep Node built-ins out of the browser build The SDK's browser build imported node:cluster, node:net, node:path, node:http, node:stream and node:os from the shared local server and Unix socket code, so a webpack 5 build of create() + one tool, for a page or a Web Worker, stopped with UnhandledSchemeError unless the app stubbed Node built-ins. Two chains reached them: duty.lifecycle and the persistent question store import findReplaceableDirectory from unix-socket-guard, and runShared() imports the shared server with import(), which webpack follows. findReplaceableDirectory moves to its own module, which uses @frontmcp/utils' dirname, so the socket guard is left to the Express host. runShared() loads the shared server through a #shared-server subpath import whose browser and worker variant is a stub: the browser builds no longer contain the shared server, and runShared() in a page rejects with BrowserEntryPointUnavailableError (BROWSER_ENTRY_POINT_UNAVAILABLE) instead of "process is not defined". runUnixSocket() refuses in a browser with the same error before it builds a server. Node builds are unchanged. Fixes #1034 * test(e2e): close the Redis client the monitor connection was opened from * fix(sdk): a build reads a server without any of its stores, so it opens no task store and needs none of their secrets The manifest boot left out the top-level redis before validating the config, but still validated sqlite, jobs.store and tasks.redis / tasks.sqlite, so a config object whose sqlite.encryption.secret or tasks.redis.url comes from an unset variable stopped frontmcp build, and it opened tasks.redis and created the tasks.sqlite file. Every store is now left out before the config is validated: redis, pubsub, sqlite, jobs.store, tasks.redis and tasks.sqlite. * test(e2e): type BrowserEntryPointUnavailableError in the declarations consumer The new error and BrowserRefusedEntryPoint had no consumer coverage, unlike BrowserStoreUnavailableError. The shared server fixture now narrows to the error, reads its code and entryPoint, and rejects an entry point a browser does not refuse. * perf(sdk): load BrowserEntryPointUnavailableError only when runUnixSocket() refuses runUnixSocket()'s browser check imported the error class statically, so every create() in a page carried it. The class now loads on the refusal path, as runShared()'s browser stub already does, and create() + one tool shrinks by about 300 bytes. Budgets are the new sizes plus the same headroom. * fix(sdk,cli): a build reads a @FrontMcp class without validating its stores, which the server validates when it starts The decorator validated the whole config when the entry loaded, before the build could leave the stores out, so a @FrontMcp class whose redis.url or sqlite.encryption.secret comes from a variable unset on the build machine failed --target cli and --target mcpb, and the other targets wrote no manifest. The build now loads the entry with FRONTMCP_BUILD_EXTRACT beside FRONTMCP_SCHEMA_EXTRACT. Under it the decorator validates the config without its store settings (redis, pubsub, sqlite, jobs.store, tasks.redis, tasks.sqlite, transport.persistence, throttle.storage) and records them as given; the manifest boot leaves them out with the same list. The generated CLI sets only FRONTMCP_SCHEMA_EXTRACT while it loads the server, so it, and every other way of running the server, validates them at startup as before. Fixes #1041 * fix(sdk): leave a store setting's parent that is an array as given, for the schema to refuse * fix(sdk): a CLI host run with FRONTMCP_BUILD_EXTRACT set validates the stores with the server's schema A class loaded under the build flag records its store settings unvalidated, and the CLI host's lite schema passes transport.persistence through, so a generated CLI whose environment had the flag opened a transport session store the server would refuse, such as an SQLite file whose encryption secret was unset. * fix(sdk): a build also reads a server without its event store, elicitation Redis and auth stores transport.eventStore, elicitation.redis, auth.tokenStorage and auth.secureStore join the shared list of store settings. A @FrontMcp class whose elicitation or event store Redis, or whose auth token storage, takes a variable the build machine had not set stopped the build, and the manifest boot wrote the SQLite files of auth.tokenStorage and auth.secureStore, for a config object too. The server validates them when it starts, as before. * test(cli): setBuildExtractFlags puts back a flag's previous value * test(sdk): mock @frontmcp/storage-sqlite without virtual: true, so a spec's mock holds whichever spec ran before it Jest caches module IDs per worker. A virtual mock gives the module a different ID than a non-virtual mock or a real load of it, so the store factories' lazy require() took the real module, or missed the mock, when another spec had required it first in the same worker. The module always resolves through the pinned moduleNameMapper, so no spec needs the flag. * test(cli): the cli-exec e2e names the fixture build's errors when it fails The fixture build's TypeScript diagnostics go to the CLI's stdout, which ensureBuild() captured and dropped, so a failed build read only as 'node exited with code 2'. It failed that way when another nx task in the same worktree rebuilt libs/sdk/dist during the run, and tsc could not resolve @frontmcp/sdk. The error now carries the build's output. * build: declare webpack, which the bundle-size e2e imports, as a root devDependency It came only through @nx/webpack. The range is the one the lockfile already resolves (5.105.1), so the lockfile gains only the root's entry. * fix(sdk,plugins): a browser refuses every Redis or SQLite store in one error, plugins' and explicit stores included Plugins declare their stores with static browserStores; the SDK's startup check reads them for the server's and its class apps' plugins, with questions, jobs, throttle.storage, auth.tokenStorage and skillsConfig.cache.redis, before any store opens. The plugin registry checks the rest (init({ useFactory }), nested plugins) before it builds them. Resource stores and sqliteStorageProvider() are refused where they open. BrowserStoreUnavailableError requires the memory options for plugin entries. * perf(plugins): CachePlugin and RememberPlugin load ioredis when they build a Redis store A page that uses them on memory or Vercel KV no longer bundles ioredis, and RememberPlugin reads its secret path through @frontmcp/utils instead of importing Node's path. The bundle-size model leaves out what utils and auth load with import(), as it does the SDK's on-demand modules, and the helpers name inputs with / on every platform. * fix(sdk): the CommonJS browser build requires @frontmcp/storage-sqlite on first use, and the guard finds a require() that runs on load dist/browser/index.cjs no longer bundles @frontmcp/storage-sqlite and its node:events imports. The browser guard also reports a require() or esbuild __require() of a Node built-in outside any function, which throws while a page loads the module, and covers the cache and remember plugins. * docs(skills): one browser refusal for every store, plugins' static browserStores, and REST tokens in a page The catalog names the stores the browser build refuses and their switches, the plugin hook that declares a plugin's stores, the CommonJS browser build's lazy Node built-ins, and warns that an Upstash or Vercel KV token used from a page is readable by every visitor. * test(sdk): start the lazy-app rate-limit tests mid-window The guard's windows align to the wall clock, so two calls that straddle a minute boundary let the second through, which failed the test under load. useMidRateLimitWindow puts the boundary 30 s away, as the other rate-limit specs do. * fix(sdk): CLI mode validates every option the server validates, and runs with it as given The lite config parser of CLI mode passed http, pubsub, transport, pagination, fetch, elicitation, extApps, ui, throttle, health, metrics and authorities through as z.any(), and dropped session, clients, observability, registry and channels, so a config object the server refuses ran in CLI mode; an invalid transport.persistence opened the CLI's session store with it. Each of those keys is now checked with the server's schema, with the server's issues, and kept as given: the server's defaults (transport, http, fetch origins, redis key prefixes) would change what CLI mode does. The dropped keys are checked and still left out, so CLI mode turns none of them on. authorities keeps its pass-through (#366): the value reaches the scope as given, keys the schema does not know included. * fix(sdk): a redis with neither host nor url says so, instead of Invalid input The redis union reported Zod's generic Invalid input when no branch matched, in server and CLI mode, so a redis whose host and url both come from unset variables did not say what was missing. It now fails with `redis needs host or url`, wherever a redis option is read (pubsub, jobs.store.redis, tasks.redis and the others). Every other message is unchanged. * fix(sdk): the job stores refuse a Vercel KV jobs.store.redis instead of connecting to localhost The job stores open jobs.store.redis with a Redis client whatever its provider, so on Node a Vercel KV one connected to a Redis on localhost. Both job store factories now refuse it with JobStoreNotSupportedError (JOB_STORE_NOT_SUPPORTED), which stops startup in server and CLI mode. A top-level Vercel KV redis was already passed over by resolveJobsStore; a test now pins that the runs stay in memory beside one. * fix(cli): the manifest warning names each invalid field instead of printing the error as JSON When the server's schema refused its config, `frontmcp.manifest.json not written: ...` printed the ZodError's message, the issues as JSON. It now prints each invalid field as `path: message`, separated by `; `. A union that says no more than Invalid input is read through its branch with the fewest issues, as the SDK's describeConfigIssues does; a union with its own message, such as `redis needs host or url`, keeps it. * fix(auth): an auth.secureStore backing it cannot use stops startup instead of falling back to memory The bare-object variant of secureStoreConfigSchema matched a config whose sqlite, redis or backend failed its own variant and stripped the backing, so a broken backing (an empty sqlite.path, a redis without host such as a url, a backend missing a method) kept the secrets in memory with no error. That variant now takes no backing, so the config is refused, with a message that names the backing, such as `secureStore.backend must implement get/set/delete/list`. createSecureStore() validates its config the same way and refuses an unusable one with SecureStoreConfigError (SECURE_STORE_CONFIG_INVALID) before it opens a store. 'memory', a config without a backing, and a backing left undefined still select the memory store. * test(e2e): fix the three type errors in the demo-e2e-cli-exec specs * test(e2e): the generated CLI of a config object validates its options as the server does Builds a CLI whose server is a config object and runs it with an invalid transport.persistence (refused, no session file), a valid one (runs), a redis with neither host nor url, and a Vercel KV jobs.store.redis; then builds the node target of a config the schema refuses and reads the manifest warning. * fix(sdk): skip the browser store check for a hand-written plugin value record, whose options are not recorded * test(sdk): set the browser bundle budgets to the measured size plus headroom * test(e2e): pin JobStoreNotSupportedError and raise the browser budgets by the measured growth The SDK root exports JobStoreNotSupportedError. The browser bundles grow with the CLI-mode checks (+344 bytes in front-mcp.metadata), the redis message (+197) and the secureStore refusal (+484): create() + one tool by 1,030 bytes (1,295,491 -> 1,296,521), with WebMcpPlugin by 1,033 (1,302,674 -> 1,303,707), and the Vite initial chunk by 605 (761,200 -> 761,805). Each budget rises by that growth; the WebMcpPlugin one also carries the +143 of the base's next commit (3a450fd), which the base was already over. * test(plugin-codecall): time simple scripts after a warm-up call, on the fastest of three The first execute() in a process pays one-time module and JIT costs, so the test failed its 100 ms bound when it ran first, alone or under load (106-184 ms in 5 of 24 loaded runs). Warm calls take 3-33 ms under the same load. The bound now applies to the calls after a warm-up, and to the fastest of three, so one descheduled call does not fail it while a slow execute() still does. * test(e2e): set the browser budgets from the combined follow-ups' measured sizes On main 5c8f654 the bundles are 1,295,491 / 1,302,674 bytes and Vite's initial chunk 761,200. With the follow-ups: 1,280,950 / 1,288,132 (most of the drop is the bundle model now leaving out what utils and auth load on demand), budgets set to size plus their headroom, 1,281,486 / 1,288,356; Vite's initial chunk grows by the measured 1,086 bytes (the single browser startup check and the CLI-schema checks), so its budget rises by exactly that, 761,267 -> 762,353. * fix(sdk): disposing a scope destroys its transport service and closes its session store Only the direct server destroyed the transport service. A scope disposed by FrontMcpInstance.shutdown() or connect() kept its session store: one that failed at startup kept retrying, and an SQLite one kept its file open. * fix(plugins): the servers that share one CachePlugin or RememberPlugin init() on redis share its client Building the type: 'redis' store at server start, rather than at init(), gave every server its own ioredis client, none of which is closed. The store is now built once, when the first server that installs it starts. * fix(sdk): a browser refuses an auth.secureStore on Redis or SQLite in its one startup refusal The startup check named auth.tokenStorage but not auth.secureStore, so a local auth in a page with a secure store on SQLite or Redis failed with the storage layer's module or connection error. * docs(skills): graceful shutdown closes the session store of transport.persistence * test(e2e): set the browser budgets from the measured sizes after the review fixes The scope's transport-service disposal and the secure store's browser refusal add 156 bytes to each esbuild bundle and 74 to Vite's initial chunk. * test(scripts): the CommonJS browser build keeps @frontmcp/storage-sqlite external, beside the ESM build's externals a70ec4a left @frontmcp/storage-sqlite out of dist/browser/index.cjs, so the check that the two builds keep the same packages external failed. * test(plugin-remember): give the shared-client test's redis config the port its type requires * fix(sdk,plugins): a build reads a server without building its plugins' stores The build's manifest boot leaves the top-level redis out, so a CachePlugin or RememberPlugin on global-store failed a --target cli or mcpb build with 'requires global "redis" configuration', and a plugin store on Redis or SQLite connected, or wrote its file, during the build. storeProvider() marks a factory provider that opens a store. In a manifest boot the provider registry does not build it and gives what injects it a stand-in that throws StoreNotOpenedInBuildError when called; any other boot builds it as before. The cache, remember, approval and result-handles stores and sqliteStorageProvider() are made with it. * test(e2e): build a CLI and an MCPB of a server whose plugins keep their stores on Redis and SQLite Both builds succeed without the redis the build leaves out, connect to no plugin Redis and write no plugin SQLite file, and the shipped server still checks the redis when it starts. * test(e2e): set the browser budgets from the measured sizes with storeProvider() The provider registry's build-boot check, storeProvider() and StoreNotOpenedInBuildError add 796 and 798 bytes to the esbuild bundles and 179 to Vite's initial chunk. * test(sdk,plugins): type the build-store specs' configs and providers for their spec configs * fix(sdk,auth): open the secure-store backing the schema validated, and refuse a publicAccess guard on Redis or SQLite in a page - createSecureStore() selects the backing from the schema's parsed config, so a config naming two backings opens the one that validated instead of the raw other one. - In a browser, a throttle.storage on Redis or SQLite is refused at startup also with throttle.enabled: false when auth.publicAccess.rateLimit builds its guard on it. - demo-e2e-mcpb builds plugin-approval and plugin-cache before its tests, which its plugin-stores spec imports, and that spec shows the build's output when the build fails. - Browser budgets re-fitted to the measured sizes. * fix(auth,sdk): refuse a secure store that names more than one backing, and count an app's publicAccess before its defaults - auth.secureStore takes one of sqlite, redis or backend: a config naming more stops startup with "secureStore takes one backing, not sqlite and redis". createSecureStore() selects from the config as given again, so a direct call keeps its Redis key prefix and its custom backend. - The browser check counts auth.publicAccess whose rateLimit is not yet defaulted (an app's own auth) as rate limited. - demo-e2e-mcpb builds the CLI before its tests too. - Browser budgets re-fitted to the measured sizes.
Stacked on #1027
What and why
Two follow-ups to the durable-jobs work (#1018) and the stores that landed with it:
jobs.store: false. Since feat(sdk): durable jobs on a local store — cancel, timeouts, cleanup, admission and run handles #1018, jobs keep their runs and dynamic definitions in the server's top-levelredis, else its top-levelsqlite, wheneverjobs.storenames no backend. A server that wants its sessions in SQLite but its job runs in memory had no way to say so.jobs: { store: false }now keeps them in memory whatever the top-level options say.redisorsqliteon their own. In a page (create(), WebMCP) neither can run: Redis needs a TCP socket, SQLite a file. Before this PR a task, elicitation or job store on Redis fell back to memory with a console warning (the Redis client cannot load in a page), so a config that asked for durability got none; a store on SQLite failed with a module error from inside its factory, persistent questions withInvalidQuestionStoreError, and a top-levelsqlitewithout apathwhile resolving its default path fromos.homedir(). The browser build now refuses it at startup, before any store opens, with one error class that names each store and the option that keeps it in memory.Every store that takes the top-level
redisorsqlite, and how to switch that offOne pattern: a subsystem that already had a memory switch keeps it, and the three that had none get
store: false.redis(schema), top-levelsqlitetransport.persistence: false(existing)create()/connect()serve no HTTPredis, in adistributeddeployment onlytransport.eventStore: { enabled: false }(existing)standalonetasks.sqlite/tasks.redis, else top-levelredis, elsesqlitetasks.store: false(new)elicitation.redis, else top-levelredis, elsesqliteelicitation.store: false(new)elicitation.persistent.storage, elseelicitation.redis/ top-levelredis, elsesqlite, else~/.frontmcp/questions.sqliteelicitation.persistent.storage: { type: 'memory' }(existing)memoryjobs.store, else top-levelredis(not Vercel KV), elsesqlitejobs.store: false(new)redis, else~/.frontmcp/duties.sqlitememoryunlessresourceStore({ storage })names onestore: falsetakes no backend of its own beside it (tasks.redis/tasks.sqlite,elicitation.redis), and each switch passes an explicit memory storage to its factory, so an ambientREDIS_URLorKV_REST_API_URLcannot pick a backend either.elicitation.store: falsecovers the elicitation state only; persistent questions keep followingpersistent.storage.The browser rule
The browser build already refuses, at startup and with a specific error, Node-only features that were asked for (
runner: 'detached'jobs, persistent questions on an edge runtime) and quietly skips only what nobody asked for. A top-levelredisorsqliteis a request for durability, so the browser build does not fall back to memory: inruntime === 'browser'(the#runtime-contextbrowser polyfill of@frontmcp/utils), a store that would open Redis over TCP or a SQLite file fails startup withBrowserStoreUnavailableError(BROWSER_STORE_UNAVAILABLE). Vercel KV, over HTTPS, is left to the rules it had, except injobs.store.redis: the job stores take no provider and open it with the Redis client, so it is refused there too.Scope.initialize(), before HA, transports or any store; persistent questions right after their settings are parsed; jobs right before their stores open. One error lists every store refused at that point.sqliteis no longer resolved: no default path (which reados.homedir()/process.cwd()) and nomkdir, and the "top-levelsqlite... no subsystem consumes it" warning is not printed there.redisandsqlitemay stay in a config shared with a Node server; those stores keep their state in memory (on Node too) and the page opens neither. The page starts without a warning: the production warning thatrequestStateis signed with a per-process key (meant for several instances behind one Redis) is not printed in a browser, a single instance that reads noVAULT_SECRET.getEnvreturns noREDIS_URL, socreateStorage({ type: 'auto' })resolves to memory there.Neither the Redis client (
ioredis) nor the SQLite store (@frontmcp/storage-sqlite,better-sqlite3) is in the browser bundle, with every store module loaded; a new e2e pins that. The Redis storage adapter class of@frontmcp/utils(its wrapper aroundioredis: 12,602 bytes minified when bundled alone with its base class and errors) is still in the bundle, because the utils ESM build is one file; keeping it out is size work, left to the browser-size follow-up.User-facing change
jobs.store: false,tasks.store: false,elicitation.store: false, each validated with a clear message (jobs.store must be false (runs kept in memory) or an object with redis or sqlite;tasks.store must be false (tasks kept in memory);tasks.store: false keeps tasks in memory, so it takes no tasks.redis or tasks.sqlite; and the same two forelicitation.store). A wrong field insidejobs.storeis named (jobs.store.sqlite.path: …).BrowserStoreUnavailableError(BROWSER_STORE_UNAVAILABLE, exported from@frontmcp/sdkwith theBrowserRefusedStoretype). Example message:A browser cannot open Redis or SQLite: background tasks would use the top-level redis (set tasks.store: false for memory, or leave redis out of the browser build); jobs would use jobs.store.sqlite (set jobs.store: false in place of jobs.store.sqlite).error.storeslists[store, option]pairs.runner: "cli"task refusal and the detached-job remedy now say that a top-levelsqliteonly counts withouttasks.store: false/jobs.store: false.frontmcp build --target cli,connect(..., { mode: 'cli' })) now reads thetasksoptions, validated as on the server. Its config parser had notaskskey, sotasks.store,tasks.sqlite,tasks.redis,tasks.runner,tasks.enabledand the TTLs were all dropped there.redisorsqlitehad no way to keep job runs, task state or elicitation state in memory →jobs.store: false,tasks.store: false,elicitation.store: false.jobs.store: falsewas refused by the schema → accepted, runs kept in memory.redisorsqlite: tasks, elicitation and jobs on Redis fell back to memory with a console warning, and SQLite stores and persistent questions failed with a module or store error (or, without apath, onos.homedir()) → startup fails before any store opens withBrowserStoreUnavailableErrornaming each store's switch; with the switches set it starts in memory, without a warning.tasksoption was ignored, and tasks ran only with a top-levelsqliteorredis→ thetasksoptions apply (tasks.store: falseruns no tasks there,tasks.sqlite/tasks.redisgive them their store,tasks.runnerandtasks.enabledare honoured), and an invalid one fails as on the server.frontmcp-development/references/create-job.md:jobs.store: false, its validation, the browser refusal.frontmcp-setup/references/setup-sqlite.md: the auto-wiring table gains persistent questions and a "keep it in memory" column (and drops theelicitation.sqliteoverride, which never existed), plus the browser rule and the generated CLI reading thetasksoptions.frontmcp-setup/references/setup-redis.md: which subsystems take the top-levelredisand their switches, the browser rule, and norequestStatekey warning in the browser build.frontmcp-config/references/configure-elicitation.md:elicitation.store, and persistent questions in the browser.frontmcp-deployment/references/build-for-cli.md: the generated CLI's background tasks and thetasksoptions it now reads.frontmcp-deployment/references/build-for-browser.md: the browser rule, what a page did up to 1.10, norequestStatekey warning in a page, and a troubleshooting row forBrowserStoreUnavailableError.Bundle size
The switches and their messages reach every browser bundle (they are schema), and so does the startup check with its error class, which is what makes a page refuse a server store. The jobs and questions checks live in their lazy helpers (
job-scope.helper,question-scope.helper), next to the resolution each one mirrors, so they load only with jobs or persistent questions. Measured againstmainwith #1016 and #1021 (#1027 adds no growth, so the numbers are the same against it):create()+ one toolcreate()+ WebMcpPlugin + one toolBy module (
create()+ one tool):errors/browser-store.errors694,scope/browser-stores578,front-mcp.metadata+522 (thejobs.storeunion and thetasks.storeswitch with their messages),scope.instance+302,elicitation/schema+237; the Vite initial chunk grows by the schema part only. The CLI parser'staskskey and the browser check of therequestStatewarning add 47 bytes to eachcreate()bundle and none to the Vite initial chunk. Each budget moves up by exactly the measured growth, on top of the budgets of the base's head (which raised the WebMcpPlugin and Vite budgets to 1,347,378 and 964,827). Node is unchanged apart from these modules.Tests
libs/sdk):scope/__tests__/browser-stores.spec.ts: which option tasks and elicitation would open, for every precedence and switch (Vercel KV,enabled: false,store: false); the refusal lists every store with its switch, in a browser only.job/__tests__/job-scope.helper.spec.ts:resolveJobsStore(false, …)is memory;jobsServerStorefor each rule, including ajobs.store.redison Vercel KV.elicitation/questions/__tests__/question-store.factory.spec.ts:questionsServerStorefor each rule.common/metadata/__tests__/jobs-config-schema.spec.tsandstore-off-config-schema.spec.ts:store: falseparses;true,'memory',nullandstore: falsebeside a backend are refused with their messages.scope/__tests__/scope-store-off.spec.ts: with a top-levelsqlite, each switch keeps its store in memory where it would take the file otherwise;tasks.store: falsewithrunner: 'cli'is refused; with the runtime set tobrowser, startup fails before any SQLite store is built and without creating the file's directory (top-levelredis/sqlite, jobs, persistent questions), and starts in memory with every switch set, without the unused-sqlitewarning. In CLI mode (createForCli),tasks.store: falsekeeps tasks off the top-levelsqlite.common/metadata/__tests__/parse-lite.spec.ts: the CLI parser keepstasksand refuses atasks.storeother thanfalse.transport/mcp-20260728/__tests__/request-state.spec.ts: no per-process key warning in a browser.apps/e2e/demo-e2e-jobs/e2e/jobs-memory-store.e2e.spec.ts: a server with a top-level SQLite andjobs.store: falseruns a job; its SQLite file holds the session keys and nomcp:jobs:key.jobs-durable.e2e.spec.tsgains the control: without the switch, the runs are in the file.apps/e2e/demo-e2e-bundle-size/e2e/browser-stores.e2e.spec.ts: the page bundle ofcreate()(browser conditions, run as ES modules) refuses the top-levelredisand ajobs.store.sqlitewithBROWSER_STORE_UNAVAILABLE, and starts and calls a tool with the switches set, printing no warning; a TCP listener posing as Redis gets no connection and the SQLite directory is never created. With the jobs, elicitation and questions modules loaded, the bundle's module list holds noioredis,@frontmcp/storage-sqliteorbetter-sqlite3.apps/e2e/demo-e2e-dts-consumer:memory-stores-must-compile.tstypes the three switches and the error, and rejectsstore: true/'memory'.sdk-root-exports.jsonpins the new export.nx run-many -t lint -p sdk skills demo-e2e-jobs demo-e2e-bundle-size demo-e2e-dts-consumer,nx run-many -t test -p sdk skills,nx run-many -t build -p sdk plugin-webmcp --skip-nx-cache, the three e2e apps above,node scripts/fix-unused-imports.mjs 3b92e82ef, prettier on the changed files, and a--noEmittypecheck of the changed SDK sources and specs; all again after the review fixes (sdk 9,296 and skills 249 unit tests, demo-e2e-jobs 55, demo-e2e-bundle-size 27, demo-e2e-dts-consumer 34). After merging fix(sdk): load the transport, job/tools and workflow/tools subpaths at runtime #1027 (withmain): sdk 9,299, demo-e2e-jobs 55, demo-e2e-bundle-size 41, demo-e2e-dts-consumer 35.Summary by CodeRabbit
store: falseoptions to keep background tasks, elicitation state, and job runs in memory, even when Redis or SQLite is configured globally.