Repository navigation
fix(sdk): CLI mode validates jobs and the top-level sqlite as the server does - #1036
Conversation
…ver 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
…xec, 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.
…e CLI refuse without One create-job example and its comment said enabled was implied, but the decorator refused the block.
|
No actionable comments were generated in the recent review. Walkthrough
Merge Risk:
|
Performance Test ResultsStatus: ✅ All tests passed Summary
Total: 101 tests across 21 projects 📊 View full report in workflow run Generated at: 2026-11-03T10:24:26.076Z |
Stacked on #1030
What and why
Fixes #1032.
CLI mode reads the server config with
parseFrontMcpConfigLite. That parser skips validating the subsystems the CLI host doesn't run, to keep cold start cheap. It also skipped two subsystems the host does run:jobsand the top-levelsqlitewerez.any(). #1028 fixedtasksthe same way.The lite parser runs in two places:
createForCli(), which the generated CLI andconnect(config, { mode: 'cli' })use;createForManifest(), whichfrontmcp buildboots to read a server.A server declared with
@FrontMcpwas already checked by the decorator, which runs the full parser. A config passed as an object was not: an entry that exports one, orconnect(config, { mode: 'cli' }). Before this PR, such a config did the following with a wrong value:jobs.store. A value that is neitherfalsenor an object ('memory',true,null) was taken for unset. The runs went to the top-levelredis(unless it is Vercel KV), else to the top-levelsqlite. A store naming bothredisandsqliteused the SQLite file.jobsfields. A wrongjobs.runner,jobs.defaultTimeoutMsorjobs.admissionreached the job subsystem unchecked, and so did ajobsblock withoutenabled.sqlite. An emptysqlite.pathwas taken for no path, so the file went to~/.{info.name}/sessions.sqlite. A missingsqlite.encryption.secret, for example from an unset environment variable, kept the task store unencrypted. The job stores already refused it, because they open the file throughcreateStorage.The change
The lite schema now takes
jobsandsqlitefromfrontMcpBaseSchema.shape, as it does fortasks. These are the server's schema objects, so the lite parser gives the same messages and fails at the same point.A valid config behaves as before. Both schemas apply defaults, but every CLI-mode consumer already falls back to the same values:
walMode: trueandttlCleanupIntervalMs: 60000, at the top level and injobs.store.sqlite. The SQLite stores usewalMode !== falseand?? 60000.jobs.store.redis: it is normalised like the server'sredisoption: aurlbecomeshost,port,password,dbandtls, andport,db,tls,keyPrefixanddefaultTtlMsget their defaults. The job stores connect with exactly those connection fields. Their keys go underjobs.store.keyPrefix ?? 'mcp:jobs:', never underredis.keyPrefix.createForManifest()still drops the backends andjobs.storeafter parsing, as before.The other keys the lite parser doesn't validate
I checked every key that the lite parser passes through as
z.any(), or drops, while the full parser validates it. Onlyjobsandsqliteare both the bug this issue reports (an option the CLI host uses to pick a store, silently taken for unset) and free of any behaviour change for a valid config. The rest are follow-ups:redis(top level)redis: { url }into{ provider: 'redis', host, port, … }. CLI mode passes the raw{ url }, andcreateTaskStoredoesn't recognise it because it looks forhost. So in the generated CLI, tasks on a URL-formredisfall tocreateStorage's auto-detection (REDIS_URL, else memory), while the server keeps them in that Redis. Validating the key moves those tasks to Redis, which is a behaviour change. Worth its own issue.fetchthis.fetch()in toolsforwardCallerTokenToandforwardCustomHeadersToentries to their origin (https://api.example.com/v1becomeshttps://api.example.com). Validating would change which hosts get the caller's token in CLI mode, so it needs its own security-reviewed PR.transportdistributedMode,providerCaching,protocoltransportas well.httpentryPath,forwardHeaders, andportfor the local auth issuerport,entryPathfromFRONTMCP_HTTP_ENTRY_PATH,bodyLimit) would start to apply.authoritiespagination,elicitation,metricspagination.toolsintools/list,elicitation.enabledin tool contexts, andmetrics.enabled/includefor answer meteringclients,observability,channels,registry,sessionregistryserves HTTP routes, which CLI mode doesn't serve.sessionis deprecated and only warns.pubsub,extApps,ui,health,throttleUser-facing change
What changed: CLI mode validates
jobsand the top-levelsqlitewith the server's schemas. That covers the generated CLI (frontmcp build --target cli),connect(config, { mode: 'cli' }),FrontMcpInstance.createForCli(), and the server read thatfrontmcp builddoes. An invalid value fails at startup with the server's error, which carries messages such as:jobs.store must be false (runs kept in memory) or an object with redis or sqlitejobs.store takes redis or sqlite, not bothjobs.runner must be 'inline', 'worker' or 'detached'jobs.defaultTimeoutMs must be at most 2147483647 ms (about 24.8 days)Zod's own messages cover
jobs.enabled,jobs.admission.*,jobs.store.sqlite.path,sqlite.pathandsqlite.encryption.secret. The generated CLI prints the error asError: …and exits with code 1.Before → after (for a config given as an object, not through
@FrontMcp):jobs.store: 'memory',trueornull: the CLI ran the jobs and kept the runs in the top-levelredisorsqlite→ it fails at startup and opens no store.jobs.storenaming bothredisandsqlite: the runs went to the SQLite file → it fails at startup.jobs.runner,jobs.defaultTimeoutMsorjobs.admission, orjobswithoutenabled(such asjobs: { store: false }): accepted → it fails at startup.sqlite.path: '': the file went to~/.{info.name}/sessions.sqlite→ it fails at startup.sqlite.encryption.secretmissing or empty: tasks were stored unencrypted → it fails at startup.frontmcp build --target clior--target mcpbof such a config: it built → the build fails with the same error. The other targets still build but printfrontmcp.manifest.json not written: …, and--strict-lintfails them. Their bundles, like the MCPB server, use the full parser, which already refused the config at boot.The server's parser, and so
@FrontMcp, already refused every one of these values,jobswithoutenabledincluded. The build reads the config in its own environment, as it already did for a@FrontMcpserver. So a value read from an environment variable that the build doesn't set, such assqlite.encryption.secretorjobs.store.redis.url, now fails those builds of a config object too.frontmcp.dev pages affected (if known): the CLI target (build for CLI, server configuration), jobs configuration (
jobs.store), SQLite setup (the generated CLI).Skills catalog entries updated:
frontmcp-deployment/references/build-for-cli.md: the generated CLI validatesjobsandsqlite, and what it did up to 1.10.frontmcp-development/references/create-job.md: the generated CLI andconnect(..., { mode: 'cli' })validatejobs, and what they did up to 1.10. Itsjobssnippets now setenabled: true, which ajobsblock needs. One example and its comment said it was implied, but the decorator refused it.frontmcp-setup/references/setup-sqlite.md: the generated CLI validates the top-levelsqlite, and what it did up to 1.10.Bundle size
The lite schema reaches browser bundles. It now references two existing schema objects in place of two
z.any()calls. I measured withnx run sdk:build --skip-nx-cacheand thennx test demo-e2e-bundle-size:create()+ one toolcreate()+ WebMcpPlugin + one toolTests
All new tests were written first and run against the unchanged code. The failing ones are marked "red first".
libs/sdk/src/common/metadata/__tests__/parse-lite.spec.ts:jobs(store: false,runner,defaultTimeoutMs,admission) and a validsqlitesurvive the lite parser.jobs.store: 'memory';redisandsqlite;jobs.runner: 'thread';jobs.defaultTimeoutMsover the timer limit.jobswithoutenabled, ajobs.store.sqlitewith an emptypath,jobs.admission.concurrency: 0,sqlite.path: '', andsqlite.encryption: { secret: undefined }.libs/sdk/src/scope/__tests__/scope-store-off.spec.ts(CLI mode,createForCli, top-levelsqlite):jobs.storefails at startup before any SQLite store opens. Red first.jobs.store: falsekeeps the runs off the SQLite file, wherejobs: { enabled: true }puts them in it.startGraphand shared with the existingtasks.store: falseCLI test.apps/e2e/demo-e2e-cli-exec/e2e/cli-build-jobs-config.e2e.spec.ts: builds a scratch server, a config object with a job, the top-levelsqlite, andjobsread from the environment, withfrontmcp build --target cli --js. Then it runs the generated CLI'sjob run echo:jobs.store: 'memory'it exits 1, prints the server's message, and never creates the SQLite file. Red first: it exited 0 and ran the job.jobs: { enabled: true }the run'smcp:jobs:keys land in the SQLite file. Withjobs.store: falsethe file holds none.runCliBundle(bundle, args, env)helper ine2e/helpers/exec-cli.tsruns any generated CLI bundle, andrunClinow calls it. The file's keys are read with this app's owne2e/helpers/sqlite-keys.ts. Importing thedemo-e2e-jobscopy would makedemo-e2e-cli-execdepend on that app in the Nx graph, so its tests would build it first.testtarget now depends oncli:build, the CLI binary its specs drive, as well as^build.nx test sdk: 665 suites, 9,312 tests.nx test skills: 249 tests.nx test demo-e2e-cli-exec: 37 suites, 211 tests.nx test demo-e2e-bundle-size: 42 tests.nx run-many -t lint -p sdk skills demo-e2e-cli-exec.node scripts/fix-unused-imports.mjs a388ff236, andprettier --checkon the changed files.--noEmittypecheck of the changed SDK sources and specs, plustsc --noEmit -p tsconfig.e2e.jsonindemo-e2e-cli-exec. That app's three errors are pre-existing, in other specs.frontmcp build --target clior--target mcpbof a config withjobs.store: 'memory'exits 1 with the server's message, and--target nodebuilds with the "manifest not written" warning. A@FrontMcpserver whosesqlite.encryption.secretreads an unset environment variable already failed--target clithe same way.Summary by CodeRabbit
Bug Fixes
enabledto be explicitly set. Setting the job store tofalsekeeps job runs in memory.Documentation