Skip to content

feat(sdk): lazy apps — listed from the build manifest, imported on their first call - #1025

Merged
alexmercerpo merged 12 commits into
feat/m15-durable-jobs-runnersfrom
feat/m19-lazy-apps
Nov 3, 2026
Merged

alexmercerpo merged 12 commits into
feat/m15-durable-jobs-runnersfrom
feat/m19-lazy-apps

Conversation

@frontegg-david

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

Copy link
Copy Markdown
Contributor

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.

@FrontMcp({
  info: { name: 'gateway', version: '1.0.0' },
  apps: [
    CoreApp,
    { id: 'billing', name: 'Billing', lazy: () => import('./billing.app') },
    { name: 'Reports', lazy: () => import('./reports.app'), enabled: fileSignal('./features.json', 'reports') },
  ],
})
export default class Server {}

A new app kind, AppKind.LAZY_IMPORT. { id?, name, description?, lazy, enabled? } is validated with a strict Zod
schema (lazyAppMetadataSchema); any other field fails at startup with LazyAppConfigurationError naming the field.
The import may resolve to the @App class, to a module whose default export is one, or to a module with exactly one
@App export. A lazy app always joins the server's scope (standalone: false), and it shares the duplicate-id check
with App.esm() / App.remote() apps. The instance (AppLazyInstance) is loaded with a dynamic import, as the remote
instance 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 real
ToolInstance / ResourceInstance / PromptInstance built from the manifest's fields (schemas as JSON Schema,
annotations, icons, _meta, tags, aliases, visibility, execution, output policy, examples, availableWhen) under the
app's owner. The scope adopts these registries like any local app's, so tools/list, resources/list,
resources/templates/list and prompts/list run their usual stages over them: list hooks, enabled, availableWhen,
client profiles, the app's and the tool's output policies (the lazy app's output is the manifest's apps[].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/get and completion/complete already resolve their entry in the flow class's resolveHookOwnerId (where
remote 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 new
hookable stage, checkAppLoaded (after checkEnabled in the three call flows), answers an entry that is on but still
a 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): metadata
only a plugin enforces (UnenforcedMetadataError), the hook order, and the guard its tools' rateLimit /
concurrency need (without it those limits would silently do nothing); its providers get onReady().

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 are
compared 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:

  • They match: the swap announces nothing (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).
  • They differ (a stale manifest): decided as reload, not fail closed: the app's own entries are listed from
    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 one list_changed`. A call to a tool the stale
    manifest 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 an EnabledSource, M08) is the
placeholders' switch: while it is off they are hidden and refused with FEATURE_OFF and the app's remedy, and no
import 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: false is 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 remedy Call 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 failed
attempt is undone first: the hooks it registered (those the app owns and those its entries' classes declare) are
unregistered (new HookRegistry.removeHooks()), its providers get onShutdown() and are disposed, and its adapters
are disposed, so a retry starts clean and never runs a hook twice. A failure the app's code must fix
(LazyAppConfigurationError, such as cannot be lazy or an import that resolves to no @App, and
UnenforcedMetadataError) 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(), no
onReady()).

Where the manifest comes from. New option @FrontMcp({ manifest }): a path (relative to the file that declares
the server) or the manifest object (a Worker or browser build imports it). Default: frontmcp.manifest.json next to
the entry script (process.argv[1]), which is where frontmcp build writes 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 with
serverManifestSchema; an unreadable one is a startup error.

  • No manifest in production (NODE_ENV=production): startup fails with LazyAppConfigurationError: 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's
    entry, or pass @FrontMcp({ manifest }).` A manifest that does not list the app counts as none.
  • No manifest in development: lazy apps are imported at startup, logged once per scope: 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 then
    has no entries to list).
  • At build time (connect(config, { mode: 'manifest' }), which frontmcp build and buildManifest() use, now
    marked on the config by createForManifest()): lazy apps are imported so the manifest has their entries. The lazy
    app'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 (@Duty providers start with the server), its own auth,
