Repository navigation
feat(sdk): hook ordering with HookPhase bands and before/after, an observer chain, a dev hook trace and appliesTo: 'all-apps' - #1005
Conversation
…ook observers, add a dev hook trace and per-hook appliesTo: 'all-apps'
…ting it match nothing
…er the hooks one call runs - A hook's before took effect by holding back the hooks it named, so they ran after every unrelated hook of a later band: an observe hook with before: ['tenant-resolver'] ran a guard ahead of the tenant resolver. The hook that declares before now takes the place of the earliest hook it names; the hooks it names keep their place. - The startup check related every hook of a scope at once, entry-class hooks included, and an agent's own hooks without those it inherits. It now checks the sets one call can sort together: a scope's flow hooks (an unknown entry runs every app's), with an agent's inherited hooks, and each entry class's hooks on their own. Two entry classes naming each other no longer stop the server; an agent hook ordered against an inherited one is no longer reported as unmatched, and a cycle with an inherited hook stops startup. - Document that an 'all-apps' hook reads and can change every app's calls, and that the trace is added after the answer budget and the cost meter.
…/m07-hook-ordering # Conflicts: # apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts # apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json
|
@coderabbitai review |
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Hook and devtools contracts libs/sdk/src/common/metadata/hook.metadata.ts, libs/sdk/src/common/decorators/hook.decorator.ts, libs/sdk/src/common/types/options/devtools/*, libs/sdk/src/common/metadata/front-mcp.metadata.ts, libs/sdk/src/common/interfaces/flow.interface.ts, libs/sdk/src/common/tokens/front-mcp.tokens.ts, libs/sdk/src/errors/registry.errors.ts, libs/sdk/src/errors/index.ts, libs/sdk/src/plugin/plugin.registry.ts, libs/sdk/src/index.ts |
Hook metadata adds priority bands, before and after targets, all-apps, and option validation. Devtools metadata adds the hookTrace setting and its schema. |
Ordering constraints and app coverage libs/sdk/src/hooks/hook-order.ts, libs/sdk/src/hooks/hook.registry.ts, libs/sdk/src/flows/flow.stages.ts, libs/sdk/src/scope/scope.instance.ts, libs/sdk/src/agent/agent.scope.ts, libs/sdk/src/front-mcp/static-startup.check.ts, libs/sdk/src/plugin/enforced-metadata.check.ts, libs/sdk/src/hooks/__tests__/hook-order.spec.ts, libs/sdk/src/hooks/__tests__/flow-hooks.ordering.spec.ts, libs/sdk/src/front-mcp/__tests__/static-startup.check.spec.ts, libs/sdk/src/flows/__tests__/flow-stage-hook-lists.spec.ts |
Hook stages apply ordering constraints after priority and registration order. Scope and agent initialization check hook order and report unmatched targets or cycles. App- and agent-owned hooks with all-apps coverage can apply to other apps’ entries. |
Observer execution and answer tracing libs/sdk/src/hooks/hook-observers.ts, libs/sdk/src/flows/flow.instance.ts, libs/sdk/src/tool/flows/call-tool.flow.ts, libs/sdk/src/resource/flows/read-resource.flow.ts, libs/sdk/src/prompt/flows/get-prompt.flow.ts, libs/observability/src/plugin/observability.hooks.ts, libs/sdk/src/hooks/__tests__/hook-observers.spec.ts, libs/observability/src/__tests__/server-telemetry.spec.ts |
The SDK supports request-scoped observer chains and records hook owner, method, stage, type, duration, and outcome. Supported flows can attach trace metadata to answers when tracing is enabled. The observability plugin registers through the shared observer API. |
Consumer and E2E validation apps/e2e/demo-e2e-dts-consumer/fixture/hook-ordering-*, apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json, apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts, apps/e2e/demo-e2e-hooks/src/apps/hook-ordering/*, apps/e2e/demo-e2e-hooks/src/main.hook-ordering.ts, apps/e2e/demo-e2e-hooks/e2e/hook-ordering.e2e.spec.ts, apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts |
Compile-time fixtures check accepted and rejected hook options. E2E examples test hook order, cross-app hooks, and trace metadata in development and production modes. Bundle-size budgets increase by 6,639 bytes and 6,515 bytes for the two entries. |
Hook behavior documentation libs/skills/catalog/frontmcp-development/references/create-plugin-hooks.md, libs/skills/catalog/frontmcp-development/references/create-plugin.md, libs/skills/catalog/frontmcp-extensibility/SKILL.md, libs/skills/catalog/frontmcp-setup/references/multi-app-composition.md |
Developer references describe ordering constraints, all-apps scope, trace metadata, observer behavior, and troubleshooting cases. |
Estimated code review effort: 4 (Complex) | ~60 minutes
Sequence Diagram(s)
sequenceDiagram
participant FlowInstance
participant RequestContext
participant ObserverUtilities
participant Hook
FlowInstance->>RequestContext: Read registered observers
FlowInstance->>ObserverUtilities: Run observers with hook metadata
ObserverUtilities->>Hook: Invoke hook through once-only continuation
Hook-->>ObserverUtilities: Return outcome
ObserverUtilities-->>FlowInstance: Complete observed hook
FlowInstance->>FlowInstance: Add trace metadata to answer when enabled
Possibly related issues
- agentfront/frontmcp#950 — The PR changes hook targeting and ordering, but the issue’s SDK bundle-splitting requirement remains outside this PR.
Suggested reviewers: alexmercerpo
Merge Risk: 
Minimal · up to 529cb
-
The reviewed changes do not show a concrete production or workflow-blocking failure.
Pre-merge checks: 
5
-
Full details: Docstring Coverage
-
Explanation
No eligible functions found in the changed files. Skipping docstring coverage check. 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 0 functions across 2 files.
-
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
Action not completed
|
…/m07-hook-ordering
|
@coderabbitai review |
Action performed
|
…/m07-hook-ordering
Performance Test ResultsStatus: ✅ All tests passed Summary
Total: 101 tests across 21 projects 📊 View full report in workflow run Generated at: 2026-11-02T16:16:29.699Z |
…/m07-hook-ordering
…gets to hook ordering demo-e2e-hooks failed its typecheck: ranHooksTool() returned an anonymous class, which declaration emit cannot name (TS4094). It now returns ToolType. Hook ordering and the observer chain run in the flow runner for every flow, so create() + one tool grew by 6,639 bytes minified (2,747 gzipped). The two browser bundle budgets move up by that much, keeping the headroom they had; the Vite initial chunk stays within its budget.
|
CI on ca49c21 had two failures, both from this PR, fixed in f5ff8e7:
|
…k ordering measured create() + one tool grew by 6,639 bytes minified and create() + WebMcpPlugin + one tool by 6,515, so the budgets are now 1,321,639 and 1,328,515.
|
Correction to the comment above: the budgets now move up by exactly each bundle's measured growth (529cbb1), to 1,321,639 (+6,639) and 1,328,515 (+6,515), as the PR body's Bundle size section states. |
Stacked on #1004
What and why
Proposal items 1.3 (its additive part), 3.4 and 3.3: hooks that order themselves by meaning instead of magic numbers,
a dev trace of the hooks a call ran, and one app's hook that runs for every app.
The reported problems:
side of the other's priority (
10001against10000), and nothing tells a reader why. fix(sdk): document hook priority order, sort the hook registry in run order and print the resolved order in dev mode #985 documented thedirection (lowest first) and added a dev-mode print of the resolved order; there was still no way to say "before
the setup gate", and no shared vocabulary for where a guard or an answer shaper belongs.
or how long each hook took, so an order bug (a refused call that was still timed, a refusal from the wrong gate)
was found only by reading code.
@frontmcp/observabilityclaimed it, and any other observer installedon the same request was dropped without a word, so nothing else could watch hooks next to it.
that app's tools, so it had to move to the server's
plugins.appliesTo: 'uncovered-apps'fills gaps for gates anentry asks for, but steps aside for any app with its own instance.
What this PR changes:
HookPhase: named priorities in run order,resolve(-100),guard(100),observe(300) andshape(500),spaced so a hook can sit between two (
HookPhase.guard + 10). The default priority stays 0, betweenresolveandguard, and no built-in priority changes: approval sits atguard, feature flags at 50, CodeCall's hidden-toolrefusal at 10, the cache plugin at 1000 (after every band), and observability opens its spans at -1000 and closes
them at 1000, outside every band.
before/afteron every hook decorator: lists of hook targets, each a plugin'sname('setup-gate') orthe name of the class that declares the hook (a plugin, provider or entry class), optionally followed by
.methodfor one hook. Within one flow, stage and type, the hooks are sorted topologically on these constraints, falling
back to priority and then registration order. The hook that declares a constraint is the one that moves: with
beforeit takes the place of the earliest hook it names, withafterit waits for the hooks it names, and thehooks it names keep their place among the others (so an
observehook withbefore: ['tenant-resolver']neverpulls a
guardhook ahead of the resolver). ForAroundhooks,beforemeans "wraps". A constraint never relatesa hook to itself or to another instance of the same hook, and never names the flow's own steps.
HookOrderCycleError(HOOK_ORDER_CYCLE), whose message andhooksname the hooksin the cycle, in order. The check runs when each scope (and each agent scope) starts, over the hooks one call can
sort together: all of the scope's plugin, provider and app hooks (a call naming an unknown tool runs every
app's, so two apps' hooks can form a cycle), with those an agent inherits through
execution.inheritPlugins,and each entry class's hooks with them, one class at a time (two entry classes never share a call). A run
still refuses a cycle it meets, such as one through an entry registered after startup.
beforeandaftermust be arrays of non-empty strings with no surrounding spaces, andappliesToone of itsvalues: anything else throws
InvalidDecoratorMetadataErrorwhere the hook is declared (Zod-checked). Before, anunknown
appliesTowas silently treated as'own-app', andbefore/afterwere not hook options.will FeatureGatePlugin.refuseDisabled (100, before setup-gate).Symbol.for('frontmcp:hook-observer')key.addHookObserver(context, observer)adds one; every installedobserver wraps the hook, the first added outermost. Whatever an observer does, the hook runs exactly once and
settles as it would unobserved: an observer that throws (before or after running the hook), never runs it or runs it
twice changes nothing, and its throw is logged once per observer as a warning. A plain function left under the key
(an older
@frontmcp/observability) still counts as one observer.@frontmcp/observabilitynow registers throughthe chain, still once per request.
NODE_ENV=development), or with the new@FrontMcp({ devtools: { hookTrace: true } }), every tool, resource and prompt answer carries_meta['frontmcp/trace'](HOOK_TRACE_META_KEY): thehooks that ran for the call, in the order they started, each as
{ owner, method, stage, type, ms, outcome }.It is recorded by an observer appended to the chain for the run, so it times the hook itself. It is added once the
answer is final, after the answer budget and the cost meter, so neither counts it. It lists the hooks
of plugins, providers, apps and entry classes (not the flow's own steps), and never a hook's arguments, the
call's input or its answer.
devtools.hookTrace: falseturns it off in dev mode; a non-boolean stops the serverat startup. Outside dev mode, without
hookTrace: true, answers never carry it.appliesTo: 'all-apps': one hook of an app's plugin runs for every app's entries in its scope, and for thetools the server declares, as a server-level plugin's hook would, even for an app with its own instance of the same
hook (then both run). It differs from
'uncovered-apps', which steps aside for an app that has its own instance,and from
@Plugin({ scope: 'server' }), which widens every hook of the plugin and cannot be installed on astandalone app. Both reach only the apps that share a scope with the installing app. This is a deliberate
widening of trust: an
'all-apps'hook reads, and can change, the arguments and answers of every app's calls inthat scope, remote apps' included, as a server-level plugin's hooks do, and the catalog says to grant it only to a
plugin one would install on the server. Remote and ESM-loaded apps install no plugins, so they cannot declare one. The startup checks of
plugin-enforced metadata (
approval,featureFlag, ...) count an'all-apps'hook as covering every app, in thefull check and in the static check that edge entry points run.
Choices that differ from the plan, and why:
methodto{ owner, stage, type, ms, outcome }, so two hooks of one plugin on a stage canbe told apart.
owneris the plugin'sname(else the declaring class's name), the same namebefore/afteruse.
documented with where each built-in sits.
devtools.hookTrace, and an explicitfalsewins over dev mode.User-facing change
HookPhasebands; newbefore/afterhook options with a startup cycle error and anunknown-target warning; Zod validation of
appliesTo,beforeandafter; a hook-observer chain(
addHookObserver); a dev-only_meta['frontmcp/trace']on tool, resource and prompt answers and thedevtools.hookTraceoption; per-hookappliesTo: 'all-apps'.before/afterfirst, then priority, then registration order. Run order isunchanged for hooks that declare neither.
@ToolHook.Will('execute', { appliesTo: 'everywhere' }): treated as'own-app'→ throwsInvalidDecoratorMetadataError: @Will('execute') invalid metadata for "appliesTo": must be 'own-app', 'uncovered-apps' or 'all-apps'. AnyappliesToother than the three values, a typo or a value of a laterrelease, now stops the module that declares the hook from loading.
before/afterthat are not arrays of non-empty strings, or a target with surrounding spaces(
{ before: [' setup-gate '] }): ignored (not hook options) → throwsInvalidDecoratorMetadataErrornaming"before"or"after":a hook target is a plugin name or a class name, optionally followed by .method, with no surrounding spaces.HookOrderCycleError(
HOOK_ORDER_CYCLE):The before/after options of the will hooks on 'execute' of 'tools:call-tool' form a cycle: setup-gate.requireSetup → feature-gate.refuseDisabled → setup-gate.requireSetup. Remove one of these constraints.Hook feature-gate.refuseDisabled (will 'execute' of 'tools:call-tool') names 'setup-gate' in before, but no hook on that stage matches it, so it changes nothing. A target is a plugin name or the class that declares the hook, optionally followed by .method.NODE_ENV=development) oftools/call,resources/readandprompts/get: no trace → a_meta['frontmcp/trace']array. Production and test answers: unchanged.hook → logged once and ignored.
(priority)→(priority, before x, after y)for a step with constraints."Hook order" (
HookPhase,before/after, the print), hook options (before,after,appliesTo: 'all-apps'), a new "Hook trace" section and "Hook observers"; the@FrontMcpoptions reference (devtools); theerrors reference (
HookOrderCycleError); multi-app composition (appliesTo: 'all-apps'and shared tools).frontmcp-development/references/create-plugin-hooks.md: the "Hook Order" section gains "Named Bands:HookPhase" (with the built-in plugins' places), "Ordering Against Another Hook:beforeandafter" (targets,scope, precedence, errors, validation), the constraints in "Seeing the Resolved Order", "Tracing a Call in Dev
Mode" and "Watching Every Hook: Hook Observers"; "HookOptions" documents
before,afterand the threeappliesTovalues against@Plugin({ scope: 'server' }); two troubleshooting rows (the cycle error, theunmatched-target warning).
frontmcp-development/references/create-plugin.md: per-hookappliesTonext to "Hook scope", ordering againstanother plugin, and a common-patterns row.
frontmcp-extensibility/SKILL.md: routing rows for ordering hooks and for one app's hook running for every app.frontmcp-setup/references/multi-app-composition.md:'all-apps'hooks reach shared tools.Tests
HookPhase, nohook-order/hook-observersmodules); the staticstartup case and the observability coexistence case were checked red by restoring the old code for the run.
libs/sdk):hooks/__tests__/hook-order.spec.ts:HookPhasevalues and slotting between bands;beforeandafterbyplugin name, class name and
Name.method, overriding priority; the hook that declaresbeforeoraftermoves,while the hooks it names keep their place past the bands between them (a chain of
beforeincluded); unrelatedhooks keep priority, then registration order; a hook never relates to itself or another instance of it; the flow's own step is never a target; an
unmatched target changes nothing; a cycle throws
HookOrderCycleErrornaming the hooks; the startup checkrelates only hooks of one flow, stage and type, warns once per hook about every unmatched target and says
nothing when all match; the decorator refuses an invalid
appliesTo,beforeorafterand keeps valid ones.hooks/__tests__/hook-observers.spec.ts: two observers both run, the first outermost; one that throws before orafter running the hook doesn't break it and is reported; an observer cannot swallow the hook's failure, nor
replace it with its own throw (only that throw is reported); a hook runs once, in the request's async context,
when an observer calls
runafter an await or after it settled, never calls it, or calls it twice; the chain keeps install order and adds anobserver once, and reads a single legacy function; the trace observer records owner, method, stage, type, ms and
outcome in start order;
withHookTraceadds to_metawithout changing the answer it was given.hooks/__tests__/flow-hooks.ordering.spec.ts(real servers): constraints order three plugins against theirpriorities and plugin order; a cycle refuses to start the server, two apps' own-app hooks in a cycle too (a
call naming an unknown tool runs both), while two entry classes whose hooks name each other start; an agent
with
execution.inheritPluginsmatches its hooks' targets against the hooks it inherits, and a cycle with onerefuses to start; an unmatched target warns once at startup;
the trace on tool, resource and prompt answers in dev mode, absent in production and test, on with
devtools.hookTrace: trueand off withfalse, and a non-boolean refused;'all-apps'runs one app's hook forevery app's tools while its own-app hooks stay on its app.
flows/__tests__/flow-stage-hook-lists.spec.ts: the print shows a step'sbeforeandafter.front-mcp/__tests__/static-startup.check.spec.ts: an approval tool on one app, or a server-level one, is coveredby an
'all-apps'gate on another app, in the full and the static checks.libs/observability),server-telemetry.spec.ts: with another observer added first on the request, therequest log still records the hooks that ran and the other observer sees them too.
apps/e2e/demo-e2e-hooks/e2e/hook-ordering.e2e.spec.ts(serversrc/main.hook-ordering.ts):beforeruns thetenant resolver ahead of the quota gate despite its higher priority; an
'all-apps'hook of the first app runsfor the second app's tool; a production server's answer has no trace; a dev server's answer lists the three hooks
in run order.
apps/e2e/demo-e2e-dts-consumer:hook-ordering-must-compile.ts(HookPhase,before/after,appliesTo: 'all-apps',addHookObserver,HookObserver,HookTraceEntry,HOOK_TRACE_META_KEY,devtools.hookTrace) andhook-ordering-must-fail.ts(an invalidappliesTo, a stringbefore, a class inafter, a write toHookPhase), each case checked to fail once its@ts-expect-erroris removed.jest libs/sdk(589 suites, 7657 tests),libs/observability(499),plugin-approval(427),plugin-feature-flags(243),plugin-codecall(1211),plugin-cache(202),plugin-remember(515),nx test skills(249): all green.nx test demo-e2e-hooks(27, the fix(sdk): document hook priority order, sort the hook registry in run order and print the resolved order in dev mode #985 hook-order e2e included),nx test demo-e2e-dts-consumer(19),nx test demo-e2e-approval(6),demo-e2e-standalone(53, the observability span and request-log specsincluded, with
@frontmcp/observabilitybuilt): green.nx run-many -t lint -p sdk observability skills: no errors, no warnings on changed lines;tsc --noEmit -p libs/sdk/tsconfig.lib.jsonandlibs/sdk/tsconfig.spec.json: clean;node scripts/fix-unused-imports.mjs snap/m05(no change); prettier on every changed file.Summary by CodeRabbit
beforeandafterconstraints and priority bands.all-appsoption.Bundle size
Hook ordering and the observer chain run in the flow runner for every flow, so the browser bundles in
demo-e2e-bundle-sizegrow, as measured on CI against #1004:create()+ one tool: 1,310,872 → 1,317,511 bytes minified (+6,639; +2,747 gzipped). Budget 1,315,000 → 1,321,639.create()+ WebMcpPlugin + one tool: 1,318,185 → 1,324,700 bytes minified (+6,515; +2,728 gzipped). Budget 1,322,000 → 1,328,515.Each budget moves up by exactly its measured growth (529cbb1). The Vite initial chunk (939,241 of 960,000) stays within its budget.