Skip to content

feat(sdk): jobs.store: false, and no Redis or SQLite store in a browser build - #1028

Merged
alexmercerpo merged 9 commits into
fix/f01-sdk-subpath-exportsfrom
feat/f02-memory-stores-browser
Nov 3, 2026
Merged

alexmercerpo merged 9 commits into
fix/f01-sdk-subpath-exportsfrom
feat/f02-memory-stores-browser

Conversation

@frontegg-david

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

Copy link
Copy Markdown
Contributor

Stacked on #1027

What and why

Two follow-ups to the durable-jobs work (#1018) and the stores that landed with it:

  1. 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-level redis, else its top-level sqlite, whenever jobs.store names 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.
  2. No Redis or SQLite in the browser build. Several stores pick the top-level redis or sqlite on 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 with InvalidQuestionStoreError, and a top-level sqlite without a path while resolving its default path from os.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 redis or sqlite, and how to switch that off

One pattern: a subsystem that already had a memory switch keeps it, and the three that had none get store: false.

Store Takes on its own Keep it in memory In the browser build
Transport sessions top-level redis (schema), top-level sqlite transport.persistence: false (existing) not created: create() / connect() serve no HTTP
SSE event store, HA manager, transport bus top-level redis, in a distributed deployment only transport.eventStore: { enabled: false } (existing) never: a page's deployment is standalone
Background tasks tasks.sqlite / tasks.redis, else top-level redis, else sqlite tasks.store: false (new) refused at startup
Elicitation state elicitation.redis, else top-level redis, else sqlite elicitation.store: false (new) refused at startup
Persistent questions elicitation.persistent.storage, else elicitation.redis / top-level redis, else sqlite, else ~/.frontmcp/questions.sqlite elicitation.persistent.storage: { type: 'memory' } (existing) refused at startup unless memory
Jobs (runs, dynamic definitions) jobs.store, else top-level redis (not Vercel KV), else sqlite jobs.store: false (new) refused at startup
Duty leases (M12) top-level redis, else ~/.frontmcp/duties.sqlite none, on purpose: a lease kept in memory lets every process run the duty duties do not run there (existing warning)
Materialized resource stores (M13) never: memory unless resourceStore({ storage }) names one already memory an explicit Redis/SQLite store opens at startup and fails there

store: false takes 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 ambient REDIS_URL or KV_REST_API_URL cannot pick a backend either. elicitation.store: false covers the elicitation state only; persistent questions keep following persistent.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-level redis or sqlite is a request for durability, so the browser build does not fall back to memory: in runtime === 'browser' (the #runtime-context browser polyfill of @frontmcp/utils), a store that would open Redis over TCP or a SQLite file fails startup with BrowserStoreUnavailableError (BROWSER_STORE_UNAVAILABLE). Vercel KV, over HTTPS, is left to the rules it had, except in jobs.store.redis: the job stores take no provider and open it with the Redis client, so it is refused there too.

  • Tasks and elicitation are checked at the top of 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.
  • In the browser the top-level sqlite is no longer resolved: no default path (which read os.homedir() / process.cwd()) and no mkdir, and the "top-level sqlite ... no subsystem consumes it" warning is not printed there.
  • With the switches set, the top-level redis and sqlite may 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 that requestState is signed with a per-process key (meant for several instances behind one Redis) is not printed in a browser, a single instance that reads no VAULT_SECRET.
  • The environment cannot pick Redis in the browser either: the browser getEnv returns no REDIS_URL, so createStorage({ 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 around ioredis: 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

  • What changed:
    • New options: 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 for elicitation.store). A wrong field inside jobs.store is named (jobs.store.sqlite.path: …).
    • New error: BrowserStoreUnavailableError (BROWSER_STORE_UNAVAILABLE, exported from @frontmcp/sdk with the BrowserRefusedStore type). 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.stores lists [store, option] pairs.
    • The runner: "cli" task refusal and the detached-job remedy now say that a top-level sqlite only counts without tasks.store: false / jobs.store: false.
    • The generated CLI (CLI mode: frontmcp build --target cli, connect(..., { mode: 'cli' })) now reads the tasks options, validated as on the server. Its config parser had no tasks key, so tasks.store, tasks.sqlite, tasks.redis, tasks.runner, tasks.enabled and the TTLs were all dropped there.
  • Before → after:
    • A server with a top-level redis or sqlite had no way to keep job runs, task state or elicitation state in memory → jobs.store: false, tasks.store: false, elicitation.store: false.
    • jobs.store: false was refused by the schema → accepted, runs kept in memory.
    • Browser build with a top-level redis or sqlite: 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 a path, on os.homedir()) → startup fails before any store opens with BrowserStoreUnavailableError naming each store's switch; with the switches set it starts in memory, without a warning.
    • CLI mode: every tasks option was ignored, and tasks ran only with a top-level sqlite or redis → the tasks options apply (tasks.store: false runs no tasks there, tasks.sqlite / tasks.redis give them their store, tasks.runner and tasks.enabled are honoured), and an invalid one fails as on the server.
  • frontmcp.dev pages affected (if known): CLI target (tasks options), jobs configuration, elicitation configuration (options and persistent questions), SQLite setup (top-level auto-wiring), Redis setup, browser build / WebMCP limitations, errors reference.
  • Skills catalog entries updated:
    • 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 the elicitation.sqlite override, which never existed), plus the browser rule and the generated CLI reading the tasks options.
    • frontmcp-setup/references/setup-redis.md: which subsystems take the top-level redis and their switches, the browser rule, and no requestState key 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 the tasks options it now reads.
    • frontmcp-deployment/references/build-for-browser.md: the browser rule, what a page did up to 1.10, no requestState key warning in a page, and a troubleshooting row for BrowserStoreUnavailableError.

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 against main with #1016 and #1021 (#1027 adds no growth, so the numbers are the same against it):

Bundle Base This PR Growth Budget
create() + one tool 1,339,977 1,342,324 +2,347 1,340,056 → 1,342,403
create() + WebMcpPlugin + one tool 1,347,154 1,349,507 +2,353 1,347,378 → 1,349,731
Vite initial chunk 964,695 965,370 +675 964,827 → 965,502

By module (create() + one tool): errors/browser-store.errors 694, scope/browser-stores 578, front-mcp.metadata +522 (the jobs.store union and the tasks.store switch with their messages), scope.instance +302, elicitation/schema +237; the Vite initial chunk grows by the schema part only. The CLI parser's tasks key and the browser check of the requestState warning add 47 bytes to each create() 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

  • Unit (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; jobsServerStore for each rule, including a jobs.store.redis on Vercel KV.
    • elicitation/questions/__tests__/question-store.factory.spec.ts: questionsServerStore for each rule.
    • common/metadata/__tests__/jobs-config-schema.spec.ts and store-off-config-schema.spec.ts: store: false parses; true, 'memory', null and store: false beside a backend are refused with their messages.
    • scope/__tests__/scope-store-off.spec.ts: with a top-level sqlite, each switch keeps its store in memory where it would take the file otherwise; tasks.store: false with runner: 'cli' is refused; with the runtime set to browser, startup fails before any SQLite store is built and without creating the file's directory (top-level redis/sqlite, jobs, persistent questions), and starts in memory with every switch set, without the unused-sqlite warning. In CLI mode (createForCli), tasks.store: false keeps tasks off the top-level sqlite.
    • common/metadata/__tests__/parse-lite.spec.ts: the CLI parser keeps tasks and refuses a tasks.store other than false.
    • transport/mcp-20260728/__tests__/request-state.spec.ts: no per-process key warning in a browser.
  • e2e:
    • apps/e2e/demo-e2e-jobs/e2e/jobs-memory-store.e2e.spec.ts: a server with a top-level SQLite and jobs.store: false runs a job; its SQLite file holds the session keys and no mcp:jobs: key. jobs-durable.e2e.spec.ts gains 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 of create() (browser conditions, run as ES modules) refuses the top-level redis and a jobs.store.sqlite with BROWSER_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 no ioredis, @frontmcp/storage-sqlite or better-sqlite3.
    • apps/e2e/demo-e2e-dts-consumer: memory-stores-must-compile.ts types the three switches and the error, and rejects store: true / 'memory'.
    • sdk-root-exports.json pins the new export.
  • Ran: 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 --noEmit typecheck 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 (with main): sdk 9,299, demo-e2e-jobs 55, demo-e2e-bundle-size 41, demo-e2e-dts-consumer 35.

Summary by CodeRabbit

  • New Features
    • Added store: false options to keep background tasks, elicitation state, and job runs in memory, even when Redis or SQLite is configured globally.
    • Browser builds now identify server-backed stores that are unavailable and stop startup before opening them. Disabling those stores allows browser startup without opening the configured backend.
  • Documentation
    • Expanded guidance for browser and CLI storage behavior, including configuration options and browser limitations.

…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.
…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.
@frontegg-david
frontegg-david marked this pull request as ready for review November 3, 2026 08:50
@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Warning

Review limit reached

Your organization has reached its usage spending cap. Adjust your spending cap in the billing tab.

Next included review available in 33 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 54bfe35 and 0ee7ebf.

Files selected for processing: 36
  • apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts
  • apps/e2e/demo-e2e-bundle-size/e2e/browser-stores.e2e.spec.ts
  • apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json
  • apps/e2e/demo-e2e-bundle-size/e2e/helpers/greet-with-stores.mjs
  • apps/e2e/demo-e2e-bundle-size/fixture/greet-with-stores.ts
  • apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts
  • apps/e2e/demo-e2e-dts-consumer/fixture/memory-stores-must-compile.ts
  • apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json
  • apps/e2e/demo-e2e-jobs/e2e/helpers/sqlite-keys.ts
  • apps/e2e/demo-e2e-jobs/e2e/jobs-durable.e2e.spec.ts
  • apps/e2e/demo-e2e-jobs/e2e/jobs-memory-store.e2e.spec.ts
  • apps/e2e/demo-e2e-jobs/src/main.memory-store.ts
  • libs/sdk/src/common/metadata/__tests__/jobs-config-schema.spec.ts
  • libs/sdk/src/common/metadata/__tests__/parse-lite.spec.ts
  • libs/sdk/src/common/metadata/__tests__/store-off-config-schema.spec.ts
  • libs/sdk/src/common/metadata/front-mcp.metadata.ts
  • libs/sdk/src/common/types/options/elicitation/interfaces.ts
  • libs/sdk/src/common/types/options/elicitation/schema.ts
  • libs/sdk/src/elicitation/questions/__tests__/question-store.factory.spec.ts
  • libs/sdk/src/elicitation/questions/question-scope.helper.ts
  • libs/sdk/src/errors/browser-store.errors.ts
  • libs/sdk/src/errors/index.ts
  • libs/sdk/src/job/__tests__/job-scope.helper.spec.ts
  • libs/sdk/src/job/job-scope.helper.ts
  • libs/sdk/src/scope/__tests__/browser-stores.spec.ts
  • libs/sdk/src/scope/__tests__/scope-store-off.spec.ts
  • libs/sdk/src/scope/browser-stores.ts
  • libs/sdk/src/scope/scope.instance.ts
  • libs/sdk/src/transport/mcp-20260728/__tests__/request-state.spec.ts
  • libs/sdk/src/transport/mcp-20260728/request-state.ts
  • libs/skills/catalog/frontmcp-config/references/configure-elicitation.md
  • libs/skills/catalog/frontmcp-deployment/references/build-for-browser.md
  • libs/skills/catalog/frontmcp-deployment/references/build-for-cli.md
  • libs/skills/catalog/frontmcp-development/references/create-job.md
  • libs/skills/catalog/frontmcp-setup/references/setup-redis.md
  • libs/skills/catalog/frontmcp-setup/references/setup-sqlite.md
Walkthrough

The SDK adds in-memory storage options for tasks, elicitation, and jobs. Browser startup rejects configurations that would open Redis or SQLite stores. The changes include store-selection logic, browser and job integration tests, and deployment and configuration documentation.

Changes: In-memory stores and browser runtime

Layer / File(s) Summary
Store configuration and type contracts
libs/sdk/src/common/metadata/*, libs/sdk/src/common/types/options/elicitation/*, libs/sdk/src/job/*, apps/e2e/demo-e2e-dts-consumer/*, libs/skills/catalog/frontmcp-deployment/references/build-for-cli.md, libs/skills/catalog/frontmcp-development/references/create-job.md, libs/skills/catalog/frontmcp-setup/references/setup-sqlite.md
Task and elicitation store settings accept false; job storage accepts false or a Redis/SQLite configuration. Store resolution and validation cover those settings. The consumer fixture checks valid and invalid TypeScript configurations. Documentation describes the corresponding store options and CLI behavior.
Store selection and browser startup checks
libs/sdk/src/scope/*, libs/sdk/src/elicitation/questions/*, libs/sdk/src/errors/*, libs/skills/catalog/frontmcp-config/references/configure-elicitation.md
Store-selection helpers identify the Redis or SQLite option used by tasks, elicitation, persistent questions, and jobs. Scope initialization uses memory when configured and rejects browser startup when a selected store would use Redis or SQLite. The SDK exports BrowserStoreUnavailableError with the refused store options.
Browser bundle and runtime validation
apps/e2e/demo-e2e-bundle-size/*, libs/sdk/src/transport/mcp-20260728/*, libs/skills/catalog/frontmcp-deployment/references/build-for-browser.md, libs/skills/catalog/frontmcp-setup/references/setup-redis.md
Browser integration tests check startup refusal, successful memory-only startup, backend access, and bundle contents. The request-state warning guard skips its warning in browser runtime. Browser bundle size budgets were raised. Documentation describes browser store limits and the warning behavior.
Job storage end-to-end checks
apps/e2e/demo-e2e-jobs/*
The durable-job test checks for job-prefixed keys in SQLite. A new memory-store server and test check that jobs complete while the configured SQLite file has no job-prefixed keys.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Server startup
  participant Scope.initialize
  participant Store selection
  participant BrowserStoreUnavailableError
  Server startup->>Scope.initialize: initialize configured stores
  Scope.initialize->>Store selection: identify selected Redis or SQLite stores
  Store selection-->>Scope.initialize: return selected store options
  Scope.initialize->>BrowserStoreUnavailableError: throw when browser stores are selected
Loading

Suggested reviewers: alexmercerpo

Merge Risk: Low · previous review 54bfe

The change is mergeable with follow-up: strengthen the durable-run assertion and restore the specific configuration error for conflicting job backends.

Pre-merge checks: Passed 5
Check name Status Explanation
Title check Passed The title clearly summarizes the two primary changes: support for jobs.store: false and prevention of Redis or SQLite stores in browser builds.
Description check Passed The description is complete and follows the required structure. It explains the motivation, user-facing changes, before-and-after behavior, affected documentation, updated skills catalog entries, and …
Docstring Coverage Passed Docstring coverage is 87.50% 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 is complete and follows the required structure. It explains the motivation, user-facing changes, before-and-after behavior, affected documentation, updated skills catalog entries, and extensive test coverage and execution results.

Full details: Docstring Coverage

Explanation

Docstring coverage is 87.50% 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 16 functions across 28 files. (8 skipped: 8 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

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.
Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between ca05375 and 54bfe35.

Files selected for processing: 36
  • apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts
  • apps/e2e/demo-e2e-bundle-size/e2e/browser-stores.e2e.spec.ts
  • apps/e2e/demo-e2e-bundle-size/e2e/fixtures/sdk-root-exports.json
  • apps/e2e/demo-e2e-bundle-size/e2e/helpers/greet-with-stores.mjs
  • apps/e2e/demo-e2e-bundle-size/fixture/greet-with-stores.ts
  • apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts
  • apps/e2e/demo-e2e-dts-consumer/fixture/memory-stores-must-compile.ts
  • apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json
  • apps/e2e/demo-e2e-jobs/e2e/helpers/sqlite-keys.ts
  • apps/e2e/demo-e2e-jobs/e2e/jobs-durable.e2e.spec.ts
  • apps/e2e/demo-e2e-jobs/e2e/jobs-memory-store.e2e.spec.ts
  • apps/e2e/demo-e2e-jobs/src/main.memory-store.ts
  • libs/sdk/src/common/metadata/__tests__/jobs-config-schema.spec.ts
  • libs/sdk/src/common/metadata/__tests__/parse-lite.spec.ts
  • libs/sdk/src/common/metadata/__tests__/store-off-config-schema.spec.ts
  • libs/sdk/src/common/metadata/front-mcp.metadata.ts
  • libs/sdk/src/common/types/options/elicitation/interfaces.ts
  • libs/sdk/src/common/types/options/elicitation/schema.ts
  • libs/sdk/src/elicitation/questions/__tests__/question-store.factory.spec.ts
  • libs/sdk/src/elicitation/questions/question-scope.helper.ts
  • libs/sdk/src/errors/browser-store.errors.ts
  • libs/sdk/src/errors/index.ts
  • libs/sdk/src/job/__tests__/job-scope.helper.spec.ts
  • libs/sdk/src/job/job-scope.helper.ts
  • libs/sdk/src/scope/__tests__/browser-stores.spec.ts
  • libs/sdk/src/scope/__tests__/scope-store-off.spec.ts
  • libs/sdk/src/scope/browser-stores.ts
  • libs/sdk/src/scope/scope.instance.ts
  • libs/sdk/src/transport/mcp-20260728/__tests__/request-state.spec.ts
  • libs/sdk/src/transport/mcp-20260728/request-state.ts
  • libs/skills/catalog/frontmcp-config/references/configure-elicitation.md
  • libs/skills/catalog/frontmcp-deployment/references/build-for-browser.md
  • libs/skills/catalog/frontmcp-deployment/references/build-for-cli.md
  • libs/skills/catalog/frontmcp-development/references/create-job.md
  • libs/skills/catalog/frontmcp-setup/references/setup-redis.md
  • libs/skills/catalog/frontmcp-setup/references/setup-sqlite.md

Included review availability: This review used your included allowance. Your plan provides up to 5 included reviews per hour; 0 remain after this review.

@frontegg-david

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@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

@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor
Action not completed

Already reviewed the last commit. Use @coderabbitai full review to rerun a review of the entire changeset.

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.

@github-actions

github-actions Bot commented Nov 3, 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-03T09:31:47.066Z
Commit: 832fd3d9

@alexmercerpo
alexmercerpo merged commit 082ff81 into main Nov 3, 2026
47 checks passed
@alexmercerpo
alexmercerpo deleted the feat/f02-memory-stores-browser branch November 3, 2026 12:14
alexmercerpo pushed a commit that referenced this pull request Nov 3, 2026
)

* 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.
frontegg-david added a commit that referenced this pull request Nov 3, 2026
… 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).
frontegg-david added a commit that referenced this pull request Nov 3, 2026
…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.
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.

2 participants