Rand Stats

MCP::Server::Tool::Shell

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

0.4.3  2026-10-24T20:12:40+01:00
    - [2026-10-24] BUGFIX: t/15 failed on Linux. Its Windows-rules tests put
      wintool.exe on PATH with a PATHEXT of ".EXE", as a Windows system
      usually has them, and the search asked the filesystem for
      "wintool.EXE", which a case-sensitive filesystem does not have; a
      macOS filesystem ignores case, so the tests passed there. Windows
      matches a name on PATH without regard to case, and the search made
      under its rules now does too, in two passes. Each name is asked for
      exactly as it is spelt first, which on Windows' own filesystems is
      already the whole answer; only when nothing answers is each PATH
      directory read and its entries compared case-folded. The same
      environment now gives the same answer on every host. t/15 pins it:
      the extension, the name, and the PATH and PATHEXT spellings each
      differ in case, and the near misses (another extension, a prefix, a
      longer name, a directory, a PATH entry that is missing or is a file)
      must not match.
    - [2026-10-24] BUGFIX: t/11 failed on a host with no perl, Alpine for one.
      Its fixture for "runs, but does not detach" was /usr/bin/perl, so the
      refusal it checked for became one about the missing perl. The fixture
      is now the running raku, which every host has.

0.4.2  2026-10-24T18:52:13+01:00
    - [2026-10-24] BUGFIX: on Rakudo 2026.09 under macOS, the pack leaked a
      file descriptor every time it asked which platform it was on -- once
      per PATH entry for every command line it refused, and once for every
      environment lookup, isolation lookup and kill -- so a long-running
      host at macOS's default limit of 256 descriptors could soon spawn
      nothing at all. That Rakudo builds $*DISTRO afresh on every read, and
      on macOS each build leaves a pipe open. The pack now keeps the first
      one it reads for the rest of the process, as every other Rakudo does;
      a stand-in $*DISTRO a test declares still wins, and one in force while
      the pack loads is never kept. t/17 counts the process's handles around
      a few hundred of each; t/18 pins the stand-in rule.
    - [2026-10-01] BUGFIX: on Windows the command-shape check finds programs
      on the real search path. It read %*ENV, but a Windows process is
      handed PATH spelt "Path" and %*ENV is case-sensitive, so it searched
      nothing and a bare name with an argument stuck to it ("git status"
      as the command) was never recognised to correct. PATH and PATHEXT are
      now read without regard to case on Windows; POSIX names stay exact.
      An environment carrying both spellings at once -- an MSYS or Cygwin
      parent hands over PATH and Path -- gets one fixed answer rather than
      whichever key the hash iterated first: the exact spelling asked for,
      else the first case-insensitive match in sorted order. t/15 pins it.
      t/15 wrote %*ENV too, adding a second entry beside the real one,
      so which one a child searched depended on hash order and its bare-name
      test passed or failed by chance. It writes to the spelling that is
      there, and pins the "Path" spelling under the faked Windows rules on
      every host.
    - [2026-09-28] Requires JSON::Fast 0.21.0 or later (zef:timo). 0.19
      (cpan:TIMOTIMO) fails its own tests under the RakuAST compiler
      frontend that Rakudo 2026.09 makes the default, so installing this
      distribution stopped at that dependency.
    - [2026-09-19] "args" and "run"'s "timeout" now publish a JSON Schema
      default, so the contract the model reads says what omitting them means.
      "args" defaults to [] -- an empty argv vector, a command run with no
      arguments at all -- which is worth saying out loud, because a model that
      omits it gets a call that works and does the wrong thing rather than an
      error that teaches it. "run"'s "timeout" defaults to this toolkit's own
      .timeout, so a kit built with timeout => 5 publishes 5; "start" still
      publishes none, because a background job that names no timeout runs
      until it finishes or is killed. "cwd", "stdin" and "output"'s "cursor"
      publish none either, and for reasons now written down in the Pod. No
      handler changed, and the new t/16-schema-contract.rakutest pins each
      claim against the handler rather than against the registration.
      (The defaults reach the wire with the MCP::Server release that
      carries a param spec's `default` into the emitted schema; against an
      older one they are simply not published.)
    - [2026-09-19] "command" is now one executable and nothing else: a command
      line in that argument is refused before anything is spawned, with an
      error that names both fields and shows the corrected call --
      {"command": "prove6", "args": ["--help"]} for a caller who sent
      {"command": "prove6 --help"}. Spawned as the argv vector this pack
      promises, that call asked for a program with a space and two dashes in
      its name, and the failure it got back ("could not run 'prove6 --help':
      No such file or directory") reads exactly like a missing binary: one
      model took it as one, spent twenty-six calls hunting for binaries that
      were on PATH the whole time, and never recovered, because nothing ever
      told it what it had got wrong.
      The rule: a command is refused when it does not name an existing
      executable AND either it contains a shell metacharacter (| & ; < > ( )
      $ ` newline, carriage return) or it contains whitespace and its first
      word does name an executable -- the case where the correction can be
      made exactly, by name. Resolution comes first in both arms, so a path
      that legitimately contains a space or parentheses still runs; the
      second arm asks for the buried program by name, so a spacey path to
      something that is not there is still the ordinary exit -1 spawn failure
      rather than a shape error. Resolution is the platform's own: a path
      separator (slash, or a backslash or drive letter on Windows) means a
      path, anything else is looked for on PATH, with PATHEXT appended and no
      execute bit asked for on Windows. Nothing is ever auto-split and run:
      that would put back the quoting ambiguity an argv vector exists to
      remove, and a client-side policy's argument-aware rules match on argv,
      so a shell string smuggled through as one command would slip past them.
    - [2026-09-19] the "command" and "args" schema descriptions, and the "run"
      and "start" tool descriptions, now state the contract where a model
      reads it while filling the field: command is the name or path of ONE
      executable, arguments go in args, and a command line is refused. The
      old "Command to run. Any executable the server user can run is allowed."
      invited exactly the call above.
    - MAINT: [2026-08-27] moved the shared test fixtures from
      C into
      C
      (C). A C directory
      is its own CompUnit repo that starts cold on a fresh C, and parallel test files pulling the fixture's
      dependency chain into that cold store can race and fail the
      install outright. C is staged and precompiled before the
      test phase runs, so shipping test support inside the dist's own
      namespace dissolves the race. This is the fleet-wide convention
      going forward.

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.