Revision history for MCP::Server::Tool::Shell

0.4.1  2026-08-21T22:04:14+01:00
    - Fix every kill on Windows dying with "expected Callable but got Hash":
      the taskkill branch of kill-process-tree tapped the reaper's output with
      a bare {} — an empty Hash composer, not a Block — so the type check
      failed before anything was signalled. A killed job stayed "running",
      its slot was never freed, and a foreground timeout came back as an
      error result instead of a timeout payload (caught by Windows CI; POSIX
      never runs the branch). The new regression test fakes $*DISTRO so the
      Windows branch runs on every platform.

0.4.0  2026-08-21T21:42:28+01:00
    - The three introspection tools (allowed, poll, output) now declare the MCP
      annotations readOnlyHint and idempotentHint: none of them starts, stops
      or advances anything -- an output cursor is a position the caller holds,
      and reading from one leaves the buffer as it was -- and the job registry
      and output buffers carry their own locks, so a host may run a batch of
      them side by side. run, start and kill declare nothing, deliberately: a
      command is whatever the caller typed, start mints a job and kill ends
      one.
    - Background jobs now emit a structured "notifications/job" alongside the
      prose lifecycle lines, on the same channel: params { job, state, exit,
      runtime, command, logger }, with state "started", "exited" or "killed",
      exit absent (rather than null) when no status ever arrived, and command a
      120-character excerpt of the job's own record of what it was asked to
      run. Exactly one start and one terminal event per job; a job that could
      not be spawned emits only the terminal one, from inside the "start" call.
      The prose lines are unchanged, so a client reading those reads exactly
      what it always did.
      Nothing the command writes is ever in a lifecycle event -- not its
      output, not its arguments. These events are what a host is most likely to
      render as trustworthy text, so a command that prints its own framing must
      not get a hearing that way; output has the debug-level pushes and the
      "output" tool.
    - Terminal isolation: spawned commands are kept away from the terminal of
      the process hosting the pack.  The pack can be plugged in-process (a
      terminal application hosting its own tool server), where children would
      otherwise inherit the host's controlling terminal — and one child that
      restores cooked mode on exit is enough to leave the host drawing
      perfectly and unable to read another keystroke.  Two mechanisms, applied
      at BOTH spawn sites (foreground "run" and background "start"):
      a run given no "stdin" argument now gets a closed pipe on fd 0 rather
      than the host's standard input (every platform — in stdio server mode
      that also stops a child eating the client's own JSON-RPC frames), and on
      POSIX each child is put in a session of its own, so /dev/tty cannot reach
      the host's terminal at all.
    - The new session is made by a trampoline that calls setsid(2) and then
      execs the command in the same process: perl by preference (present on
      macOS and mainstream Linux, and the pack supplies the trampoline text, so
      a command that cannot be exec'd still reports exit -1 with the reason on
      stderr exactly as it always did), falling back to setsid(1) on images
      without perl.  The mechanism is resolved and VERIFIED once per process;
      a toolkit that asks for isolation and cannot get it fails to construct
      rather than starting quietly unprotected.
    - Unix kills now take the process tree.  A child in its own session leads
      its own process group, so a timeout, a cancel or the "kill" tool signals
      the group: grandchildren the command spawned no longer outlive it (and no
      longer hold its pipes open long enough to lose the exit status).  This
      closes the Unix half of what the previous release fixed for Windows.
    - New "isolate-terminal" configuration key (default True, also settable
      from a JSON config): turning it off restores the previous behaviour
      exactly — children inherit standard input and the controlling terminal —
      for a host with no terminal to protect, or a machine that can provide no
      mechanism.
    - New unit MCP::Server::Tool::Shell::Isolation, documenting the hazard and
      the mechanism, and exporting the seams the pack uses.
    - Fixed: the Windows tree-kill added in 0.3.0 passed Proc::Async.pid — a
      Promise — to taskkill, so it stringified a Promise instead of a pid and
      the tree was never walked.  Pids are now read (without blocking) through
      a helper both kill paths share.

0.3.0
    - New "launcher" attribute: a duck-typed Callable given the validated plan
      { command, argv, dir } just before the process is built, returning the
      wrapped { command, argv } to exec in its place — the seam a host uses to
      wrap a run in an OS sandbox (sandbox-exec, bwrap).  It wraps BOTH spawn
      sites (foreground "run" and background "start"); an undefined launcher is
      byte-for-byte the old behaviour; a launcher that throws (or returns an
      unspawnable shape) is a failed spawn (exit -1, reason in stderr), never a
      crash and never a fallback to the unwrapped command.  Programmatic only —
      from-config does not offer it as a key.
    - A launcher may return "sandboxed => True", which surfaces as
      "sandbox => { active => true }" on the result (foreground "run", and a
      job's "output"), so a host can tell a sandbox denial from an ordinary
      non-zero exit.  Absent when there is no launcher.
    - Windows: a kill (timeout, cancel, or the "kill" tool) now takes the whole
      process tree via "taskkill /T /F /PID", so grandchildren no longer
      outlive it.  Unix behaviour is unchanged (SIGKILL to the direct child).

0.2.0  2026-08-09T20:02:19+01:00
    - Background jobs: new "start", "poll", "output" and "kill" tools run a
      command that outlives the tool call which started it.  Jobs are capped
      (max-jobs, default 4), remembered after they finish (max-finished-jobs,
      default 16) and eventually forgotten (job-ttl, default 600s); "output"
      takes an opaque cursor for incremental reads, or none at all for a
      capped snapshot of the whole run.
    - Output is now bounded.  Each stream of each run keeps a head and a
      rolling tail (max-output-bytes, default 128 KiB; max-output-lines,
      default 2000) with a "[... N bytes / M lines dropped ...]" marker where
      the middle went and a "truncated" key reporting the same counts.  A run
      that stays inside its budget is byte-for-byte what it always was, and
      its payload still has exactly the four keys it always had.
    - Standard input: "run" and "start" take an optional "stdin" string, fed
      to the command and then closed.  Omitting it leaves standard input
      unconnected, exactly as before; an empty string is an immediate EOF.
      The write is detached, so a command that never reads cannot stall the
      server.
    - Live progress: a 2026-07-28 request that opted into logging (_meta
      logLevel) gets the command's output as notifications/message while it
      runs, batched at 32 KiB and flushed from the handler thread.  Background
      jobs push through MCP::Server.notify instead, which reaches a legacy
      stdio session's transport-wide channel; on Streamable HTTP there is no
      server-initiated stream between requests, so "poll" and "output" are the
      reliable way to follow a job there.
    - "run" takes an optional per-call "timeout" that overrides the server
      default; "start" takes an optional one with no default.
    - A cancelled request (a caller that hung up) now kills the command and
      unwinds instead of running to the end of its budget: the run loop wakes
      at least every 0.25s to flush, check the deadline and check for
      cancellation.  Jobs deliberately do not observe cancellation.
    - New "allow-any" configuration key for deployments that gate consent
      elsewhere (an MCP::Client::Policy in front of the tools, say).  It is
      mutually exclusive with "allow", and one of the two is required.
    - New MCP::Server::Tool::Shell::Buffer (capped capture buffer with cursor
      reads) and MCP::Server::Tool::Shell::Jobs (Job + Registry), both
      documented and usable on their own.
    - Requires MCP::Server 0.4.0 or newer, for MCP::Server.notify.

0.1.0  2026-07-29T00:00:00+01:00
    - Initial release
    - MCP::Server::Toolkit pack providing an allowlisted command runner:
      "run" executes one of the exact command strings in the allow list and
      returns JSON with exit, stdout, stderr and timed-out; "allowed" lists
      the allow list.
    - Commands are spawned via Proc::Async as an argv vector, never through a
      shell, so no quoting, globbing, redirection or interpolation happens.
    - Configurable allow list (required), default working directory and
      per-run timeout, all validated at construction time.
    - Runs are killed with SIGKILL once the timeout expires and reported with
      timed-out: true; unspawnable commands report exit -1 with the reason on
      stderr instead of failing the call.
    - Default prefix "sh", so tools register as sh_run and sh_allowed.
