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.