Skip to content

mcpx: config-declared hooks over the event bus, running any command, JS/TS or Python module #1872

Closed

Description

@dezren39

Relocated from dezren39/darwin-nix#81 on 2026-10-28 (placement audit): mcpx's single source lives in developing-today/code (pkgs/mcpx); re-home tracked at #309. Original body below; issue references inside it are historical (dezren39/nix numbering).

Parent: #173

Hook vocabulary (#181 collision 2): this issue carries the one hook vocabulary that script.* (shipped), #82's runtime registration and #112's exec lifecycle list adopt. The decision is being recorded by SURFACES under pkgs/mcpx/docs/decisions/ (in flight; linked here when it merges). Until then the phase table and hook-point set in Approach C are input to that record, not the decision.

Summary

mcpx already has hooks in two disconnected places: five script.* settings that splice TypeScript into a generated script wrapper, and an internal pool.Hooks struct the daemon wires to the event bus. Neither is what the notes ask for — a hook that a user declares in configuration, that fires on something the daemon noticed, and that runs anything: a command, a JS/TS module, a Python module, or a plugin.

The daemon already publishes everything worth hooking (internal/events/events.go). What is missing is a declarative subscriber table and a small set of executors. Build the hook table on top of the existing bus rather than adding call sites.

Original notes

  • plugins
    • config can set hooks for non-go/typescript(/python)
    • config can set hooks to call any command,js/ts/py module
    • go/ts(/py) can directly interact with app set own hooks, get/set vars, trigger any external command internally which can be triggered outside the app or in a script, call public and private functions of app, fork configure socket shutdown and independently interact (maybe this mode also supports still get/set vars transport types rpc anything they could do inside.

(The third bullet is the programmatic half — a plugin registering its own hooks at runtime — and is answered in the privileged-surface issue. This issue is the declarative half.)

Current state (what mcpx does today, with file refs)

Script hooks exist and are good, but they are script-shaped and TypeScript-only. internal/settings/registry.go:201-243 declares six settings — script.launcher, script.before, script.prefix, script.onSuccess, script.onError, script.suffix — all Repeatable, most of kind KindSource. KindSource means the value may be inline text, a path to a file, or a directory of files (internal/source/source.go), resolved with @text:/@file: prefixes or probed. The launcher then splices them at @-placeholders (internal/launcher/template.go, #7). onError is documented as "A hook, not a handler. The error is rethrown afterwards" (internal/settings/registry.go:234). Since #75 these apply to inline snippets as well as files.

So the configuration ergonomics of hooks are already solved: repeatable, inline-or-file, composable, with a search path. What they lack is anything but TypeScript, and anything but a script run.

Pool hooks exist and are internal. internal/daemon/hooks.go:24 InstallHooks attaches a pool.Hooks to every pool: OnMessage, OnProgress, OnListChanged, OnResourceUpdated, OnElicitationComplete, Elicit. Every one of these does the same thing — translate into an event and r.publish it. This is a Go-level extension point with no user-facing surface.

The bus is the hook source. 17 kinds today: 16 constants in internal/events/events.go:30-51 (tools.changed, resources.changed, prompts.changed, resource.updated, elicit.opened|answered|completed, sample.opened|answered, server.started|stopped, call.started|finished, server.log, progress, task.updated) plus catalog.changed, published ad hoc from internal/daemon/consumer.go:236 without a constant. Events carry Seq (monotonic, so a reconnecting subscriber replays losslessly), Kind, At, Server, Session, Trace, URI and a JSON Data. Filter matches kind prefixes — "elicit" matches every elicitation event, "*" matches all — plus session, server and URI. GET /v1/events is the SSE view with kinds, session, server, uri and since query parameters (internal/api/ops.go:156); subscriptions/listen is the MCP-legal filtered view of the same stream.

Two structural facts that constrain the design:

  • The bus is fire-and-forget. Publish fans out to subscribers; nothing waits for a subscriber, and nothing can veto. A hook that must change what happens (deny a call, rewrite arguments) cannot be a bus subscriber as the bus exists today.
  • The history is bounded (New(keep int); events.history, default 1024 — internal/defaults/defaults.json:52, wired at internal/daemon/server.go:165). A subscriber that reconnects past the history is told so (event: gap, server.go:838-842), but one that falls behind while connected is not: its 256-slot channel drops events and only bumps a counter nothing reads (events.go:177, :224-231), and a replay longer than 256 matching events is truncated the same way. A hook runner that is slow is therefore a correctness problem, not just a performance one. Gap semantics are Bump build/buck/preludes/buck2-prelude from 3a02ea3 to 4371899 #6's to define (Phase 3: sync, blob gate, and relays #181 collision 13); this issue consumes them.

Approaches

Approach A — least recommended: a hooks block per event kind that shells out inline and synchronously

"hooks": { "call.finished": "notify-send 'done'" }

The obvious shape, and the one every tool from git to husky to opencode ships. It is wrong here specifically because of where it would have to be called from. The natural implementation is a call in Registry.publish or in the pool, which puts an arbitrary user command on the synchronous path of every tool call. One hook that spawns a 200ms process turns every call.finished into a 200ms tax, and a hook that hangs hangs the daemon; mcpx's own performance story (#79) is about shaving tens of milliseconds off exec, which a per-event fork+exec erases.

It also multiplies call sites. Each new hook point is a new line in the pool or the registry, and they drift — which is the exact failure the internal/api/ops*.go one-table design exists to prevent.

And it says nothing about how a hook receives the event. Argv? Environment? stdin JSON? Picking badly here is the thing that cannot be changed later.

If it ships at all, ship it as sugar over B, not as its own mechanism.

Approach B — middle: a declarative subscriber table over the existing bus, with three executors

Hooks are a config list. Each entry is a filter plus an action. The runner is one bus subscriber that owns a bounded worker pool; the daemon's event path does not change at all.

{
  "hooks": [
    { "on": "server.stopped", "run": "./bin/page-me", "when": { "server": "chrome" } },
    { "on": "elicit", "module": "./hooks/route-questions.ts" },
    { "on": "call.finished", "module": "hooks/record.py", "async": true }
  ]
}

Facts and opinions that belong in the design:

  • on reuses events.Filter verbatim. Prefix matching is already there, "*" is already there, and the /v1/events query parameters are already the same vocabulary (kinds, session, server, uri). A hook filter that is a different language from the event-stream filter is a bug factory. Anything beyond that — matching on the tool name inside Data, for instance — is a JSON pointer predicate and can wait.
  • The event is delivered as JSON on stdin, with the flat fields also in the environment (MCPX_EVENT_KIND, MCPX_EVENT_SERVER, MCPX_EVENT_SESSION, MCPX_EVENT_SEQ, MCPX_TRACE_IDS). stdin because the payload is arbitrary JSON and argv has a length limit; environment as well because the 90% hook is a one-liner that wants $MCPX_EVENT_SERVER without jq. This is the decision from A that must be made once and never changed.
  • Three executors, and they should not be three code paths. A command (argv, or a string through sh -c — pick one and be loud about which); a JS/TS module, which is the existing runner (internal/runner/runner.go, deno/bun/node) with the event as the argument, so a TS hook gets the generated typed client for free and can call other servers; and a Python module, which is a command with a runtime resolver in front of it (uv run / uvx / python3 — uv 0.9.28 and Python 3.14.7 are present here). Python is not special: it is the proof the command executor generalises.
  • MCPX_SOCKET in the hook's environment makes every hook a /v1 client, which is what makes hooks useful rather than decorative — a hook can add a server, set a setting, answer an elicitation, or run a script.
  • Delivery semantics have to be stated. At-most-once, bounded queue per hook, drop with a warning when the queue is full, and a seq watermark per hook so a daemon restart can replay. Do not promise ordering across hooks; do promise ordering within one hook.
  • Timeout, and what a non-zero exit means. Follow the adapter's precedent (internal/adapter/adapter.go): a non-zero exit is a result, not an error — record it, publish a hook.failed event, carry on. A hook must never be able to fail a tool call it was only watching.
  • This is settings, not an endpoint. It fits the registry as a repeatable structured setting; the existing KindSource machinery already gives inline-or-file, and a paths.hooks list would give a drop-in directory exactly like paths.adapters.

What B does not do: it cannot influence anything. Every hook is an observer. For a large fraction of what people want hooks for — "block this tool call", "rewrite these arguments", "inject this into the context" — that is not enough.

Approach C — recommended: B, plus a declared phase per hook, with only observers on the bus

Everything in B, with one addition that changes what hooks are for: each hook declares a phase, and the phase decides whether it is a bus subscriber or an in-line interceptor.

phase when blocking may veto/rewrite delivery
observe (default) after the fact no no bus subscriber, queued, replayable by seq
before before the operation yes, bounded yes — deny, or return a replacement result direct call, one per operation
after before the result is returned yes, bounded yes — rewrite the result direct call

observe is B unchanged and costs the daemon nothing. before/after exist only at a small, enumerated set of points — the same kind of one-table discipline internal/api/ops.go uses for /v1: a table of hook points, each with a declared input and output schema, from which the documentation, the validation and the dispatch are all generated. Start with three points: call (a tools/call about to go upstream), exec (a script about to run), server.start. That is enough for the real uses and small enough to audit.

Why this is the recommendation:

  • It is the only option where a hook can do the thing people actually want hooks for, without putting user code on the path of every event.
  • The blocking set is bounded and declared, so the "every hook is a latency tax" failure of A is confined to hooks the user explicitly opted into a blocking phase for, with a mandatory timeout and a declared fail-open/fail-closed per hook.
  • before/after are the same shape as the existing script.before/script.onSuccess/script.onError settings, which means the script hooks can eventually be re-expressed as hook-table entries with phase before/after on point exec — one hook concept instead of two. Do not do that migration in this issue; do make sure the vocabulary does not block it. Input for the decision record: the shipped script.* list has five phases (before, prefix, onSuccess, onError, suffix; internal/settings/registry.go:212-242, run at internal/runner/runner.go:190-205), and the shipped onError runs and then rethrows ("a hook, not a handler"), while Update module gopkg.in/go-jose/go-jose.v2 to v2.6.3 [SECURITY] - autoclosed #112's onError may replace the result. The vocabulary has to name which of the two a hook is.
  • A plugin (sibling issue) becomes a hook target for free: { "on": "call", "phase": "before", "plugin": "guard", "tool": "check" } is a plugin tool call. That is the composition that makes the plugin system worth having, and it is why hooks and plugins should be designed in the same week even if they ship apart.
  • It answers "config can set hooks for non-go/typescript" directly: the executor set is command / JS-TS module / Python module / plugin tool, and none of them is Go. Go extension is by writing a plugin binary, not by being loaded into the daemon.

Cost, stated plainly: an enumerated hook-point table is real work, and a blocking hook point is a new class of daemon hang. Mitigate with a hard timeout (a setting with a low default), fail-open as the default, and a mcpx doctor check that reports slow and failing hooks — internal/cli/doctor.go already does exactly this for adapters and API specs.

Open questions

  • Does a before-phase hook get the arguments, and may it rewrite them? Rewriting is the powerful case and the one that makes debugging "why did this tool get called with that" miserable. Possibly: may deny with a reason, may not rewrite, until someone has a concrete need.
  • Fail-open or fail-closed by default for a before hook that times out? Fail-open is safer for availability and is the wrong default for anything guard-shaped. Probably per-hook with fail-open as the default.
  • Where do hook definitions live in the config chain? Bump build/buck/preludes/buck2-prelude from 3a02ea3 to 370cd4d #5 governs the chain; a project config that can declare a hook that runs a command is a notable escalation over a project config that can declare a server.
  • Should hooks be addressable from /v1 at all — list them, disable one, fire one for testing? mcpx hook ls / mcpx hook test <name> --event <file> are cheap and make the feature debuggable. Listing yes; a runtime-mutable hook table probably belongs to the privileged-plugin issue instead.
  • Interaction with Bump build/buck/preludes/buck2-prelude from 3a02ea3 to 4371899 #6: if every event lands in an event store, a hook is a trigger on that table, and "replay hooks over history" becomes possible. Worth designing the seq watermark so this stays open.
  • The bus history bound: what is keep today, and is it enough that a hook whose worker pool is saturated for a few seconds does not lose events? Answered: events.history, default 1024 (above). A saturated hook pool is gapped, not merely slow; what the hook is told is Bump build/buck/preludes/buck2-prelude from 3a02ea3 to 4371899 #6's gap contract (collision 13), which recommends one explicit gap marker for reconnect, live overflow and replay truncation.
  • Do hooks fire for events a plugin published? They should, and that implies plugins can publish to the bus, which is a privileged-surface question.

Ease · Impact · Related

Ease: M for the observer table and the three executors; L with phases and the hook-point table. Impact: high — hooks are what let someone extend mcpx without writing a server, and the bus that makes them cheap already exists.

Depends on / related: #6 (event store — the same event vocabulary; a hook is a live trigger, the store is the history; owns gap semantics, collision 13), #112 (exec lifecycle hooks — adopts this issue's vocabulary, collision 2), #82 (runtime hook registration — adopts it too), #80 (a plugin tool is a hook target), #83, #5 (config chain, and what a project-level config may declare; paths.config is still unread), #7 (script.* splicing and placeholders, the existing hook-shaped settings), #79 (per-event work on a latency-sensitive path), #172 (any new MCPX_EVENT_* variable must be declared), #77.

Security/auth deferred; see tracker.

Activity

  1. dezren39 commented on Oct 28, 2026

    @dezren39
    MemberAuthor

    History carried over from dezren39/darwin-nix#81 (verbatim, oldest first):


    @dezren39 · 2026-09-30T07:21:25Z

    Integration pass. Placed in the DAG rooted at #181.


    @dezren39 · 2026-09-30T07:27:47Z

    Integration pass -- cross-theme collision. Surfaced only when the themes were put side by side; the authors of these issues could not see each other's work. Recorded at the root in #181.

    Four hook vocabularies, and no issue owns the reconciliation

    script.* (shipped), #81's hook-point table, #82's runtime hook registration, and #112's prePrompt/postPrompt/onError list are four vocabularies for one concept. #112 sits in a different theme (#176) from #81/#82 (#173), so nobody currently owns the overlap. Reconciling them after the fact is a breaking config change -- design them as one.

    Also on: #82 #112. Nobody currently owns this.


    @dezren39 · 2026-09-30T07:28:28Z

    Integration pass -- cross-theme collision. Surfaced only when the themes were put side by side; the authors of these issues could not see each other's work. Recorded at the root in #181.

    events.history already answers #81's open question, and changes its design

    #81 asks what keep is today. It is events.history, default 1024 (internal/defaults/defaults.json:52, wired at internal/daemon/server.go:165). That means a saturated hook pool is gapped, not merely slow, which the seq-watermark design has to handle explicitly.

    Also on: #6. Nobody currently owns this.


    @dezren39 · 2026-09-30T11:05:46Z

    Collision ownership (issues pass, 2026-09-30, at 05c78b2). #181 listed these cross-theme collisions with no owner. Each now has one:

    #181 row Collision Owner What the owner decides
    2 four hook vocabularies (script.* shipped, #81, #82, #112) #81, via a decision record SURFACES is writing under pkgs/mcpx/docs/decisions/ (in flight; linked here when it merges) The vocabulary is decided in the decision record; #81 is the issue that carries it. #82 and #112 adopt it rather than defining their own.
    13 events.history (default 1024) already answers #81's open question #6 A bounded history means a saturated consumer is gapped, not slow. That is a property of the event store, so #6 defines gap semantics (a seq watermark and an explicit gap marker); #81's hooks consume them.

    Where this issue is the owner, the decision is made here; where it is not, it adopts the owner's decision rather than making its own.


    @dezren39 · 2026-09-30T12:12:08Z

    Issues pass (2026-09-30, at 05c78b2).


    @dezren39 · 2026-09-30T12:16:47Z

    Issues pass (2026-09-30, architect follow-up). Added input for the hook-vocabulary decision record (collision 2): the shipped script.* list has five phases (registry.go:212-242, runner.go:190-205), and its onError rethrows while #112's may replace the result; the vocabulary has to name which a hook is. (From the AI-layer worker.)


    @dezren39 · 2026-09-30T16:58:13Z

    Decided (SURFACES, 0001-hook-vocabulary, merged in #228). #181 collision 2 is settled: the shipped launcher phases are the one vocabulary.

    What changes in this issue:

    • Phases are before, prefix, onSuccess, onError, suffix (shipped) plus observe (new, the default: asynchronous, off the bus). after becomes onSuccess/onError (or suffix); async becomes phase: observe.
    • Hook points live in one Go table like api.Ops(): script, call, server.start. The point this issue calls exec is renamed script, so it visibly matches script.<phase>.
    • A hook is {on, phase, action, effect}; actions are source (only via script.<phase>), run (an argv array, never a shell string), module, prompt, plugin. effect takes the autonomy levels of 0002.
    • Hooks are declared as one hooks setting, not endpoints (Phase 3: sync, blob gate, and relays #181 recommendation 6). That needs a JSON setting kind in internal/settings.
    • Design delivery against what exists: events.history is 1024 and a subscriber's buffer is 256, so a saturated hook pool is gapped (Phase 3: sync, blob gate, and relays #181 row 13). call.started and task.updated are declared bus kinds nothing publishes; drop them from the hookable list until something does.

    @dezren39 · 2026-10-28T21:03:09Z

    Triage verdict — confirmed, partially decided, unimplemented; mis-homed in this repo.

    Verified against current main (2026-10-28):

    • This repo (dezren39/nix@8c42c2c, origin/main) has no mcpx Go source — pkgs/mcpx/ and internal/ were deleted 2026-10-19 (lab-ops-hub container crawler accepts inet_aton IPv4 spellings (127.1, 2130706433, 0x7f.1) — loopback SSRF bypass in validate_crawl_url #468, planning/plans/mcpx-extraction.md steps 4–5); mcpx now lives solely in developing-today/code at pkgs/mcpx/, consumed here as a pinned source-only flake input.
    • In the new home (developing-today/code@528886e, origin/main): the internal half this issue describes still exists exactly as described — pool.Hooks at pkgs/mcpx/internal/pool/pool.go:1069, wired to the bus by Registry.InstallHooks in pkgs/mcpx/internal/daemon/hooks.go — and it is explicitly "not a user vocabulary; the source of bus events" (pkgs/mcpx/docs/decisions/0001-hook-vocabulary.md, Mapping table).
    • The user-facing half does not exist: git grep "hooks." pkgs/mcpx/internal/settings/registry.go at code HEAD returns no config-declared hook settings (only the unrelated hooks.autonomy consumer entry at internal/settings/consumer.go:145); there is no hooks: [{on, phase, run|module|plugin}] declaration surface.
    • Partial progress to credit: decision 0001 (hook vocabulary, accepted 2026-09-30) is present in code and adopts this issue's vocabulary with amendments — run: must be an argv array (a string is refused/forbidden), phase: after splits into onSuccess/onError, and several items are explicitly Deferred → chore(deps): bump stefanzweifel/git-auto-commit-action from 4 to 5 #81 (e.g. mcpx hook test, script.started/script.finished bus kinds). So the design input landed, the feature did not.
    • Re-homing is already tracked by mcpx: re-home the open mcpx backlog from dezren39/nix, with this repo as the single source #309 ("mcpx: re-home the live backlog from dezren39/nix", OPEN) and extraction plan steps 3/6, which name the design backlog for transfer-then-close here.

    Route: no small_fix packet in the nix queue — implementing config hooks here would recreate the vendored mcpx copy the extraction removed, and a duplicate filing in code would violate the one-issue-per-change rule while code#309 owns the transfer. Keep open here until code#309 re-files it in developing-today/code (carrying decision 0001 as its vocabulary), then close here with that link.


    agent-note:v1 · agent: repo-queue-worker-shift-nix (lane A) · task 2064 · kind: triage · outcome: confirmed, decision 0001 landed in code, feature unimplemented; routed to #309 re-home; no nix follow-up packet

  2. developing-today commented on Oct 28, 2026

    @developing-today
    Contributor
  3. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Oct 29, 2026
  4. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    Agent note — triage verdict (queue task 2765): NOT a duplicate; scope confirmed with corrections; implementation follow-up enqueued

    Dedupe (checked 2026-10-29, code issues, all states): no existing issue covers config-declared hooks over the event bus. Named neighbours, none a duplicate: tracker #1885 (extensibility — plugins, hooks, tool sources, embedding) is the parent; #1873 is the programmatic/privileged half, explicitly split from this declarative half by this issue's own body; #1929 is the exec-lifecycle list (the #112 successor) which adopts this issue's vocabulary — a consumer, not a cover; #1870 (a plugin is an MCP server) defines one hook target type; #1842 (paths.config governs the config chain) owns the config-chain question this issue's open questions point at.

    Scope vs current main (origin/main 42796be) — corrections to the issue's stated current state:

    Follow-up: implementation packet enqueued on this queue with acceptance: hookable event set re-derived from current events.go + decision 0001; declarative hook table in config (respecting #1842's config-chain ruling on what project config may declare); command / JS-TS module / Python module executors (no Go loading); hard timeout + fail-open default per-hook; mcpx doctor reporting of slow/failing hooks; pkgs/mcpx go test ./... green in CI.

  5. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    Agent note — triage closed (queue task 2765)

    • Verdict above stands: not a duplicate; scope confirmed with corrections (decision 0001 landed; bus vocabulary re-derived).
    • Implementation follow-up enqueued as task 3097 on repo-health-code-pilot.
    • This triage task is complete; the issue stays open for task 3097.
  6. 30 remaining items

  7. added
    status/parkedDeliberately paused with a recorded resume condition
    on Oct 30, 2026
  8. dezren39 commented on Oct 30, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · parked

  9. dezren39 commented on Oct 30, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · parked

  10. added a commit that references this issue on Oct 30, 2026
  11. dezren39 commented on Oct 30, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · parked

  12. added
    queue/claimedQueue worker lifecycle state
    and removed
    status/parkedDeliberately paused with a recorded resume condition
    queue/parkedQueue task is parked (waiting on CI, time, or an event)
    on Oct 30, 2026
  13. dezren39 commented on Oct 30, 2026

    @dezren39
    MemberAuthor

    Agent note — resumed, verifying merged PR (queue task 3097, Lane D)

  14. dezren39 commented on Oct 30, 2026

    @dezren39
    MemberAuthor

    Agent note — done, closing (queue task 3097, Lane D)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions