From eb4cabc69ef79d5a03ef9bb52a131d6eefd16876 Mon Sep 17 00:00:00 2001 From: Drewry Pope Date: Thu, 29 Oct 2026 22:22:22 -0500 Subject: [PATCH] mcpx: config-declared hooks over the event bus (#1872) Implements the declarative half of #1872, built to docs/decisions/0001: a daemon-scoped hooks setting (JSON array of hook objects) plus hooks.timeout (2s default). Each hook is phase observe: on is an events.Filter kind prefix verbatim, when carries server/session/uri, and the action is exactly one of run (an argv array, never a shell string) or module (.py via python3/uv, .ts/.js/.mjs via deno/bun/node). Non-observe phases are refused at parse time; resource.watching and hook.* are unhookable. internal/hooks: Parse validation against the hookable kind set re-derived from internal/events constants (catalog.changed gains the constant it never had), and a Runner that subscribes once, fans out to a bounded serial queue per hook (drops counted, gap count carried on the next delivery), executes under a hard context timeout, delivers the event as JSON on stdin with MCPX_HOOK_* env vars (declared in settings.ScriptEnv per #172), and counts fired/failed/timed-out/dropped/slow. A failure is a result: counted, logged, and published as hook.failed; publishing never waits on a hook. Daemon wiring in NewServer (parse failure logs loudly and installs no hooks; doctor fails that check instead). mcpx hook ls lists hooks in force with executor readiness; doctor gains a hooks check for unparsable declarations and unready executors. Generated docs (configuration.md, environment.md, parity.md) regenerated via their -update tests. Tests: parse validation (phases, unhookable kinds, two actions, shell string), prefix+when matching, declared hook fires on matching kinds only with the event JSON on stdin, Python module hook runs, timeout cuts a hanging hook while Publish proceeds, failing hook publishes hook.failed. Pre-existing on main, untouched: TestEveryDocumentHasAChapter fails on clean main too (11 docs missing from compendium.manifest.json). --- pkgs/mcpx/docs/configuration.md | 2 + pkgs/mcpx/docs/environment.md | 7 + pkgs/mcpx/docs/parity.md | 3 +- pkgs/mcpx/internal/cli/dispatch.go | 1 + pkgs/mcpx/internal/cli/doctor.go | 27 ++ pkgs/mcpx/internal/cli/hook.go | 94 ++++++ pkgs/mcpx/internal/cli/manual.go | 13 + pkgs/mcpx/internal/daemon/consumer.go | 2 +- pkgs/mcpx/internal/daemon/server.go | 22 ++ pkgs/mcpx/internal/events/events.go | 10 + pkgs/mcpx/internal/hooks/hooks.go | 259 ++++++++++++++++ pkgs/mcpx/internal/hooks/hooks_test.go | 92 ++++++ pkgs/mcpx/internal/hooks/runner.go | 286 ++++++++++++++++++ pkgs/mcpx/internal/hooks/runner_test.go | 127 ++++++++ pkgs/mcpx/internal/settings/registry.go | 43 +++ pkgs/mcpx/internal/settings/scriptenv.go | 51 ++++ pkgs/mcpx/internal/settings/scriptenv_test.go | 1 + 17 files changed, 1038 insertions(+), 2 deletions(-) create mode 100644 pkgs/mcpx/internal/cli/hook.go create mode 100644 pkgs/mcpx/internal/hooks/hooks.go create mode 100644 pkgs/mcpx/internal/hooks/hooks_test.go create mode 100644 pkgs/mcpx/internal/hooks/runner.go create mode 100644 pkgs/mcpx/internal/hooks/runner_test.go diff --git a/pkgs/mcpx/docs/configuration.md b/pkgs/mcpx/docs/configuration.md index bf4f82ed7..f026fe335 100644 --- a/pkgs/mcpx/docs/configuration.md +++ b/pkgs/mcpx/docs/configuration.md @@ -375,7 +375,9 @@ and a switch to turn the report off would have kept it quiet. | setting | kind | default | scope | hot | flag | variable | governs | | --- | --- | --- | --- | --- | --- | --- | --- | +| `hooks` | string | *(empty)* | daemon | no | `--hooks` | `MCPX_HOOKS` | config-declared hooks over the event bus: a JSON array of hook objects | | `hooks.autonomy` | enum | `run` | call | yes | `--hooks-autonomy` | `MCPX_HOOKS_AUTONOMY` | whether configured script hooks (before, onSuccess, onError) run | +| `hooks.timeout` | duration | `2s` | daemon | no | `--hooks-timeout` | `MCPX_HOOKS_TIMEOUT` | the hard timeout for a declared hook that does not set its own | ### http diff --git a/pkgs/mcpx/docs/environment.md b/pkgs/mcpx/docs/environment.md index b126f86f6..4ce50e4f8 100644 --- a/pkgs/mcpx/docs/environment.md +++ b/pkgs/mcpx/docs/environment.md @@ -94,6 +94,13 @@ and read by name for that reason. | `MCPX_EPHEMERAL` | `mcpx exec` / `mcpx run` | always | 1 when the session key was minted for this run alone, so its instances may be stopped as soon as it ends | the generated client | | `MCPX_RUN` | `mcpx exec` / `mcpx run`; the exec service (`/v1/exec`, `mcpx_exec`) | always | this execution's run id, which groups the artifacts it produces | the generated client, when storing an artifact | | `MCPX_INPUT` | `mcpx exec` / `mcpx run` | mcpx exec and mcpx run, locally and with --remote; not when mcpx serve collects the output | report: nothing in this run can answer a server's question mid-call, so a call that asks one exits 75 with the question instead of waiting for it to expire | the generated client, sent as X-Mcpx-Input | +| `MCPX_HOOK_NAME` | the declared-hook runner, for a hook it runs | always | the declared name of the hook being run | the hook itself | +| `MCPX_HOOK_PHASE` | the declared-hook runner, for a hook it runs | always | the hook's phase; always observe for config-declared hooks | the hook itself | +| `MCPX_HOOK_EVENT_KIND` | the declared-hook runner, for a hook it runs | always | the kind of the event the hook is running for; the full event is JSON on stdin | the hook itself | +| `MCPX_HOOK_EVENT_SEQ` | the declared-hook runner, for a hook it runs | always | the event's sequence number on the daemon's bus | the hook itself | +| `MCPX_HOOK_EVENT_SERVER` | the declared-hook runner, for a hook it runs | empty when the event names no server | the server the event is about | the hook itself | +| `MCPX_HOOK_EVENT_SESSION` | the declared-hook runner, for a hook it runs | empty when the event belongs to no session | the session the event belongs to | the hook itself | +| `MCPX_HOOK_EVENT_URI` | the declared-hook runner, for a hook it runs | empty when the event names no resource | the resource URI the event is about | the hook itself | | `MCPX_ENDPOINT` | `mcpx exec` / `mcpx run`; the exec service (`/v1/exec`, `mcpx_exec`); an event consumer | always | the daemon's HTTP endpoint | the generated client; the plugin as a fallback for MCPX_DAEMON_ENDPOINT | | `MCPX_SOCKET` | `mcpx exec` / `mcpx run`; the exec service (`/v1/exec`, `mcpx_exec`); the plugin loader, for a discovered plugin | empty when the daemon is remote, since its socket is not reachable from here | the daemon's unix socket, which is faster than the endpoint. Also where a daemon binds and a client dials, for anyone who sets it by hand | the generated client, `mcpx` run inside the script or shell, the plugin | | `MCPX_ARTIFACTS_LOCAL` | `mcpx exec` / `mcpx run`; the exec service (`/v1/exec`, `mcpx_exec`) | always | 1 when the daemon shares this filesystem, so artifact({path}) may hand over a path instead of the bytes | the generated client | diff --git a/pkgs/mcpx/docs/parity.md b/pkgs/mcpx/docs/parity.md index f5aac788e..e6aa9c525 100644 --- a/pkgs/mcpx/docs/parity.md +++ b/pkgs/mcpx/docs/parity.md @@ -118,7 +118,7 @@ carry instead is a description that says so and MCP annotations | `tool_invoke` | `POST /v1/tools/{tool}` | `mcpx tool invoke` *generated* | `mcpx_tool_invoke` | `ops.toolInvoke()` | | | `types` | `GET /v1/types` | `mcpx types` | `mcpx_types` | `ops.types()`, `types()` | | -## CLI commands (66) +## CLI commands (67) Every command the binary dispatches, with the operations it reaches. A command that reaches none says why: those are decisions, not gaps. @@ -151,6 +151,7 @@ that reaches none says why: those are decisions, not gaps. | `health` | `health` | Generated from the operation table. | | `help` | – | Text about the binary, produced by the binary. | | `history` | `catalog_history` | Generated from the operation table. | +| `hook` | – | Reads the resolved `hooks` setting from the configuration chain on this machine. | | `init` | – | Writes a file into the current directory. | | `interactions` | `interactions_list`, `interactions_get` | | | `log` / `logs` | `log_query`, `log_record` | | diff --git a/pkgs/mcpx/internal/cli/dispatch.go b/pkgs/mcpx/internal/cli/dispatch.go index bdbce432d..1dd6a4459 100644 --- a/pkgs/mcpx/internal/cli/dispatch.go +++ b/pkgs/mcpx/internal/cli/dispatch.go @@ -61,6 +61,7 @@ func (a *App) handWritten() map[string]Handler { "prompts": a.CmdPrompts, "resources": a.CmdResources, "doctor": a.CmdDoctor, + "hook": a.CmdHook, "schema": a.CmdSchema, "elicit": a.CmdElicit, "diagnose": a.CmdDiagnose, diff --git a/pkgs/mcpx/internal/cli/doctor.go b/pkgs/mcpx/internal/cli/doctor.go index fb4cbd513..5e8f1d924 100644 --- a/pkgs/mcpx/internal/cli/doctor.go +++ b/pkgs/mcpx/internal/cli/doctor.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/dezren39/mcpx/internal/config" + "github.com/dezren39/mcpx/internal/hooks" "github.com/dezren39/mcpx/internal/opencode" ) @@ -263,6 +264,32 @@ func (a *App) CmdDoctor(ctx context.Context, args []string) error { add(check{"api specs", "ok", fmt.Sprintf("%d declared", len(apis)), ""}) } + // Declared hooks. A hook whose declaration does not parse, or whose + // executor cannot run, fails silently at fire time -- the daemon + // logs it and carries on, which is right for the daemon and useless + // for the person wondering why nothing happened. Doctor is where + // that silence ends. (Runtime slow/failing counts live on the + // daemon's hook runner and surface as hook.failed events.) + if raw := a.Settings().String("hooks"); raw != "" { + declared, herr := hooks.Parse(raw, a.Settings().Duration("hooks.timeout")) + switch { + case herr != nil: + add(check{"hooks", "fail", herr.Error(), "fix the `hooks` setting; `mcpx hook ls` shows the same error"}) + default: + var unready []string + for _, h := range declared { + if r := hookReady(h); r != "yes" { + unready = append(unready, h.Name+": "+strings.TrimPrefix(r, "no: ")) + } + } + if len(unready) > 0 { + add(check{"hooks", "warn", strings.Join(unready, "; "), "install the command or fix the module path"}) + } else { + add(check{"hooks", "ok", fmt.Sprintf("%d declared, all executors ready", len(declared)), ""}) + } + } + } + if a.JSON { return a.out(map[string]any{ "checks": checks, diff --git a/pkgs/mcpx/internal/cli/hook.go b/pkgs/mcpx/internal/cli/hook.go new file mode 100644 index 000000000..16b20bd18 --- /dev/null +++ b/pkgs/mcpx/internal/cli/hook.go @@ -0,0 +1,94 @@ +package cli + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "text/tabwriter" + "time" + + "github.com/dezren39/mcpx/internal/hooks" +) + +// CmdHook inspects the config-declared hooks (the `hooks` setting, +// docs/decisions/0001). Listing is the debuggability surface the issue +// asked for; a runtime-mutable hook table belongs to the privileged +// plugin surface and is deliberately not here. +// +// mcpx hook ls the hooks in force, where each came from, and +// whether its executor can actually run +func (a *App) CmdHook(ctx context.Context, args []string) error { + if len(args) == 0 || args[0] != "ls" { + return fmt.Errorf("usage: mcpx hook ls -- list the declared hooks in force (the only subcommand; the hook table is config, not runtime state)") + } + fs := newFlagSet("hook") + if err := parseFlags(a, fs, args[1:]); err != nil { + return err + } + declared, err := hooks.Parse(a.Settings().String("hooks"), a.Settings().Duration("hooks.timeout")) + if err != nil { + return err + } + type row struct { + Name string `json:"name"` + On string `json:"on"` + Executor string `json:"executor"` + Source string `json:"source"` + Timeout string `json:"timeout"` + Failure string `json:"failure"` + Ready string `json:"ready"` + } + rows := make([]row, 0, len(declared)) + for _, h := range declared { + src := strings.Join(h.Run, " ") + if h.Module != "" { + src = h.Module + } + fail := "open" + if h.FailureClosed { + fail = "closed" + } + rows = append(rows, row{ + Name: h.Name, On: strings.Join(h.On, ","), Executor: string(h.Executor), + Source: src, Timeout: h.Timeout.Round(time.Millisecond).String(), + Failure: fail, Ready: hookReady(h), + }) + } + if a.JSON { + return a.out(rows) + } + if len(rows) == 0 { + fmt.Println("No hooks declared. Declare them in the `hooks` setting:") + fmt.Println(` "hooks": [{"name": "page", "on": "server.stopped", "run": ["notify-send", "done"]}]`) + return nil + } + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tON\tEXECUTOR\tSOURCE\tTIMEOUT\tFAILURE\tREADY") + for _, r := range rows { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + r.Name, r.On, r.Executor, r.Source, r.Timeout, r.Failure, r.Ready) + } + return tw.Flush() +} + +// hookReady reports whether the hook's executor can run at all on this +// machine -- the check that turns "the hook never fired" from a mystery +// into a line of output. +func hookReady(h hooks.Hook) string { + switch h.Executor { + case hooks.ExecRun: + if len(h.Run) == 0 { + return "no: empty run" + } + if _, err := exec.LookPath(h.Run[0]); err != nil { + return "no: " + h.Run[0] + " not on PATH" + } + case hooks.ExecModuleJS, hooks.ExecModulePython: + if _, err := os.Stat(h.Module); err != nil { + return "no: module file missing" + } + } + return "yes" +} diff --git a/pkgs/mcpx/internal/cli/manual.go b/pkgs/mcpx/internal/cli/manual.go index 8a4b3142a..a41fc5d05 100644 --- a/pkgs/mcpx/internal/cli/manual.go +++ b/pkgs/mcpx/internal/cli/manual.go @@ -429,6 +429,19 @@ func handCommands() []Command { "says what to do about it, because one that only reports a problem " + "leaves the reader where they started.", }, + { + Name: "hook", Group: "inspection", + Local: "Reads the resolved `hooks` setting from the configuration chain on this machine.", + Usage: "ls", Summary: "list the config-declared hooks in force", + Detail: "Hooks are declared in the `hooks` setting (docs/decisions/0001): " + + "a JSON array of {name, on, run | module, when, timeoutMs, failure}. " + + "Every hook is an observer over the daemon's event bus -- it runs " + + "after the fact, under a hard timeout, and cannot change what it " + + "watched. `ls` shows each hook, its filter, its executor and " + + "whether that executor can actually run on this machine. The table " + + "is configuration, not runtime state: there is no subcommand that " + + "changes it on a running daemon.", + }, { Name: "prompts", Group: "discovery", Usage: "[. key=value...]", Summary: "list or render a server's prompts", diff --git a/pkgs/mcpx/internal/daemon/consumer.go b/pkgs/mcpx/internal/daemon/consumer.go index df16fe3e8..aba08f775 100644 --- a/pkgs/mcpx/internal/daemon/consumer.go +++ b/pkgs/mcpx/internal/daemon/consumer.go @@ -246,7 +246,7 @@ func (r *Registry) ObserveCatalog() { for _, ch := range changes { r.logf("catalog: %s %s", ch.Tool, ch.What) r.publish(events.Event{ - Kind: events.Kind("catalog.changed"), Data: mustJSON(ch), + Kind: events.CatalogChanged, Data: mustJSON(ch), }) } } diff --git a/pkgs/mcpx/internal/daemon/server.go b/pkgs/mcpx/internal/daemon/server.go index 5cd882649..6f0db9683 100644 --- a/pkgs/mcpx/internal/daemon/server.go +++ b/pkgs/mcpx/internal/daemon/server.go @@ -24,6 +24,7 @@ import ( "github.com/dezren39/mcpx/internal/defaults" "github.com/dezren39/mcpx/internal/elicit" "github.com/dezren39/mcpx/internal/events" + "github.com/dezren39/mcpx/internal/hooks" "github.com/dezren39/mcpx/internal/logging" "github.com/dezren39/mcpx/internal/logstore" "github.com/dezren39/mcpx/internal/mcpclient" @@ -65,6 +66,12 @@ type Server struct { // Events carries everything the daemon notices, to every subscriber. Events *events.Bus + // hookRunner runs the config-declared hooks (the `hooks` setting) + // over Events. Nil when none are declared, or when the declaration + // failed to parse -- doctor reports that case, and a daemon that + // refuses to start over an observer's typo helps nobody. + hookRunner *hooks.Runner + // Origins decides which browser origins may use the daemon at all; see // refuseBrowserPages. The zero value allows loopback origins only. Origins mcpserver.OriginPolicy @@ -237,6 +244,17 @@ func NewServer(opts Options) (*Server, error) { } reg.UseSettings(opts.Settings) reg.InstallHooks(srv.Events, broker, nil) + if raw := opts.Settings.String("hooks"); raw != "" { + declared, herr := hooks.Parse(raw, opts.Settings.Duration("hooks.timeout")) + if herr != nil { + opts.Logger.Printf("hooks: %v (no hooks installed)", herr) + } else if len(declared) > 0 { + srv.hookRunner = hooks.NewRunner(srv.Events, declared, func(f string, a ...any) { + opts.Logger.Printf(f, a...) + }) + srv.hookRunner.Start() + } + } srv.initConsumer() srv.tokenStore, _ = logstore.NewTokenStore(filepath.Join(opts.Paths.State, "tokens.db")) if opts.Config != nil { @@ -250,6 +268,10 @@ func NewServer(opts Options) (*Server, error) { // Registry exposes the pool registry (used by the in-process fast path). func (s *Server) Registry() *Registry { return s.reg } +// HookRunner exposes the declared-hook runner, for status and tests. +// Nil when no hooks are declared. +func (s *Server) HookRunner() *hooks.Runner { return s.hookRunner } + // Endpoint is the loopback base URL once Listen has run. func (s *Server) Endpoint() string { return s.endpoint } diff --git a/pkgs/mcpx/internal/events/events.go b/pkgs/mcpx/internal/events/events.go index da6c291af..e27006976 100644 --- a/pkgs/mcpx/internal/events/events.go +++ b/pkgs/mcpx/internal/events/events.go @@ -52,6 +52,16 @@ const ( ServerLog Kind = "server.log" Progress Kind = "progress" + + // CatalogChanged is published by the daemon's consumer registry when + // the catalogue moves. It had no constant and was published as a bare + // string, which kept it out of every enumerated list of kinds. + CatalogChanged Kind = "catalog.changed" + + // HookFailed is published by the declared-hook runner when a hook + // exits non-zero or is cut by its timeout. It is not itself hookable: + // a hook subscribed to it could retrigger itself forever. + HookFailed Kind = "hook.failed" ) // Event is one thing that happened. diff --git a/pkgs/mcpx/internal/hooks/hooks.go b/pkgs/mcpx/internal/hooks/hooks.go new file mode 100644 index 000000000..9968bbe3f --- /dev/null +++ b/pkgs/mcpx/internal/hooks/hooks.go @@ -0,0 +1,259 @@ +// Package hooks is the declarative half of mcpx's hook vocabulary +// (docs/decisions/0001): hooks a user declares in configuration, in the +// `hooks` setting, that fire on events the daemon publishes to its bus. +// +// Every hook here is phase `observe` -- the only phase this package +// implements. An observe hook runs after the fact, asynchronously, and +// can never slow, veto or rewrite the operation it watched: the bus is +// fire-and-forget, the runner owns a bounded queue per hook, and every +// execution is cut by a hard timeout. The blocking phases (before, +// onSuccess, onError, suffix) belong to the enumerated hook points and +// are rejected at parse time rather than silently treated as observers. +// +// A hook is one action, exactly one per entry (decision 0001): +// +// - run: an argv array, never a shell string. Write +// ["sh", "-c", "..."] to ask for a shell, out loud. +// - module: a file run for its kind -- .py by the Python runtime, +// .ts/.js/.mjs by the first available JS runtime (deno, bun, +// node). The packet for #1872 names a Python module an executor +// of its own; decision 0001 spells Python as a `run` until a +// runtime knows it, and this is that knowledge, kept in one place. +// +// The event is delivered as JSON on stdin, with the flat fields also in +// the environment as MCPX_HOOK_* variables, so the common one-liner +// hook never has to parse JSON to know which server stopped. +package hooks + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/dezren39/mcpx/internal/events" +) + +// Executor names what runs a hook. +type Executor string + +const ( + ExecRun Executor = "run" + ExecModuleJS Executor = "module:js" + ExecModulePython Executor = "module:python" +) + +// Hook is one validated declaration. +type Hook struct { + Name string + // On is the event filter: kind prefixes, verbatim events.Filter + // semantics. "*" matches every hookable kind. + On []string + // When carries the filter's other fields. + Server string + Session string + URIs []string + // Phase is always "observe" in this package; Parse rejects the rest. + Phase string + Executor Executor + // Run is the argv for ExecRun. + Run []string + // Module is the file for the module executors. + Module string + // Timeout is the hard cut on one execution. + Timeout time.Duration + // FailureClosed is the hook's declared `"failure": "closed"`. In + // the observe phase there is no operation left to refuse, so it + // changes only how a failure is reported: closed failures are + // counted separately and named in the hook.failed event, so a + // guard-shaped hook's failures are never mistaken for noise. + FailureClosed bool +} + +// Filter is the hook's event filter, in the bus's own vocabulary. +func (h Hook) Filter() events.Filter { + return events.Filter{Kinds: h.On, Server: h.Server, Session: h.Session, URIs: h.URIs} +} + +// Matches reports whether the hook fires on e. +func (h Hook) Matches(e events.Event) bool { return h.Filter().Matches(e) } + +// decl is the JSON shape of one entry in the `hooks` setting. +type decl struct { + Name string `json:"name"` + On json.RawMessage `json:"on"` + Phase string `json:"phase"` + Run []string `json:"run"` + Module string `json:"module"` + When struct { + Server string `json:"server"` + Session string `json:"session"` + URI []string `json:"uri"` + } `json:"when"` + TimeoutMs int `json:"timeoutMs"` + Timeout string `json:"timeout"` + Failure string `json:"failure"` +} + +// HookableKinds is every kind a hook may subscribe to: every kind the +// bus carries, re-derived from the constants in internal/events, minus +// the ones that are never on the bus or must never retrigger a hook. +// +// - resource.watching is a /v1/events stream report, never published. +// - hook.failed is published by this package's runner; a hook on it +// could retrigger itself forever. +func HookableKinds() []events.Kind { + all := []events.Kind{ + events.ToolsChanged, events.ResourcesChanged, events.PromptsChanged, + events.ResourceUpdated, + events.ElicitOpened, events.ElicitAnswered, events.ElicitCompleted, + events.SampleOpened, events.SampleAnswered, + events.ServerStarted, events.ServerStopped, + events.CallFinished, + events.ServerLog, events.Progress, + events.CatalogChanged, + } + sort.Slice(all, func(i, j int) bool { return all[i] < all[j] }) + return all +} + +// hookable reports whether prefix p can match at least one hookable +// kind, under the Filter rule (exact, or prefix followed by a dot, or +// "*"). +func hookable(p string) bool { + if p == "*" { + return true + } + if strings.HasPrefix(p, "hook.") || p == "hook" { + return false + } + for _, k := range HookableKinds() { + ks := string(k) + if p == ks || strings.HasPrefix(ks, p+".") { + return true + } + } + return false +} + +// Parse validates the raw `hooks` setting value: a JSON array of hook +// objects. defaultTimeout is the daemon's hooks.timeout, used by any +// hook that does not declare its own. An empty value declares no +// hooks and is not an error. +func Parse(raw string, defaultTimeout time.Duration) ([]Hook, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + if defaultTimeout <= 0 { + defaultTimeout = 2 * time.Second + } + var decls []decl + if err := json.Unmarshal([]byte(raw), &decls); err != nil { + return nil, fmt.Errorf("hooks: not a JSON array of hook objects: %w", err) + } + out := make([]Hook, 0, len(decls)) + seen := map[string]bool{} + for i, d := range decls { + h, err := convert(d, i, defaultTimeout) + if err != nil { + return nil, err + } + if seen[h.Name] { + return nil, fmt.Errorf("hooks: two hooks named %q", h.Name) + } + seen[h.Name] = true + out = append(out, h) + } + return out, nil +} + +func convert(d decl, i int, defaultTimeout time.Duration) (Hook, error) { + h := Hook{ + Name: d.Name, + Phase: d.Phase, + Module: strings.TrimSpace(d.Module), + Server: d.When.Server, + Session: d.When.Session, + URIs: d.When.URI, + } + if h.Name == "" { + h.Name = fmt.Sprintf("hook-%d", i+1) + } + if h.Phase == "" { + h.Phase = "observe" + } + if h.Phase != "observe" { + return h, fmt.Errorf("hooks: hook %q declares phase %q: only \"observe\" is implemented for config-declared hooks; the blocking phases belong to the hook points (docs/decisions/0001)", h.Name, h.Phase) + } + // on: one kind prefix, or a list of them. + switch { + case len(d.On) == 0: + return h, fmt.Errorf("hooks: hook %q declares no \"on\": name at least one event kind prefix", h.Name) + default: + var one string + if err := json.Unmarshal(d.On, &one); err == nil { + h.On = []string{one} + } else if err := json.Unmarshal(d.On, &h.On); err != nil { + return h, fmt.Errorf("hooks: hook %q: \"on\" is neither a kind nor a list of kinds", h.Name) + } + } + for _, p := range h.On { + if !hookable(p) { + return h, fmt.Errorf("hooks: hook %q: %q matches no hookable event kind (hookable: %s; resource.watching is never on the bus and hook.* is the runner's own)", h.Name, p, joinKinds()) + } + } + // Exactly one action. + switch { + case len(d.Run) > 0 && h.Module != "": + return h, fmt.Errorf("hooks: hook %q declares both \"run\" and \"module\": a hook is exactly one action", h.Name) + case len(d.Run) > 0: + h.Executor = ExecRun + h.Run = d.Run + case h.Module != "": + switch { + case strings.HasSuffix(h.Module, ".py"): + h.Executor = ExecModulePython + case strings.HasSuffix(h.Module, ".ts"), strings.HasSuffix(h.Module, ".js"), + strings.HasSuffix(h.Module, ".mjs"), strings.HasSuffix(h.Module, ".mts"): + h.Executor = ExecModuleJS + default: + return h, fmt.Errorf("hooks: hook %q: module %q: cannot tell its kind from the extension (.py, .ts, .js, .mjs)", h.Name, h.Module) + } + default: + return h, fmt.Errorf("hooks: hook %q declares no action: give \"run\" (an argv array) or \"module\" (a file)", h.Name) + } + // Timeout: per-hook wins, then the daemon default. Always bounded. + h.Timeout = defaultTimeout + if d.Timeout != "" { + t, err := time.ParseDuration(d.Timeout) + if err != nil { + return h, fmt.Errorf("hooks: hook %q: timeout %q: %w", h.Name, d.Timeout, err) + } + h.Timeout = t + } + if d.TimeoutMs > 0 { + h.Timeout = time.Duration(d.TimeoutMs) * time.Millisecond + } + if h.Timeout <= 0 { + return h, fmt.Errorf("hooks: hook %q: timeout must be positive; a hook with no cut can hang its runner", h.Name) + } + switch d.Failure { + case "", "open": + case "closed": + h.FailureClosed = true + default: + return h, fmt.Errorf("hooks: hook %q: failure %q: want \"open\" (the default) or \"closed\"", h.Name, d.Failure) + } + return h, nil +} + +func joinKinds() string { + ks := HookableKinds() + parts := make([]string, len(ks)) + for i, k := range ks { + parts[i] = string(k) + } + return strings.Join(parts, ", ") +} diff --git a/pkgs/mcpx/internal/hooks/hooks_test.go b/pkgs/mcpx/internal/hooks/hooks_test.go new file mode 100644 index 000000000..e2a0ccf5c --- /dev/null +++ b/pkgs/mcpx/internal/hooks/hooks_test.go @@ -0,0 +1,92 @@ +package hooks_test + +import ( + "strings" + "testing" + "time" + + "github.com/dezren39/mcpx/internal/events" + "github.com/dezren39/mcpx/internal/hooks" +) + +func TestParseEmptyDeclaresNothing(t *testing.T) { + hs, err := hooks.Parse("", time.Second) + if err != nil || len(hs) != 0 { + t.Fatalf("empty: got %v, %v", hs, err) + } +} + +func TestParseCommandHookDefaults(t *testing.T) { + hs, err := hooks.Parse(`[{"name":"page","on":"server.stopped","run":["true"]}]`, 3*time.Second) + if err != nil { + t.Fatal(err) + } + if len(hs) != 1 { + t.Fatalf("want 1 hook, got %d", len(hs)) + } + h := hs[0] + if h.Executor != hooks.ExecRun || h.Phase != "observe" || h.FailureClosed { + t.Fatalf("defaults wrong: %+v", h) + } + if h.Timeout != 3*time.Second { + t.Fatalf("timeout should fall back to the daemon default, got %s", h.Timeout) + } +} + +func TestParseRejectsNonObservePhase(t *testing.T) { + _, err := hooks.Parse(`[{"on":"call.finished","phase":"before","run":["true"]}]`, time.Second) + if err == nil || !strings.Contains(err.Error(), "observe") { + t.Fatalf("a blocking phase must be refused, got %v", err) + } +} + +func TestParseRejectsUnhookableKinds(t *testing.T) { + for _, on := range []string{"call.started", "resource.watching", "hook.failed", "nope"} { + _, err := hooks.Parse(`[{"on":"`+on+`","run":["true"]}]`, time.Second) + if err == nil { + t.Fatalf("on=%s should be refused", on) + } + } +} + +func TestParseRejectsTwoActionsAndShellString(t *testing.T) { + if _, err := hooks.Parse(`[{"on":"progress","run":["true"],"module":"x.py"}]`, time.Second); err == nil { + t.Fatal("two actions should be refused") + } + if _, err := hooks.Parse(`[{"on":"progress","run":"echo hi"}]`, time.Second); err == nil { + t.Fatal("a shell string for run should be refused: run is an argv array") + } +} + +func TestParseModuleKindsAndTimeoutOverride(t *testing.T) { + hs, err := hooks.Parse(`[ + {"name":"py","on":["elicit"],"module":"h.py","timeoutMs":250,"failure":"closed"}, + {"name":"ts","on":"*","module":"h.ts"} + ]`, 2*time.Second) + if err != nil { + t.Fatal(err) + } + if hs[0].Executor != hooks.ExecModulePython || hs[0].Timeout != 250*time.Millisecond || !hs[0].FailureClosed { + t.Fatalf("python hook wrong: %+v", hs[0]) + } + if hs[1].Executor != hooks.ExecModuleJS { + t.Fatalf("ts hook wrong: %+v", hs[1]) + } +} + +func TestHookMatchesPrefixOnly(t *testing.T) { + hs, err := hooks.Parse(`[{"on":"elicit","run":["true"],"when":{"server":"chrome"}}]`, time.Second) + if err != nil { + t.Fatal(err) + } + h := hs[0] + if !h.Matches(events.Event{Kind: events.ElicitOpened, Server: "chrome"}) { + t.Fatal("prefix elicit should match elicit.opened for the named server") + } + if h.Matches(events.Event{Kind: events.ElicitOpened, Server: "other"}) { + t.Fatal("when.server should filter") + } + if h.Matches(events.Event{Kind: events.ServerStarted, Server: "chrome"}) { + t.Fatal("elicit should not match server.started") + } +} diff --git a/pkgs/mcpx/internal/hooks/runner.go b/pkgs/mcpx/internal/hooks/runner.go new file mode 100644 index 000000000..d7e09916c --- /dev/null +++ b/pkgs/mcpx/internal/hooks/runner.go @@ -0,0 +1,286 @@ +package hooks + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/dezren39/mcpx/internal/events" +) + +// queueDepth bounds one hook's backlog. A hook that falls behind loses +// events rather than memory: the loss is counted (Stats.Dropped) and +// the next event the hook does receive carries the gap count, which is +// #6's gap contract in miniature -- one explicit marker, never a +// silent hole. +const queueDepth = 32 + +// Stats is one hook's counters since the runner started. Doctor and +// status read these; a hook that is slow or failing is visible here +// and in the hook.failed events the runner publishes. +type Stats struct { + Name string `json:"name"` + Fired uint64 `json:"fired"` + Failed uint64 `json:"failed"` + FailedClosed uint64 `json:"failedClosed"` + TimedOut uint64 `json:"timedOut"` + Dropped uint64 `json:"dropped"` + Slow uint64 `json:"slow"` + LastDurationMs int64 `json:"lastDurationMs"` + LastError string `json:"lastError,omitempty"` +} + +// Runner subscribes to the bus once and fans events out to one serial +// worker per declared hook, so ordering within a hook is promised and +// ordering across hooks is not (decision 0001, delivery semantics). +type Runner struct { + bus *events.Bus + logf func(string, ...any) + hooks []Hook + + mu sync.Mutex + stats map[string]*Stats + subs *events.Subscription + + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup +} + +// NewRunner builds a runner. logf may be nil. +func NewRunner(bus *events.Bus, hs []Hook, logf func(string, ...any)) *Runner { + if logf == nil { + logf = func(string, ...any) {} + } + r := &Runner{bus: bus, logf: logf, hooks: hs, stats: map[string]*Stats{}} + for _, h := range hs { + r.stats[h.Name] = &Stats{Name: h.Name} + } + return r +} + +// Hooks returns the declarations in force. +func (r *Runner) Hooks() []Hook { return r.hooks } + +// Stats returns a snapshot of every hook's counters. +func (r *Runner) Stats() []Stats { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]Stats, 0, len(r.hooks)) + for _, h := range r.hooks { + out = append(out, *r.stats[h.Name]) + } + return out +} + +// Start subscribes and launches the workers. Stop ends them. +func (r *Runner) Start() { + r.ctx, r.cancel = context.WithCancel(context.Background()) + sub, _ := r.bus.Subscribe(events.Filter{}, 0) + r.subs = sub + queues := make(map[string]chan events.Event, len(r.hooks)) + for _, h := range r.hooks { + q := make(chan events.Event, queueDepth) + queues[h.Name] = q + r.wg.Add(1) + go r.work(h, q) + } + r.wg.Add(1) + go func() { + defer r.wg.Done() + for e := range sub.C { + // The runner's own voice is never a hook trigger. + if strings.HasPrefix(string(e.Kind), "hook.") { + continue + } + for _, h := range r.hooks { + if !h.Matches(e) { + continue + } + select { + case queues[h.Name] <- e: + default: + r.mu.Lock() + r.stats[h.Name].Dropped++ + r.mu.Unlock() + } + } + } + }() +} + +// Stop unsubscribes and waits for the workers to drain their current +// execution (each still bounded by its own timeout). +func (r *Runner) Stop() { + if r.subs != nil { + r.subs.Close() + } + if r.cancel != nil { + r.cancel() + } + r.wg.Wait() +} + +func (r *Runner) work(h Hook, q chan events.Event) { + defer r.wg.Done() + var gap uint64 + for { + select { + case <-r.ctx.Done(): + return + case e := <-q: + r.mu.Lock() + gapNow := r.stats[h.Name].Dropped - gap + gap = r.stats[h.Name].Dropped + r.mu.Unlock() + r.execute(h, e, gapNow) + } + } +} + +// payload is the JSON a hook receives on stdin: the event itself, plus +// the runner's delivery facts (which hook, and how many events were +// dropped for it since the last delivery). +type payload struct { + events.Event + Hook string `json:"hook"` + Dropped uint64 `json:"dropped,omitempty"` +} + +// execute runs one hook for one event, under the hook's hard timeout. +// A failure is a result, not an error (the adapter's precedent): it is +// counted, logged, and published as hook.failed; the daemon and the +// operation the hook watched carry on either way. +func (r *Runner) execute(h Hook, e events.Event, dropped uint64) { + body, err := json.Marshal(payload{Event: e, Hook: h.Name, Dropped: dropped}) + if err != nil { + return + } + argv, err := argvFor(h) + if err != nil { + r.recordFailure(h, e, 0, err) + return + } + ctx, cancel := context.WithTimeout(r.ctx, h.Timeout) + defer cancel() + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + cmd.Stdin = bytes.NewReader(body) + cmd.Env = append(os.Environ(), + "MCPX_HOOK_NAME="+h.Name, + "MCPX_HOOK_PHASE=observe", + fmt.Sprintf("MCPX_HOOK_EVENT_KIND=%s", e.Kind), + fmt.Sprintf("MCPX_HOOK_EVENT_SEQ=%d", e.Seq), + "MCPX_HOOK_EVENT_SERVER="+e.Server, + "MCPX_HOOK_EVENT_SESSION="+e.Session, + "MCPX_HOOK_EVENT_URI="+e.URI, + ) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + start := time.Now() + runErr := cmd.Run() + dur := time.Since(start) + + r.mu.Lock() + st := r.stats[h.Name] + st.Fired++ + st.LastDurationMs = dur.Milliseconds() + if dur >= h.Timeout*4/5 && runErr == nil { + st.Slow++ + } + r.mu.Unlock() + + if ctx.Err() == context.DeadlineExceeded { + r.mu.Lock() + r.stats[h.Name].TimedOut++ + r.mu.Unlock() + r.recordFailure(h, e, dur, fmt.Errorf("hook timed out after %s", h.Timeout)) + return + } + if runErr != nil { + r.recordFailure(h, e, dur, fmt.Errorf("%v: %s", runErr, firstLine(out.String()))) + return + } + if st := r.statsFor(h.Name); st.Slow > 0 && dur >= h.Timeout*4/5 { + r.logf("hooks: %s slow on %s: %s of %s budget", h.Name, e.Kind, dur.Round(time.Millisecond), h.Timeout) + } +} + +func (r *Runner) statsFor(name string) Stats { + r.mu.Lock() + defer r.mu.Unlock() + return *r.stats[name] +} + +func (r *Runner) recordFailure(h Hook, e events.Event, dur time.Duration, err error) { + r.mu.Lock() + st := r.stats[h.Name] + st.Failed++ + if h.FailureClosed { + st.FailedClosed++ + } + st.LastError = err.Error() + r.mu.Unlock() + r.logf("hooks: %s failed on %s: %v", h.Name, e.Kind, err) + data, merr := json.Marshal(map[string]any{ + "hook": h.Name, "kind": string(e.Kind), "seq": e.Seq, + "durationMs": dur.Milliseconds(), "error": err.Error(), + "failure": map[bool]string{true: "closed", false: "open"}[h.FailureClosed], + }) + if merr == nil && r.bus != nil { + r.bus.Publish(events.Event{Kind: events.HookFailed, Server: e.Server, Session: e.Session, Data: data}) + } +} + +// argvFor resolves a hook's action to a command line. +// +// run is verbatim: the declaration is the argv, never a shell string. +// A JS module runs under the first JS runtime on PATH, in the order +// the script settings try them (deno, bun, node). A Python module runs +// under python3, or `uv run` when python3 is absent -- Python is a +// module kind here because #1872 names it one, resolved in this one +// place so the choice is made once. +func argvFor(h Hook) ([]string, error) { + switch h.Executor { + case ExecRun: + if len(h.Run) == 0 { + return nil, fmt.Errorf("hook %s: empty run", h.Name) + } + return h.Run, nil + case ExecModulePython: + if p, err := exec.LookPath("python3"); err == nil { + return []string{p, h.Module}, nil + } + if p, err := exec.LookPath("uv"); err == nil { + return []string{p, "run", h.Module}, nil + } + return nil, fmt.Errorf("hook %s: no python3 or uv on PATH for module %s", h.Name, h.Module) + case ExecModuleJS: + for _, rt := range []string{"deno", "bun", "node"} { + p, err := exec.LookPath(rt) + if err != nil { + continue + } + if rt == "deno" { + return []string{p, "run", "-A", h.Module}, nil + } + return []string{p, h.Module}, nil + } + return nil, fmt.Errorf("hook %s: no deno, bun or node on PATH for module %s", h.Name, h.Module) + } + return nil, fmt.Errorf("hook %s: unknown executor %q", h.Name, h.Executor) +} + +func firstLine(s string) string { + s = strings.TrimSpace(s) + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/pkgs/mcpx/internal/hooks/runner_test.go b/pkgs/mcpx/internal/hooks/runner_test.go new file mode 100644 index 000000000..e398064df --- /dev/null +++ b/pkgs/mcpx/internal/hooks/runner_test.go @@ -0,0 +1,127 @@ +package hooks_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/dezren39/mcpx/internal/events" + "github.com/dezren39/mcpx/internal/hooks" +) + +func waitFor(t *testing.T, what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", what) +} + +func TestDeclaredHookFiresOnMatchingKindsOnly(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "fired") + hs, err := hooks.Parse(`[{"name":"rec","on":"server.stopped","run":["sh","-c","cat > `+marker+`"]}]`, 2*time.Second) + if err != nil { + t.Fatal(err) + } + bus := events.New(16) + r := hooks.NewRunner(bus, hs, nil) + r.Start() + defer r.Stop() + + bus.Publish(events.Event{Kind: events.ServerStarted, Server: "x"}) + bus.Publish(events.Event{Kind: events.ServerStopped, Server: "chrome"}) + waitFor(t, "hook to fire", func() bool { + _, err := os.Stat(marker) + return err == nil + }) + body, err := os.ReadFile(marker) + if err != nil { + t.Fatal(err) + } + // The event arrives as JSON on stdin, with its kind and server. + if !strings.Contains(string(body), `"kind":"server.stopped"`) || + !strings.Contains(string(body), `"server":"chrome"`) || + !strings.Contains(string(body), `"hook":"rec"`) { + t.Fatalf("hook stdin payload wrong: %s", body) + } + st := r.Stats() + if len(st) != 1 || st[0].Fired != 1 || st[0].Failed != 0 { + t.Fatalf("stats wrong: %+v", st) + } +} + +func TestPythonModuleHookRuns(t *testing.T) { + dir := t.TempDir() + mod := filepath.Join(dir, "h.py") + out := filepath.Join(dir, "out") + src := "import sys, json\nd = json.load(sys.stdin)\nopen(r'" + out + "', 'w').write(d['kind'])\n" + if err := os.WriteFile(mod, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + hs, err := hooks.Parse(`[{"name":"py","on":"progress","module":"`+mod+`"}]`, 5*time.Second) + if err != nil { + t.Fatal(err) + } + bus := events.New(16) + r := hooks.NewRunner(bus, hs, nil) + r.Start() + defer r.Stop() + bus.Publish(events.Event{Kind: events.Progress}) + waitFor(t, "python hook output", func() bool { + b, err := os.ReadFile(out) + return err == nil && string(b) == "progress" + }) +} + +func TestTimeoutCutsHangingHookAndPublishProceeds(t *testing.T) { + hs, err := hooks.Parse(`[{"name":"hang","on":"progress","run":["sleep","30"],"timeoutMs":100}]`, 2*time.Second) + if err != nil { + t.Fatal(err) + } + bus := events.New(16) + r := hooks.NewRunner(bus, hs, nil) + r.Start() + defer r.Stop() + start := time.Now() + bus.Publish(events.Event{Kind: events.Progress}) + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("publishing must never wait on a hook; took %s", elapsed) + } + waitFor(t, "timeout to be recorded", func() bool { + st := r.Stats() + return len(st) == 1 && st[0].TimedOut == 1 && st[0].Failed == 1 + }) +} + +func TestFailingHookPublishesHookFailedAndIsCounted(t *testing.T) { + hs, err := hooks.Parse(`[{"name":"bad","on":"progress","run":["false"]}]`, 2*time.Second) + if err != nil { + t.Fatal(err) + } + bus := events.New(16) + sub, _ := bus.Subscribe(events.Filter{Kinds: []string{"hook.failed"}}, 0) + defer sub.Close() + r := hooks.NewRunner(bus, hs, nil) + r.Start() + defer r.Stop() + bus.Publish(events.Event{Kind: events.Progress}) + select { + case e := <-sub.C: + if e.Kind != events.HookFailed || !strings.Contains(string(e.Data), `"hook":"bad"`) { + t.Fatalf("hook.failed payload wrong: %s", e.Data) + } + case <-time.After(5 * time.Second): + t.Fatal("no hook.failed event published") + } + waitFor(t, "failure counted", func() bool { + st := r.Stats() + return len(st) == 1 && st[0].Failed == 1 + }) +} diff --git a/pkgs/mcpx/internal/settings/registry.go b/pkgs/mcpx/internal/settings/registry.go index 317c6892e..cdbfdf833 100644 --- a/pkgs/mcpx/internal/settings/registry.go +++ b/pkgs/mcpx/internal/settings/registry.go @@ -20,6 +20,7 @@ func Registry() []Setting { s = append(s, execSettings()...) s = append(s, pathSettings()...) s = append(s, daemonSettings()...) + s = append(s, hookSettings()...) s = append(s, outputSettings()...) s = append(s, protoSettings()...) s = append(s, transportSettings()...) @@ -338,6 +339,48 @@ func scriptSettings() []Setting { } } +// hookSettings are the config-declared hooks of docs/decisions/0001: +// one structured `hooks` setting, in the same JSON-in-a-string shape as +// script.runtimes and presets, because the registry has no structured +// kind and those two are the precedent for one that needs it. +func hookSettings() []Setting { + return []Setting{ + { + Path: "hooks", Kind: KindString, Default: "", + Scope: ScopeDaemon, + Commands: []string{"daemon", "status", "restart", "hook", "doctor"}, + Name: "Declared hooks", + Short: "config-declared hooks over the event bus: a JSON array of hook objects", + Long: "A JSON array. Each entry is one hook: {name, on, run | module, " + + "when, timeoutMs, failure}. `on` is an events.Filter kind prefix " + + "verbatim -- \"elicit\" matches every elicitation event, \"*\" " + + "matches everything hookable; resource.watching is never on the " + + "bus and hook.* is the runner's own, and both are refused. `run` " + + "is an argv array, never a shell string -- write [\"sh\", \"-c\", " + + "...\"] to ask for a shell. `module` is a file run for its kind: " + + ".py by python3 (or uv run), .ts/.js/.mjs by deno, bun or node. " + + "Every hook is phase observe: it runs after the fact, off the " + + "bus, under a hard timeout, and can never slow, veto or rewrite " + + "what it watched. The event arrives as JSON on stdin, with " + + "MCPX_HOOK_* variables for the flat fields. A non-zero exit is a " + + "result, not an error: it is counted, `mcpx hook ls` and doctor " + + "report it, and a hook.failed event is published. \"failure\": " + + "\"closed\" marks a hook whose failures are guard-shaped; the " + + "default is \"open\".", + }, + { + Path: "hooks.timeout", Kind: KindDuration, Default: "2s", + Scope: ScopeDaemon, + Commands: []string{"daemon", "status", "restart", "hook", "doctor"}, + Name: "Hook timeout", + Short: "the hard timeout for a declared hook that does not set its own", + Long: "Low on purpose: a hook is an observer, and an observer that " + + "needs longer than this is a job, not a hook. A hook may set its " + + "own timeoutMs; this is the cut for the ones that do not.", + }, + } +} + func pathSettings() []Setting { spliceLong := "A list. A null entry stands for whatever the layer below provided, " + "so [\"./mine\", null] searches yours first and then the usual places. " + diff --git a/pkgs/mcpx/internal/settings/scriptenv.go b/pkgs/mcpx/internal/settings/scriptenv.go index 95b91bf43..742265304 100644 --- a/pkgs/mcpx/internal/settings/scriptenv.go +++ b/pkgs/mcpx/internal/settings/scriptenv.go @@ -34,6 +34,9 @@ const ( // SetByPlugin is the opencode plugin, into the environment of every // shell command the editor runs. SetByPlugin Setter = "plugin" + // SetByHooks is the declared-hook runner (internal/hooks), into the + // environment of every hook process it starts for an event. + SetByHooks Setter = "hooks" // SetByPluginLoader is the plugin loader (#1870), into the environment // of every plugin process it hands the pool: the back-channel a plugin // uses to call /v1. Not SetByPlugin -- that is the opencode plugin, a @@ -56,6 +59,8 @@ func (s Setter) Describe() string { return "the opencode plugin" case SetByPluginLoader: return "the plugin loader, for a discovered plugin" + case SetByHooks: + return "the declared-hook runner, for a hook it runs" } return string(s) } @@ -129,6 +134,52 @@ func ScriptEnv() []EnvVar { "asks one exits 75 with the question instead of waiting for it to expire", ReadBy: client + ", sent as X-Mcpx-Input", }, + // ---- the event a hook is running for ---- + { + Name: "MCPX_HOOK_NAME", + SetBy: []Setter{SetByHooks}, + Meaning: "the declared name of the hook being run", + ReadBy: "the hook itself", + }, + { + Name: "MCPX_HOOK_PHASE", + SetBy: []Setter{SetByHooks}, + Meaning: "the hook's phase; always observe for config-declared hooks", + ReadBy: "the hook itself", + }, + { + Name: "MCPX_HOOK_EVENT_KIND", + SetBy: []Setter{SetByHooks}, + Meaning: "the kind of the event the hook is running for; the full event is JSON on stdin", + ReadBy: "the hook itself", + }, + { + Name: "MCPX_HOOK_EVENT_SEQ", + SetBy: []Setter{SetByHooks}, + Meaning: "the event's sequence number on the daemon's bus", + ReadBy: "the hook itself", + }, + { + Name: "MCPX_HOOK_EVENT_SERVER", + SetBy: []Setter{SetByHooks}, + When: "empty when the event names no server", + Meaning: "the server the event is about", + ReadBy: "the hook itself", + }, + { + Name: "MCPX_HOOK_EVENT_SESSION", + SetBy: []Setter{SetByHooks}, + When: "empty when the event belongs to no session", + Meaning: "the session the event belongs to", + ReadBy: "the hook itself", + }, + { + Name: "MCPX_HOOK_EVENT_URI", + SetBy: []Setter{SetByHooks}, + When: "empty when the event names no resource", + Meaning: "the resource URI the event is about", + ReadBy: "the hook itself", + }, // ---- reaching the daemon ---- { Name: "MCPX_ENDPOINT", diff --git a/pkgs/mcpx/internal/settings/scriptenv_test.go b/pkgs/mcpx/internal/settings/scriptenv_test.go index f6f68c95a..6bee5276d 100644 --- a/pkgs/mcpx/internal/settings/scriptenv_test.go +++ b/pkgs/mcpx/internal/settings/scriptenv_test.go @@ -21,6 +21,7 @@ var setterSources = map[settings.Setter]string{ settings.SetByRunner: "internal/runner/", settings.SetByPlugin: "plugin/opencode/", settings.SetByPluginLoader: "internal/plugin/", + settings.SetByHooks: "internal/hooks/", } func setterOf(file string) (settings.Setter, bool) {