standalone, app ui defaults, widget tools, entries with authorities, entries with an enabled source of their
own (the lazy app's enabled is the switch for that), and entries with featureFlag or codecall metadata (the
feature-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 with
appliesTo: 'all-apps', or a hook on any flow but tools:call-tool, resources:read-resource,
prompts:get-prompt and completion:complete (a list hook, say). appliesTo: 'uncovered-apps' is allowed: another
app'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 app
fails with LazyAppConfigurationError: App "billing" cannot be lazy: <reasons>. ... Remove \lazy` to import the app
at 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 with DuplicateAppIdError. Until a lazy app loads, code that reads its entries outside their own calls (a third-party list hook, CodeCall's includeTools` filter) sees only the fields the
manifest records.

Smaller changes: the empty plugin / adapter / skill registries of the remote app instance moved to
app/instances/empty-app-registries.ts so the lazy instance shares them; EnforcedMetadataScope takes the registry
methods it reads (Pick<...>), so the check runs on one app's registries; the DuplicateAppIdError message names
lazy apps too.

Deviations from PLAN.md / the brief:

  • The import runs in resolveHookOwnerId, before the flow, rather than in a stage: a flow collects the hooks that run
    for 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 checkAppLoaded stage reports a failed import through the flow.
  • Stale manifests reload with a warning and list_changed (above) instead of failing closed.
  • A lazy app takes an enabled of its own: the proposal's "a feature that is off is never loaded" is about a feature
    switch (2.3), which the placeholders cannot read from the app's code.
  • Added @FrontMcp({ manifest }), needed where there is no file system (Workers, browsers) and for tests.
  • Besides the multiapp e2e, a cli-exec e2e builds a real project with frontmcp build --target node and runs the
    bundle in production, which covers finding the manifest next to the entry.

User-facing change

  • What changed:
    • apps takes a lazy app: { id?, name, description?, lazy: () => Promise<unknown>, enabled?: EnabledOption }. New
      exports: LazyAppMetadata, lazyAppMetadataSchema, LazyAppLoadError, LazyAppConfigurationError,
      LazyAppRecordMetadata, AppLazyImport, AppKind.LAZY_IMPORT, LateAppRegistries.
    • New @FrontMcp option manifest?: string | ServerManifest (message for a wrong type: `manifest` must be the path of a frontmcp.manifest.json, or the manifest itself).
    • New error codes and messages: 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 (...)).
    • New log lines: No frontmcp.manifest.json (looked for <path>): lazy apps are loaded at startup. ... (info, once
      per 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).
    • New hookable stage checkAppLoaded in tools:call-tool, resources:read-resource and prompts:get-prompt.
    • apps items that are neither a class nor a remote app now fail with apps items must be annotated with @App() | @FrontMcpApp(), or be a valid remote app or lazy app ({ name, lazy }) configuration.; the
      DuplicateAppIdError message adds and each lazy app its own \id` or `name`.`
    • ScopeEntry has a new abstract method checkLateApp(app); HookRegistry has removeHooks(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 id
      another app has fails startup with DuplicateAppIdError.
  • Before → after:
    • Every app's code was imported and initialized at startup → a lazy app is imported on its first call, listed from
      the manifest until then; a lazy app that is off is never imported.
    • frontmcp.manifest.json was a build artifact only → a server with lazy apps reads it at run time; a production
      deployment of a lazy app must ship it next to the entry (or pass @FrontMcp({ manifest })), or it does not start.
  • frontmcp.dev pages affected (if known): grouping capabilities into apps (multi-app composition: lazy apps),
    the @FrontMcp configuration reference (manifest), the build manifest page (read at run time by lazy apps), the
    live surface / enabled page (a lazy app's switch), the production checklist, the error reference
    (LAZY_APP_LOAD_FAILED), the flow stages reference (checkAppLoaded).
  • Skills catalog entries updated:
    • 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.
    • New example frontmcp-setup/examples/multi-app-composition/lazy-apps-from-the-manifest.md, registered in
      skills-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 a
      troubleshooting 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's enabled, and featureFlagSource() in place of an
      entry's featureFlag.
    • Second review: multi-app-composition.md (the failure bullet: what is undone, what is retried; the new "cannot
      have" items; what code outside the calls sees before the first call; two checklist lines; the
      LAZY_APP_LOAD_FAILED row); frontmcp-development/references/create-plugin.md (a lazy app cannot install a
      plugin whose hooks reach every app); frontmcp-development/references/official-plugins.md (no featureFlag on a
      lazy app's entries, featureFlagSource() on the lazy app instead; CodeCall judges a lazy app's tools by the
      manifest 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 normalizeApp had no lazy kind). Two key mechanisms were also checked by
mutation: without the late guard setup the rate-limit test fails, and without the unchanged-lists announcement guard
the no-list_changed and stale-manifest tests fail.

  • Unit (libs/sdk/src/app/__tests__, 45 tests):
    • app.lazy.listing.spec.ts (5): with a list hook, elicitation and an app output policy of schemaMode: 'both', the
      lazy 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 no list_changed and no warning; a
      stale one lists the manifest until the first call, then the code, with tools/list_changed only 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 (provider onReady()), and runs with the app's
      plugin 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_OFF without importing; enabled: false never imports and refuses tools and resources; an EnabledSource
      off (not imported, its remedy), on (imported), off again (hidden, FEATURE_OFF); a failed import gives
      LAZY_APP_LOAD_FAILED with a remedy and no cause, the other app keeps serving, the next call imports again; a
      failed 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 rateLimit is enforced (the guard is created); metadata
      no installed plugin enforces refuses the load.
    • app.lazy.boot.spec.ts (24): normalization and four invalid configs; development without a manifest imports at
      startup with one log line; production without one does not start; enabled: false is not imported; the manifest
      is 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 @App class, one with another id, a default export.
    • Coverage of the new code: 95.9% lines (app.lazy.instance.ts 94.5%, app/lazy/* 97.1%, errors, flags and
      unchanged-lists.ts 100%).
  • Second review (each red on the first review's head, green after):
    • app.lazy.boot.spec.ts (+7): refused at startup in development with cannot be lazy: a scope: 'server' plugin,
      one nested in another plugin, a plugin hook on tools:list-tools, a hook with appliesTo: 'all-apps', a @Duty
      provider, a tool declaring featureFlag and codecall; (+1) a lazy app whose id a local app has fails with
      DuplicateAppIdError (red: it booted, the two apps sharing app:core).
    • app.lazy.calls.spec.ts (+3): a failure after the app registered hooks (a plugin's provider that refuses once) is
      retried with the remedy Call it again, the retried app's plugin and class hooks run once, and the failed
      attempt's provider got onShutdown() (red: restart the server, no onShutdown()); an app refused for a list hook
      leaves none of its hooks registered, so tools/list and later calls run none of them (red: the app loaded); an
      import 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 and
      the 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 app
      whose tool declares codecall fails; with a manifest from before the tool opted out, codecall:execute's
      callTool('purge') is refused and the tool never runs (red: the build succeeded, and the script returned
      status: ok after running the opted-out tool).
  • E2E:
    • apps/e2e/demo-e2e-multiapp/e2e/lazy-apps.e2e.spec.ts (4): src/lazy/write-manifest.ts writes the manifest as
      frontmcp build does; in production the server lists the billing app (tool schema, resource) while its module's
      import 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 node on a scratch
      project writes the lazy app's tools into dist/node/frontmcp.manifest.json; the bundle run with
      NODE_ENV=production finds it next to itself, lists charge with the module not imported, and imports it on the
      first call.
    • demo-e2e-dts-consumer → lazy-apps-must-compile.ts: LazyAppMetadata (with fileSignal()), lazy apps in
      apps, @FrontMcp({ manifest }) as a path and an object, the two errors, and a @ts-expect-error for a lazy
      that is not a function.
  • Bundle size: the lazy app instance is reached through the app registry's dynamic import, which the browser
    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.
  • Ran:
    • libs/sdk whole suite (--maxWorkers=50%): 626 suites, 8666 tests passed; after the last changes, the app,
      manifest, surface and notification folders again: 22 suites, 270 passed; the lazy specs: 45 passed.
    • nx test skills: 249 passed. plugin-feature-flags, plugin-codecall, plugin-skilled-openapi, adapters and
      testing: 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: 12
      passed. 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 fresh cli:build): cli-build-manifest, cli-skills-export-from-entry and the new
      cli-build-lazy-apps: 3 suites, 8 passed.
    • nx lint sdk skills: 0 errors. tsc --noEmit on libs/sdk/tsconfig.lib.json and libs/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-manifest and prettier --check on every changed file:
      clean.
    • CodeRabbit CLI (--committed, against the parent): 0 findings on the feature commit; on both commits, 1 (the
      built-bundle e2e left its server running when the client failed to connect), fixed.
    • After the second review: libs/sdk whole suite (--maxWorkers=50%): 626 suites, 8681 tests passed; the lazy and
      hook-registry specs: 67 passed (coverage of app.lazy.instance.ts, app/lazy/* and hook.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 (fresh cli:build):
      36 suites, 209 passed. nx lint sdk: 0 errors. tsc --noEmit on libs/sdk/tsconfig.lib.json: none; on the SDK and
      plugin-codecall spec configs: none in the changed files. fix-unused-imports.mjs b831a45cd and prettier --check on 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):

Bundle #1024 This PR Growth Budget
create() + one tool 1,333,845 1,338,215 +4,370 1,334,799 → 1,339,169
create() + WebMcpPlugin + one tool 1,341,024 1,345,392 +4,368 1,341,666 → 1,346,034
Vite initial chunk 959,868 960,796 +928 960,000 → 960,928

Each 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 checkAppLoaded stage, added after review (+286 / +286 / +68 bytes).

Summary by CodeRabbit

  • New Features
    • Added support for loading eligible apps on their first call, with concurrent requests sharing the same load.
    • Added manifest-based listings so tools, resources, and prompts can appear before an app is loaded. Production servers require a valid manifest for lazy apps; development can load them at startup without one.
    • Added app-level enablement for lazy apps, allowing disabled apps to remain unloaded while their entries are unavailable.
  • Bug Fixes
    • Reduced unnecessary client list-change notifications when visible listings remain unchanged.
  • Documentation
    • Added guidance for configuring, building, deploying, and troubleshooting lazy apps.

…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.
@frontegg-david
frontegg-david marked this pull request as ready for review November 2, 2026 21:38
@frontegg-david
frontegg-david added this pull request to stack #997 November 2, 2026 21:38
@frontegg-david

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

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 52 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 ed50436 and bf2261f.

Files selected for processing: 1
  • libs/skills/catalog/frontmcp-setup/references/multi-app-composition.md
Walkthrough

The SDK now supports manifest-backed lazy apps. It lists eligible entries before import and loads an app when a related request occurs. The change adds configuration validation, lazy-load errors, list-change handling, tests, and usage and deployment documentation.

Changes: Lazy app imports

Layer / File(s) Summary
App and manifest contracts
libs/sdk/src/common/metadata/*, libs/sdk/src/common/records/app.record.ts, libs/sdk/src/app/app.utils.ts, libs/sdk/src/manifest/build-server-manifest.ts, libs/sdk/src/front-mcp/*, libs/sdk/src/errors/*
Adds lazy-app metadata, a manifest configuration option, and lazy-import records. Manifest mapping helpers expose app, tool, resource, and prompt mappings.
Startup and loading
libs/sdk/src/app/instances/*, libs/sdk/src/app/lazy/*, libs/sdk/src/app/app.registry.ts, libs/sdk/src/scope/scope.instance.ts, libs/sdk/src/hooks/hook.registry.ts
Lazy instances read manifests, register placeholders, validate imported apps, and replace placeholders after loading. Concurrent requests share an import. Failed initialization performs cleanup. Late-loaded entries receive scope checks.
Request flows and list updates
libs/sdk/src/app/__tests__/app.lazy.*.spec.ts, libs/sdk/src/app/__tests__/lazy-app.fixtures.ts, libs/sdk/src/{tool,resource,prompt,completion}/flows/*, libs/sdk/src/surface/*, libs/sdk/src/notification/notification.service.ts, libs/sdk/src/transport/mcp-20260728/subscriptions.ts
Tool, resource, prompt, and completion flows load associated apps. List notifications and tool-list version updates are suppressed when client-visible lists remain unchanged. SDK tests cover loading, errors, retries, and manifest mismatches.
Integration checks and guidance
apps/e2e/demo-e2e-{cli-exec,multiapp,dts-consumer}/**, apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts, plugins/plugin-codecall/src/__tests__/codecall-lazy-app.flow.spec.ts, libs/skills/catalog/**
Adds end-to-end and declaration-consumer tests, updates browser bundle-size limits, tests CodeCall restrictions, and documents lazy-app configuration, deployment, and limitations.

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

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ToolRegistry
  participant AppLazyInstance
  participant Importer
  Client->>ToolRegistry: List manifest-backed tool placeholders
  Client->>ToolRegistry: Call a placeholder tool
  ToolRegistry->>AppLazyInstance: Load the associated app
  AppLazyInstance->>Importer: Import app module
  Importer-->>AppLazyInstance: Return app class
  AppLazyInstance-->>ToolRegistry: Replace placeholders with loaded entries
  ToolRegistry-->>Client: Return tool result
Loading

Suggested reviewers: alexmercerpo

Merge Risk: Low · previous review ed504

The wording could lead developers to expect a disabled app not to import during development. Clarification is advisable, but the risk is bounded.

Pre-merge checks: Passed 5
Check name Status Explanation
Title check Passed The title clearly and concisely identifies the main change: lazy apps are listed from the build manifest and imported on their first call.
Description check Passed The description follows the required template. It explains the purpose, user-facing API and behavior changes, affected documentation, catalog updates, and extensive test validation.
Docstring Coverage Passed Docstring coverage is 71.43% which is sufficient. The required threshold is 65.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentati…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 71.43% which is sufficient. The required threshold is 65.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 28 functions across 47 files. (5 skipped: 5 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 7351232 and ed50436.

Files selected for processing: 9
  • apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts
  • libs/sdk/src/app/__tests__/app.lazy.calls.spec.ts
  • libs/sdk/src/completion/flows/complete.flow.ts
  • libs/sdk/src/scope/scope.instance.ts
  • libs/skills/catalog/create-tool/references/availability.md
  • libs/skills/catalog/frontmcp-development/references/official-plugins.md
  • libs/skills/catalog/frontmcp-setup/examples/multi-app-composition/lazy-apps-from-the-manifest.md
  • libs/skills/catalog/frontmcp-setup/references/multi-app-composition.md
  • libs/skills/catalog/skills-manifest.json
Files skipped from review as they are similar to previous changes: 4
  • libs/skills/catalog/frontmcp-development/references/official-plugins.md
  • libs/skills/catalog/create-tool/references/availability.md
  • libs/skills/catalog/frontmcp-setup/examples/multi-app-composition/lazy-apps-from-the-manifest.md
  • libs/skills/catalog/skills-manifest.json

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

@coderabbitai

coderabbitai Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor
Action not completed

Review rate limited.

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

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Performance Test Results

Status: ✅ All tests passed

Summary

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

Total: 101 tests across 21 projects

📊 View full report in workflow run


Generated at: 2026-11-03T04:18:08.428Z
Commit: a3b69eb5

@frontegg-david

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor
Action not completed

Review rate limited.

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

@frontegg-david

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor
Action performed

Review finished.

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

@coderabbitai coderabbitai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  • Fix comments: Fix CodeRabbit comments on this PR

Comment thread libs/sdk/src/scope/scope.instance.ts Resolved
@frontegg-david
frontegg-david marked this pull request as draft November 3, 2026 02:00
…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.
@frontegg-david
frontegg-david marked this pull request as ready for review November 3, 2026 02:06
@frontegg-david

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor
Action not completed

Review rate limited.

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

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

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor
Action performed

Review finished.

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

@coderabbitai coderabbitai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  • Fix comments: Fix CodeRabbit comments on this PR

frontegg-david added a commit that referenced this pull request Nov 3, 2026
…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.
frontegg-david added a commit that referenced this pull request Nov 3, 2026
…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.
@alexmercerpo
alexmercerpo merged commit 3097e47 into main Nov 3, 2026
47 checks passed
@alexmercerpo
alexmercerpo deleted the feat/m19-lazy-apps branch November 3, 2026 07:27
alexmercerpo pushed a commit that referenced this pull request Nov 3, 2026
…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
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