Repository navigation
feat(sdk): lazy apps — listed from the build manifest, imported on their first call - #1025
Conversation
…cp.manifest.json until then
A lazy app ({ name, lazy: () => import('./billing.app'), enabled? }) is listed from the build manifest
through the normal list flows without importing it. A call to one of its entries imports and initializes
it once, before the request's flow collects its hooks, then runs the app's own entry through the normal
flow. A stale manifest is replaced by the loaded entries with list_changed; a failed import is a typed,
retryable LAZY_APP_LOAD_FAILED; production without a manifest does not start; development without one
imports lazy apps at startup.
…h stages and the answer budget Also fit the browser bundle budgets to the lazy app code: create() + one tool grows 1,330,324 -> 1,333,826 bytes (+3,502) and create() + WebMcpPlugin + one tool 1,337,501 -> 1,341,005 (+3,504) over the parent's head.
… load - A lazy app cannot have hooks that reach past its own calls, which would guard and shape nothing until it is imported: a plugin with scope 'server' (refused before the app initializes), a hook with appliesTo 'all-apps', or a hook on a flow other than the four entry call flows (a list hook). Nor duties, which start with the server, nor entries with featureFlag or codecall metadata, which the feature-flag and CodeCall plugins read from the placeholder (CodeCall's policy judged a tool that opted out by its placeholder and then ran the loaded tool). - A failed load is undone: the hooks it registered are unregistered (HookRegistry.removeHooks), its providers get onShutdown() and are disposed, and its adapters are disposed. Configuration errors are answered until a restart; any other failure is retried by the next call, instead of deciding by a scope-wide hook count. - An import that resolves after the server began shutting down does not start the app. - A lazy app's id must be no other app's (DuplicateAppIdError).
…deCall; budgets fit the fix - codecall-lazy-app.flow.spec.ts: the manifest of a lazy app whose tool declares codecall is not built, and with a manifest from before the opt-out codecall:execute's callTool is refused without running the tool (it ran before the fix). - official-plugins.md: no featureFlag or codecall on a lazy app's entries, and what CodeCall's filters see before a lazy app loads. - The browser bundle budgets rise by the fix's 580 bytes, to the PR's measured growth.
…nto feat/m19-lazy-apps # Conflicts: # apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts # apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json
The lazy app kind, its placeholders and the loader ship in the browser build, where lazy imports split code: create() + one tool grows by 4,084 bytes minified, create() + WebMcpPlugin + one tool by 4,082, and the Vite initial chunk by 860, all over #1024's head. The three budgets move up by exactly that.
|
@coderabbitai review |
|
Warning Review limit reachedYour organization has reached its usage spending cap. Adjust your spending cap in the billing tab. Next included review available in 52 minutes. View limit details
Walkthrough
Merge Risk:
|
Action not completed
|
…nto feat/m19-lazy-apps
…nto feat/m19-lazy-apps
Performance Test ResultsStatus: ✅ All tests passed Summary
Total: 101 tests across 21 projects 📊 View full report in workflow run Generated at: 2026-11-03T04:18:08.428Z |
…nto feat/m19-lazy-apps
|
@coderabbitai review |
Action not completed
|
|
@coderabbitai review |
Action performed
|
…d set up one guard for lazy apps loading at once - completion:complete gets the checkAppLoaded stage prompts:get-prompt and resources:read-resource have, so a completion whose app failed to load is answered LAZY_APP_LOAD_FAILED instead of an empty completion. - Scope.checkLateApp runs the late guard setup one app at a time: two lazy apps loading together each created a guard manager, the second replacing the first (and, on Redis, leaking its connection). - With throttle.enabled false, a lazy app's load no longer replaces the publicAccess guard startup set up, which reset the anonymous rate limit. - Docs: the node example runs dist/node/<name>.bundle.js; an off enabled source keeps a lazy app unimported once the manifest lists it, while development without one imports it unless enabled is false. - Bundle budgets re-fitted to the measured growth over #1024's head.
|
@coderabbitai review |
Action not completed
|
… on edge with only a persistent-questions Redis - AgentInstance.callToolThrough passes the agent call's signal into the tools:call-tool ctx, so a tool the model calls (or a nested agent's invoke tool) aborts with the agent's call, and a persistent question it waits on stops waiting instead of taking an answer after the call ended. - Scope: a Redis named only in elicitation.persistent.storage (no elicitation.redis, no server redis) is passed to createElicitationStore as its storage, so an edge server with it starts instead of failing the distributed-storage check before the questions open. A server sqlite still wins, and memory or sqlite question stores are not shared. - Docs: configure-elicitation says where the elicitation state goes; the persistent-questions examples declare their tools' outputSchema. - Bundle budgets re-fitted to the measured growth over #1025's head.
|
@coderabbitai review |
Action performed
|
…lazy app's import in development
…elicitation store on dispose - agents:call-agent gives the agent a controller linked to the call's signal, as tools:call-tool does, and aborts it when execute() fails. The run loop enforces execution.timeout itself and rejects with a plain Error, which neither flow treated as a timeout, so a step it cut short (a tool the model called, a persistent question) kept going and could take an answer meant for the next call. linkedAbortController moves to utils/abort.utils so both flows share it. - The scope destroys the elicitation store and disconnects its storage on dispose; a Redis opened for it stayed connected after the scope ended. - Docs: the question storage precedence lists elicitation.redis; an agent's this.signal is described in create-agent and offload-cpu-work. - Bundle budgets re-fitted to the measured growth over #1025's head.
…k an answer a cancelled call took - An edge runtime has no store a question can be kept in: redis needs a TCP connection, sqlite a local file, memory ends with the isolate, and the HTTP stores (upstash, vercel-kv) have no atomic setIfEquals. The shared persistentQuestionsSettings() now refuses elicitation.persistent there with InvalidQuestionStoreError and the reason, before the scope opens either store; the docs no longer promise a Redis-only edge server. - askPersistentQuestion checks the call's signal after store.ask and after the async schema check: when the call was cancelled first, the answer it took is put back for the next call and it gets QuestionPendingError. The answered outcome carries the question's expiresAt, and restore() keeps it (an answer past it is dropped) instead of starting a new TTL. - Docs: create-agent and offload-cpu-work say where execution.timeout applies (the default run loop, and the call when timeout.executeMs is unset). - Bundle budgets re-fitted to the measured growth over #1025's head.
…an inbox, delivered exactly once (#1026) * feat(sdk,auth,cli): keep elicitation questions for the caller that asked, with an inbox to answer them this.elicit(message, schema, { persist: { key } }) keeps the question for its subject (the signed-in user, else the verified session) in a store opened through the storage factory, with a TTL longer than the call's wait. A person signed in for that subject answers it at /_frontmcp/inbox or with frontmcp inbox; the answer reaches the waiting call, or the next call of the subject, once. * perf(sdk): check elicitation.persistent when the store opens, keeping its schema out of the browser bundle * perf(sdk): keep the persistent question errors with the questions, out of the always-loaded errors * test(e2e): fit the browser bundle budgets to persistent questions * docs(skills): persistent questions, their inbox and frontmcp inbox in the elicitation pages * test(e2e): type the persistent questions API for a strict consumer * test(sdk,cli): cover putting an answer back, store contention, inbox text bodies and the inbox command; let agent scopes ask persistent questions * fix(sdk): print the inbox sign-in link on the server console instead of handing it to the caller A code in a tool result let the agent sign in for its own subject and answer its own question. printQuestionInboxLink() prints the link on stderr, where the person at the machine reads it as they read the startup token, and returns only its expiry. * fix(cli): read the inbox link from stdin or FRONTMCP_INBOX_LINK, follow no redirect, and print no control characters frontmcp inbox takes the link from FRONTMCP_INBOX_LINK, a terminal prompt or stdin when the command line gives none, so the one-time code shows in no process list or shell history. Every request refuses redirects, so the session cookie and an answer never follow a redirect to another server. Plain http is taken for a loopback server only, and control characters and bidi overrides the server sends are stripped before printing. * fix(sdk): bind persistent questions to issuer, server and key, confine subject sign-ins, and print inbox links only on a terminal - A signed-in caller's subject is principal:<iss>#<sub>, so the same sub at another issuer is another subject. - The store is keyed by the server's info.name, so servers that share a Redis, a SQLite file or the default questions.sqlite do not share questions; a record found at a key it does not belong at is dropped, so a ciphertext moved to another question's place is never delivered. - The default questions.sqlite is refused when another user could replace it or read it (M12's directory guard, a regular owner-only file). - New route auth 'subject': the inbox routes take only a session from a createCode({ subject }) code, and a 'people' route refuses such a session, so a code sent for one caller's questions opens no other page. The startup token's session gets 401 at the inbox. GET /_frontmcp/questions is no-store. - elicitation.persistent.inboxLinks: 'terminal' (default) prints an inbox link only while stderr is a terminal, since a link in a log, a stdio client's capture or an agent's output lets its reader answer; 'stderr' prints it always, 'off' never. * docs(skills): subject routes, issuer-bound subjects, inboxLinks and the hardened frontmcp inbox * test(e2e): fit the browser bundle budgets to persistent questions on top of #1025 The question options and the 'subject' route kind reach both measured bundles (+661 bytes minified each over #1025's head), and the utils change feed now joins the Vite initial chunk (+3,462). The three budgets move up by exactly that. * fix(sdk): give an agent its call's abort signal, so a persistent question it waits on stops with the call - AgentContext passes `signal` to ExecutionContextBase, and AgentInstance forwards the call's signal into the constructor arguments. `this.signal` was always undefined in an agent, so a cancelled or timed-out agent call kept waiting on its persistent question and could take an answer meant for the next call. - Docs: only a signed-in caller's question reaches its other sessions; a session-scoped caller's stays with its session. The inbox example's features name `frontmcp inbox --url <server>`, which keeps the one-time code out of shell history and process lists. * fix(sdk): stop an agent's tool calls with the agent's call, and start on edge with only a persistent-questions Redis - AgentInstance.callToolThrough passes the agent call's signal into the tools:call-tool ctx, so a tool the model calls (or a nested agent's invoke tool) aborts with the agent's call, and a persistent question it waits on stops waiting instead of taking an answer after the call ended. - Scope: a Redis named only in elicitation.persistent.storage (no elicitation.redis, no server redis) is passed to createElicitationStore as its storage, so an edge server with it starts instead of failing the distributed-storage check before the questions open. A server sqlite still wins, and memory or sqlite question stores are not shared. - Docs: configure-elicitation says where the elicitation state goes; the persistent-questions examples declare their tools' outputSchema. - Bundle budgets re-fitted to the measured growth over #1025's head. * fix(sdk): abort an agent's signal when its call fails, and close the elicitation store on dispose - agents:call-agent gives the agent a controller linked to the call's signal, as tools:call-tool does, and aborts it when execute() fails. The run loop enforces execution.timeout itself and rejects with a plain Error, which neither flow treated as a timeout, so a step it cut short (a tool the model called, a persistent question) kept going and could take an answer meant for the next call. linkedAbortController moves to utils/abort.utils so both flows share it. - The scope destroys the elicitation store and disconnects its storage on dispose; a Redis opened for it stayed connected after the scope ended. - Docs: the question storage precedence lists elicitation.redis; an agent's this.signal is described in create-agent and offload-cpu-work. - Bundle budgets re-fitted to the measured growth over #1025's head. * fix(sdk): refuse persistent questions on an edge runtime, and put back an answer a cancelled call took - An edge runtime has no store a question can be kept in: redis needs a TCP connection, sqlite a local file, memory ends with the isolate, and the HTTP stores (upstash, vercel-kv) have no atomic setIfEquals. The shared persistentQuestionsSettings() now refuses elicitation.persistent there with InvalidQuestionStoreError and the reason, before the scope opens either store; the docs no longer promise a Redis-only edge server. - askPersistentQuestion checks the call's signal after store.ask and after the async schema check: when the call was cancelled first, the answer it took is put back for the next call and it gets QuestionPendingError. The answered outcome carries the question's expiresAt, and restore() keeps it (an answer past it is dropped) instead of starting a new TTL. - Docs: create-agent and offload-cpu-work say where execution.timeout applies (the default run loop, and the call when timeout.executeMs is unset). - Bundle budgets re-fitted to the measured growth over #1025's head. * docs(skills): derive the persistent-question examples' execute() types from their schemas, as create-tool's derive-execute-types rule requires
Stacked on #1024
What and why
Proposal item 4.1 (b): lazy apps. A server that imports many apps eagerly pays for all of them in every process it
starts; with this, an app's code is imported on its first call, and until then the build manifest (M18's
frontmcp.manifest.json) answers the lists for it.A new app kind,
AppKind.LAZY_IMPORT.{ id?, name, description?, lazy, enabled? }is validated with a strict Zodschema (
lazyAppMetadataSchema); any other field fails at startup withLazyAppConfigurationErrornaming the field.The import may resolve to the
@Appclass, to a module whose default export is one, or to a module with exactly one@Appexport. A lazy app always joins the server's scope (standalone: false), and it shares the duplicate-id checkwith
App.esm()/App.remote()apps. The instance (AppLazyInstance) is loaded with a dynamic import, as the remoteinstance is.
Placeholders answer the lists through the normal list flows. For each tool, resource, resource template and
prompt the manifest lists for the app (
app === <id>), the app's registries hold a placeholder: a realToolInstance/ResourceInstance/PromptInstancebuilt from the manifest's fields (schemas as JSON Schema,annotations, icons,
_meta, tags, aliases, visibility, execution, output policy, examples,availableWhen) under theapp's owner. The scope adopts these registries like any local app's, so
tools/list,resources/list,resources/templates/listandprompts/listrun their usual stages over them: list hooks,enabled,availableWhen,client profiles, the app's and the tool's output policies (the lazy app's
outputis the manifest'sapps[].output),the elicitation extension of output schemas, the surface hash. The unit tests compare each list with the eager
server's, byte for byte.
The first call imports the app before the request's flow collects its hooks.
tools/call,resources/read,prompts/getandcompletion/completealready resolve their entry in the flow class'sresolveHookOwnerId(whereremote apps load their capabilities); when that entry is a placeholder and it is on, the app is imported and
initialized there, and the entry is resolved again. So even the first request runs the app's own entry, with the hooks
its plugins and providers registered, its static hooks, and every stage of the normal flow (authorization, M04
aliases,
enabled, input and output schemas, the answer budget). Concurrent first calls wait on one import. A newhookable stage,
checkAppLoaded(aftercheckEnabledin the three call flows), answers an entry that is on but stilla placeholder: its app failed to load before the run.
When a lazy app loads after startup, the checks startup runs on every app run for it (
Scope.checkLateApp): metadataonly a plugin enforces (
UnenforcedMetadataError), the hook order, and the guard its tools'rateLimit/concurrencyneed (without it those limits would silently do nothing); its providers getonReady().Swapping placeholders for the loaded entries. The app's own registries drop the placeholders and adopt the loaded
app's registries; lineage de-duplication keeps the owner path
app:<id>, as for an eager app. The loaded entries arecompared with the manifest's, using M18's own per-entry projections (
manifestToolOf,manifestResourceOf,manifestPromptOf, now exported from the manifest builder) and a key-order-independent comparison:keepingListsUnchanged(): the registries' change events still fire,while the notification service, the live surface's tool-list version and the 2026-07-28 subscription stream skip
their
list_changed).then on, the server logs
App "billing" does not match frontmcp.manifest.json (tools): its entries are listed from its code from now on. Run \frontmcp build` to write the manifest again., and each list that changed (tools, resources, prompts; the app's output policy counts for tools) gets onelist_changed`. A call to a tool the stalemanifest listed but the app no longer has is answered as an unknown tool. Reloading keeps the server serving what
its code does, and the input and output of every call are checked against the loaded entry's own schemas, so the
stale listing cannot make a call run against the wrong contract.
A feature that is off is never loaded. The lazy app's
enabled(a boolean or anEnabledSource, M08) is theplaceholders' switch: while it is off they are hidden and refused with
FEATURE_OFFand the app's remedy, and noimport happens. After the app is loaded, its entries take the same switch, so turning it off again hides them. An
entry the manifest lists as
enabled: falseis refused without importing the app.Failures. A failed import answers the call with
LazyAppLoadError(LAZY_APP_LOAD_FAILED, 503, JSON-RPC-32603,App "Billing" could not be loaded, with the remedyCall it again: the server imports the app again on the next call. ...); the cause is logged, never sent; the server keeps serving; the next call imports again. The failedattempt is undone first: the hooks it registered (those the app owns and those its entries' classes declare) are
unregistered (new
HookRegistry.removeHooks()), its providers getonShutdown()and are disposed, and its adaptersare disposed, so a retry starts clean and never runs a hook twice. A failure the app's code must fix
(
LazyAppConfigurationError, such ascannot be lazyor an import that resolves to no@App, andUnenforcedMetadataError) is not retried: every call gets the same error with the remedy to fix the app and restart.An import that resolves after the server began shutting down does not start the app (no
onBootstrap(), noonReady()).Where the manifest comes from. New option
@FrontMcp({ manifest }): a path (relative to the file that declaresthe server) or the manifest object (a Worker or browser build imports it). Default:
frontmcp.manifest.jsonnext tothe entry script (
process.argv[1]), which is wherefrontmcp buildwrites it for every target (dist/<target>/,the mcpb archive's
server/). It is read once per scope, only when the scope has a lazy app, and validated withserverManifestSchema; an unreadable one is a startup error.NODE_ENV=production): startup fails withLazyAppConfigurationError:App "billing" is lazy, and no frontmcp.manifest.json was found (looked for <path>). In production a lazy app is listed from the manifest until its first call: Run \frontmcp build` and ship frontmcp.manifest.json next to the server'sentry, or pass @FrontMcp({ manifest }).` A manifest that does not list the app counts as none.
No frontmcp.manifest.json (looked for <path>): lazy apps are loaded at startup. Run \frontmcp build` to write one;with it, each lazy app is imported on its first call.
A lazy app withenabled: false` is not imported (it thenhas no entries to list).
connect(config, { mode: 'manifest' }), whichfrontmcp buildandbuildManifest()use, nowmarked on the config by
createForManifest()): lazy apps are imported so the manifest has their entries. The lazyapp's own switch is not applied there, so the manifest records the entries' own
enabled, not the app's.What a lazy app cannot have. Anything a placeholder cannot list without the code, or that only startup sets up:
skills, agents, jobs, workflows, channels, duties (
@Dutyproviders start with the server), its ownauth,standalone, appuidefaults, widget tools, entries withauthorities, entries with anenabledsource of theirown (the lazy app's
enabledis the switch for that), and entries withfeatureFlagorcodecallmetadata (thefeature-flag plugin's list filter and CodeCall's list filter, search and policy read them from the placeholder, which
carries only the manifest's fields). Nor hooks that reach past its own calls, which would guard and shape nothing
until the app is imported: a plugin with
scope: 'server'(refused before the app initializes), a hook withappliesTo: 'all-apps', or a hook on any flow buttools:call-tool,resources:read-resource,prompts:get-promptandcompletion:complete(a list hook, say).appliesTo: 'uncovered-apps'is allowed: anotherapp's entry that needs such a gate fails startup's enforced-metadata check while the lazy app is not loaded.
Whenever the app loads (at startup in development, in
frontmcp build, on the first call in production) such an appfails with
LazyAppConfigurationError: App "billing" cannot be lazy: <reasons>. ... Remove \lazy` to import the appat startup, or move these to an app that is not lazy.
, and so does a manifest that lists one of them for a lazy app, at startup. An import that resolves to another app's id fails the same way, and a lazy app whose id is any other app's (local, remote or lazy) stops startup withDuplicateAppIdError. Until a lazy app loads, code that reads its entries outside their own calls (a third-party list hook, CodeCall'sincludeTools` filter) sees only the fields themanifest records.
Smaller changes: the empty plugin / adapter / skill registries of the remote app instance moved to
app/instances/empty-app-registries.tsso the lazy instance shares them;EnforcedMetadataScopetakes the registrymethods it reads (
Pick<...>), so the check runs on one app's registries; theDuplicateAppIdErrormessage nameslazy apps too.
Deviations from PLAN.md / the brief:
resolveHookOwnerId, before the flow, rather than in a stage: a flow collects the hooks that runfor a request (owner hooks, the entry class's static hooks) before its first stage, so importing in a stage would
have run the first call without the hooks of the app's own plugins (an approval or audit plugin, for instance). The
hookable
checkAppLoadedstage reports a failed import through the flow.list_changed(above) instead of failing closed.enabledof its own: the proposal's "a feature that is off is never loaded" is about a featureswitch (2.3), which the placeholders cannot read from the app's code.
@FrontMcp({ manifest }), needed where there is no file system (Workers, browsers) and for tests.frontmcp build --target nodeand runs thebundle in production, which covers finding the manifest next to the entry.
User-facing change
appstakes a lazy app:{ id?, name, description?, lazy: () => Promise<unknown>, enabled?: EnabledOption }. Newexports:
LazyAppMetadata,lazyAppMetadataSchema,LazyAppLoadError,LazyAppConfigurationError,LazyAppRecordMetadata,AppLazyImport,AppKind.LAZY_IMPORT,LateAppRegistries.@FrontMcpoptionmanifest?: string | ServerManifest(message for a wrong type:`manifest` must be the path of a frontmcp.manifest.json, or the manifest itself).LAZY_APP_LOAD_FAILED(App "<name>" could not be loaded, data{ code, status, app, retryable }, remedy),LAZY_APP_CONFIGURATION_ERROR(Lazy app "<name>" is invalid (...),App "<id>" is lazy, and ...,App "<id>" cannot be lazy: ...,App "<id>": the lazy import resolved to no @App class ...,App "<id>": the lazy import resolved to the imported app "<other>" ...,<where> is not a frontmcp.manifest.json this server can read (...)).No frontmcp.manifest.json (looked for <path>): lazy apps are loaded at startup. ...(info, onceper scope),
App "<id>" is loaded at startup: <where> does not list it. ...(info),App "<id>" does not match frontmcp.manifest.json (<lists>): ...(warn),App "<id>" could not be loaded (<retry or restart>): <cause>(error).
checkAppLoadedintools:call-tool,resources:read-resourceandprompts:get-prompt.appsitems that are neither a class nor a remote app now fail withapps items must be annotated with @App() | @FrontMcpApp(), or be a valid remote app or lazy app ({ name, lazy }) configuration.; theDuplicateAppIdErrormessage addsand each lazy app its own \id` or `name`.`ScopeEntryhas a new abstract methodcheckLateApp(app);HookRegistryhasremoveHooks(hooks).App "<id>" cannot be lazy: ...reasons:it has duties ("<name>"), which start with the server,plugin "<name>" has scope 'server' (its hooks would guard no other app until this one is imported),the hook <plugin>.<method> is on <flow>, which runs it for every app,the hook <plugin>.<method> applies to all apps,<entry> declares 'featureFlag'/'codecall',which a plugin reads from its placeholder. A lazy app whose idanother app has fails startup with
DuplicateAppIdError.the manifest until then; a lazy app that is off is never imported.
frontmcp.manifest.jsonwas a build artifact only → a server with lazy apps reads it at run time; a productiondeployment of a lazy app must ship it next to the entry (or pass
@FrontMcp({ manifest })), or it does not start.the
@FrontMcpconfiguration reference (manifest), the build manifest page (read at run time by lazy apps), thelive surface /
enabledpage (a lazy app's switch), the production checklist, the error reference(
LAZY_APP_LOAD_FAILED), the flow stages reference (checkAppLoaded).frontmcp-setup/references/multi-app-composition.md: a "Lazy Apps (Imported on First Call)" section (options,lists, first call, stale manifest, failures, where the manifest comes from, what a lazy app cannot have), patterns,
checklist and troubleshooting rows, the new example in its table, the description.
frontmcp-setup/examples/multi-app-composition/lazy-apps-from-the-manifest.md, registered inskills-manifest.json;frontmcp-setup/SKILL.md(routing row, examples table).frontmcp-deployment/references/build-manifest.md: "Lazy Apps Read It at Run Time", a checklist line and atroubleshooting row.
frontmcp-development/references/decorators-guide.md:manifest?row.frontmcp-production-readiness/references/common-checklist.md: ship the manifest with lazy apps.create-tool/references/availability.md: a lazy app'senabled, andfeatureFlagSource()in place of anentry's
featureFlag.multi-app-composition.md(the failure bullet: what is undone, what is retried; the new "cannothave" items; what code outside the calls sees before the first call; two checklist lines; the
LAZY_APP_LOAD_FAILEDrow);frontmcp-development/references/create-plugin.md(a lazy app cannot install aplugin whose hooks reach every app);
frontmcp-development/references/official-plugins.md(nofeatureFlagon alazy app's entries,
featureFlagSource()on the lazy app instead; CodeCall judges a lazy app's tools by themanifest until it loads, so they cannot declare
codecall).Tests
Red first: the three unit specs were written before the implementation and all 37 of their first tests failed
(the lazy app config was refused, and
normalizeApphad no lazy kind). Two key mechanisms were also checked bymutation: without the late guard setup the rate-limit test fails, and without the unchanged-lists announcement guard
the no-
list_changedand stale-manifest tests fail.libs/sdk/src/app/__tests__, 45 tests):app.lazy.listing.spec.ts(5): with a list hook, elicitation and an app output policy ofschemaMode: 'both', thelazy server's tools, resources, templates and prompts equal the eager server's and nothing is imported; the same
for a client profile with
structured: 'text'; a matching manifest swaps with nolist_changedand no warning; astale one lists the manifest until the first call, then the code, with
tools/list_changedonly and the warning;a tool the stale manifest lists and the app lost is answered as unknown.
app.lazy.calls.spec.ts(16): the first call imports, initializes (provideronReady()), and runs with the app'splugin hook; the authorization stage sees the loaded tool class; the answer budget fits the loaded tool's answer;
five concurrent first calls import once; an alias (M04) reaches the loaded tool; the output schema refuses a bad
answer; resource, template and prompt reads load the app once; an entry the manifest lists as off is refused with
FEATURE_OFFwithout importing;enabled: falsenever imports and refuses tools and resources; anEnabledSourceoff (not imported, its remedy), on (imported), off again (hidden,
FEATURE_OFF); a failed import givesLAZY_APP_LOAD_FAILEDwith a remedy and no cause, the other app keeps serving, the next call imports again; afailed resource read and prompt get; an app that fails after registering hooks is not imported again and says to
restart; a completion loads the app; a late-loaded tool's
rateLimitis enforced (the guard is created); metadatano installed plugin enforces refuses the load.
app.lazy.boot.spec.ts(24): normalization and four invalid configs; development without a manifest imports atstartup with one log line; production without one does not start;
enabled: falseis not imported; the manifestis built by importing the app and does not record the app's switch; the manifest read from
@FrontMcp({ manifest })and from next to the entry script;
splitByApp; an invalid manifest; a manifest that lists a skill for a lazy app;six things a lazy app cannot have; an import that is not an
@Appclass, one with another id, a default export.app.lazy.instance.ts94.5%,app/lazy/*97.1%, errors, flags andunchanged-lists.ts100%).app.lazy.boot.spec.ts(+7): refused at startup in development withcannot be lazy: ascope: 'server'plugin,one nested in another plugin, a plugin hook on
tools:list-tools, a hook withappliesTo: 'all-apps', a@Dutyprovider, a tool declaring
featureFlagandcodecall; (+1) a lazy app whose id a local app has fails withDuplicateAppIdError(red: it booted, the two apps sharingapp:core).app.lazy.calls.spec.ts(+3): a failure after the app registered hooks (a plugin's provider that refuses once) isretried with the remedy
Call it again, the retried app's plugin and class hooks run once, and the failedattempt's provider got
onShutdown()(red:restart the server, noonShutdown()); an app refused for a list hookleaves none of its hooks registered, so
tools/listand later calls run none of them (red: the app loaded); animport that resolves after the server closed bootstraps no provider and runs no
onReady()(red: both ran).(+1) Another app's
this.callTool()imports the app and runs the call with its hooks.hooks/__tests__/hooks.utils.spec.ts(+1):removeHooks()drops hooks from the flow, stage and class indexes andthe records, and keeps the rest in order.
plugins/plugin-codecall/src/__tests__/codecall-lazy-app.flow.spec.ts(2): building the manifest of a lazy appwhose tool declares
codecallfails; with a manifest from before the tool opted out,codecall:execute'scallTool('purge')is refused and the tool never runs (red: the build succeeded, and the script returnedstatus: okafter running the opted-out tool).apps/e2e/demo-e2e-multiapp/e2e/lazy-apps.e2e.spec.ts(4):src/lazy/write-manifest.tswrites the manifest asfrontmcp builddoes; in production the server lists the billing app (tool schema, resource) while its module'simport counter stays 0; three concurrent first calls and a resource read import it once; development without a
manifest imports it at startup; production without one does not start, with the message.
apps/e2e/demo-e2e-cli-exec/e2e/cli-build-lazy-apps.e2e.spec.ts(1):frontmcp build --target nodeon a scratchproject writes the lazy app's tools into
dist/node/frontmcp.manifest.json; the bundle run withNODE_ENV=productionfinds it next to itself, listschargewith the module not imported, and imports it on thefirst call.
demo-e2e-dts-consumer→lazy-apps-must-compile.ts:LazyAppMetadata(withfileSignal()), lazy apps inapps,@FrontMcp({ manifest })as a path and an object, the two errors, and a@ts-expect-errorfor alazythat is not a function.
bundle inlines, and the flows import the small placeholder module (580 bytes of the growth are the second review's
fix). The numbers and the budget changes are in "Bundle size" below.
libs/sdkwhole suite (--maxWorkers=50%): 626 suites, 8666 tests passed; after the last changes, theapp,manifest,surfaceandnotificationfolders again: 22 suites, 270 passed; the lazy specs: 45 passed.nx test skills: 249 passed.plugin-feature-flags,plugin-codecall,plugin-skilled-openapi,adaptersandtesting: 3346 passed (8 skipped, as before).nx test demo-e2e-multiapp: 3 suites, 34 passed.demo-e2e-dts-consumer: 27 passed.demo-e2e-bundle-size: 12passed.
demo-e2e-notifications: 50 passed.demo-e2e-feature-flags: 33 passed.demo-e2e-protocol-20260728:147 passed (one test failed once under machine load and passed when the suite ran again alone). In
demo-e2e-cli-exec(on a freshcli:build):cli-build-manifest,cli-skills-export-from-entryand the newcli-build-lazy-apps: 3 suites, 8 passed.nx lint sdk skills: 0 errors.tsc --noEmitonlibs/sdk/tsconfig.lib.jsonandlibs/sdk/tsconfig.spec.json:no errors; on the multiapp app's and the cli-exec e2e's configs: none in the new files.
node scripts/fix-unused-imports.mjs origin/feat/m18-build-manifestandprettier --checkon every changed file:clean.
--committed, against the parent): 0 findings on the feature commit; on both commits, 1 (thebuilt-bundle e2e left its server running when the client failed to connect), fixed.
libs/sdkwhole suite (--maxWorkers=50%): 626 suites, 8681 tests passed; the lazy andhook-registry specs: 67 passed (coverage of
app.lazy.instance.ts,app/lazy/*andhook.registry.ts: 96% lines).plugin-codecall: 43 suites, 1213 passed.nx test skills: 249 passed.nx test demo-e2e-multiapp: 34 passed.demo-e2e-dts-consumer: 27.demo-e2e-bundle-size: 12.demo-e2e-notifications: 50.demo-e2e-feature-flags:33.
demo-e2e-protocol-20260728: 147.demo-e2e-approval: 6.demo-e2e-cli-exec(freshcli:build):36 suites, 209 passed.
nx lint sdk: 0 errors.tsc --noEmitonlibs/sdk/tsconfig.lib.json: none; on the SDK andplugin-codecallspec configs: none in the changed files.fix-unused-imports.mjs b831a45cdandprettier --checkon every changed file: clean.Bundle size
The lazy app kind, its placeholders and the loader ship in the browser build (lazy imports are how a browser app splits its code, so this belongs there). Measured against #1024's head (on today's
main):create()+ one toolcreate()+ WebMcpPlugin + one toolEach budget moves up by exactly the measured growth. The Vite initial chunk had only 132 bytes of headroom left on #1024 (earlier PRs stayed under it without raising it), so this is the first PR in the stack that raises it. The figures include the completion flow's
checkAppLoadedstage, added after review (+286 / +286 / +68 bytes).Summary by CodeRabbit