Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pkgs/mcpx/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions pkgs/mcpx/docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
3 changes: 2 additions & 1 deletion pkgs/mcpx/docs/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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` | |
Expand Down
1 change: 1 addition & 0 deletions pkgs/mcpx/internal/cli/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions pkgs/mcpx/internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"

"github.com/dezren39/mcpx/internal/config"
"github.com/dezren39/mcpx/internal/hooks"
"github.com/dezren39/mcpx/internal/opencode"
)

Expand Down Expand Up @@ -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,
Expand Down
94 changes: 94 additions & 0 deletions pkgs/mcpx/internal/cli/hook.go
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
dezren39 marked this conversation as resolved.
Resolved
}
13 changes: 13 additions & 0 deletions pkgs/mcpx/internal/cli/manual.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "[<ns>.<name> key=value...]", Summary: "list or render a server's prompts",
Expand Down
2 changes: 1 addition & 1 deletion pkgs/mcpx/internal/daemon/consumer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
}
}
Expand Down
22 changes: 22 additions & 0 deletions pkgs/mcpx/internal/daemon/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 }

Expand Down
10 changes: 10 additions & 0 deletions pkgs/mcpx/internal/events/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading