=begin pod

=head1 MCP::Server::Tool::Shell

A command runner for L<MCP::Server|https://github.com/m-doughty/MCP-Server>.

Six tools — C<run>, C<start>, C<poll>, C<output>, C<kill> and C<allowed> — let
an LLM invoke local programs. Which programs is your decision: either an exact
allow list, or C<allow-any> for deployments that ask a human instead. Arguments
are handed to the program as an argv vector (there is no shell anywhere in the
code path), output is captured into capped buffers so a runaway command cannot
exhaust the server's memory, foreground runs are bounded by a timeout, and work
that will not fit in one tool call can be started as a background job and polled.

=head2 Synopsis

Plug it into a server you are already building:

=begin code :lang<raku>
use MCP::Server;
use MCP::Server::Tool::Shell;

my $server = MCP::Server.new(:name<dev-tools>, :version<1.0>);

$server.plug: MCP::Server::Tool::Shell.new(
    allow   => <git rg /usr/bin/jq>,   # exact argv0 strings
    cwd     => '/home/me/project',     # optional default working directory
    timeout => 15,                     # seconds, default 30
);

$server.run;   # registers sh_run, sh_start, sh_poll, sh_output, sh_kill, sh_allowed
=end code

Or assemble a server out of toolkit packs in one shot, with no glue code:

=begin code :lang<raku>
use MCP::Server;

MCP::Server.new(
    :name<dev-tools>,
    :tools[
        'Shell' => { allow => <git rg>, cwd => '/srv/app', prefix => 'git' },
    ],
).run;   # registers git_run, git_start, ...
=end code

Or run it straight off the C<raku-mcp> command line that ships with
C<MCP::Server>:

=begin code :lang<bash>
raku-mcp --tool=Shell='{"allow":["git","rg"],"timeout":10}'
=end code

=begin code :lang<bash>
raku-mcp --config=mcp.json
=end code

=begin code :lang<json>
{
  "name": "dev-tools",
  "instructions": "Read-only git and ripgrep access to the checkout.",
  "tools": {
    "Shell#git": { "allow": ["git"], "cwd": "/srv/app", "prefix": "git" },
    "Shell#search": { "allow": ["rg"], "cwd": "/srv/app", "prefix": "rg", "timeout": 10 }
  }
}
=end code

The C<#alias> suffix lets the same pack be loaded twice with different settings —
here one instance can only run C<git> and the other only C<rg>, each under its
own prefix.

Wiring that into Claude Code:

=begin code :lang<json>
{
  "mcpServers": {
    "dev-tools": {
      "command": "raku-mcp",
      "args": ["--config=/home/me/mcp.json"]
    }
  }
}
=end code

=head2 Configuration

=begin table
Key               | Type            | Default  | Meaning
==================|=================|==========|=========================================
allow             | Array of Str    | required | Exact argv0 strings that may be executed
allow-any         | Bool            | False    | Run anything, with no allow list at all
cwd               | Str (directory) | none     | Default working directory for every run
isolate-terminal  | Bool            | True     | Keep children away from the host's terminal
timeout           | Real (seconds)  | 30       | Wall-clock budget for a foreground run
max-output-bytes  | Int (bytes)     | 131072   | Retained output per stream, per run
max-output-lines  | Int (lines)     | 2000     | Retained lines per stream, per run
max-jobs          | Int             | 4        | Background jobs that may run at once
max-finished-jobs | Int             | 16       | Finished jobs that stay pollable
job-ttl           | Real (seconds)  | 600      | How long a finished job stays pollable
=end table

=item B<allow> must contain at least one non-empty string unless B<allow-any> is
set. Entries are matched with C<eq> — see L<#Security>.

=item B<allow-any> and B<allow> are mutually exclusive, and one of them is
required: a runner that can run everything and a runner that can run nothing are
both configurations somebody meant, but "I forgot to say" is not. See
L<#Running anything: allow-any and MCP::Client::Policy>.

=item B<cwd> must be an existing directory at construction time; the constructor
dies otherwise. A per-call C<cwd> argument overrides it.

=item B<isolate-terminal> keeps spawned commands away from the terminal this
process is attached to, and is on unless you turn it off. Leave it on unless
you know the process has no terminal worth protecting — see
L<#Terminal isolation>, which is also where the "it refuses to construct"
failure mode is explained.

=item B<timeout> must be greater than zero. Fractional values are fine
(C<0.5> is half a second). A per-call C<timeout> argument overrides it for one
C<run>; C<start> takes one too, but has no default.

=item B<max-output-bytes> (floor 1024) and B<max-output-lines> (floor 10) apply
per stream, so a run may hold twice them at most. See L<#Truncation>.

=item B<max-jobs>, B<max-finished-jobs> (both at least 1) and B<job-ttl>
(greater than zero) govern background jobs. See L<#Background jobs>.

=item B<prefix> is I<not> a setting of this pack: it is reserved by the toolkit
system. Pass it to C<.plug($kit, :prefix<git>)>, or beside the entry in
C<:tools[...]> / the JSON config, to namespace the tool names.

Every setting is validated when the toolkit is constructed, so a typo or a
missing directory fails at startup rather than on the first tool call:

=begin code :lang<raku>
MCP::Server::Tool::Shell.new(allow => []);
# Shell toolkit requires a non-empty allow list (or allow-any => True when
# consent is enforced elsewhere, e.g. by an MCP::Client::Policy)

MCP::Server::Tool::Shell.new(allow => <git>, allow-any => True);
# Shell toolkit allow and allow-any are mutually exclusive: ...

MCP::Server::Tool::Shell.new(allow => <git>, cwd => '/nope');
# Shell toolkit cwd is not an existing directory: /nope

MCP::Server::Tool::Shell.from-config({ allow => <git>, timeut => 5 });
# Unknown config key(s) for MCP::Server::Tool::Shell: timeut.
# Valid keys: allow, allow-any, cwd, isolate-terminal, job-ttl,
# max-finished-jobs, max-jobs, max-output-bytes, max-output-lines, timeout
=end code

The default prefix is C<sh>, so the tools below are registered as C<sh_run>,
C<sh_start>, C<sh_poll>, C<sh_output>, C<sh_kill> and C<sh_allowed> unless you
pass a C<prefix> of your own.

=head2 Tools

=head3 Which of them may run at once

C<allowed>, C<poll> and C<output> declare the MCP annotations
C<< readOnlyHint => True >> and C<< idempotentHint => True >>; C<run>,
C<start> and C<kill> declare nothing.

A host reads those hints as a licence to run a batch of the annotated calls
side by side, and to execute identical ones in a batch once. It holds here:
C<allowed> reads a list this instance was built with, and C<poll> and
C<output> read a job's state and a window of what it has B<already> written —
an C<output> cursor is a position the caller holds, so reading from one leaves
the buffer exactly as it was and the same call twice is the same answer twice.
The job registry and every job's buffers take their own locks, so several of
these at once see coherent state rather than a half-written flush.

The other three could not be annotated: a command is whatever the caller
typed and this pack cannot know whether it writes, C<start> mints a job, and
C<kill> ends one.

=head3 run

Runs one command in the foreground and waits for it.

=begin table
Argument | Type            | Required | Meaning
=========|=================|==========|===========================================
command  | string          | yes      | Must be `eq` an entry of the allow list
args     | array of string | no       | One array entry per argument
cwd      | string          | no       | Working directory, overriding the default
stdin    | string          | no       | Text to write to standard input, then close
timeout  | number          | no       | Seconds, overriding the server default
=end table

The result is a JSON object as text content:

=begin code :lang<json>
{"exit":0,"stderr":"","stdout":"main\n","timed-out":false}
=end code

=begin table
Field      | Type    | Meaning
===========|=========|=====================================================
exit       | Int     | Process exit status; see below
stdout     | Str     | What the command wrote to standard output, capped
stderr     | Str     | What the command wrote to standard error, capped
timed-out  | Bool    | True when the command was killed for exceeding the budget
truncated  | Object  | Only present when output was dropped; see L<#Truncation>
=end table

C<exit> reports:

=item the command's own exit status for a normal exit (C<0> on success);
=item C<128 + signal> when the command was terminated by a signal, following
the POSIX shell convention — a timeout kill on Unix therefore shows up as
C<137> (128 + C<SIGKILL>), while on Windows C<TerminateProcess> yields C<1>;
=item C<-1> when the command could not be spawned at all (not on C<PATH>, not
executable, and so on). The reason is appended to C<stderr>;
=item C<null> in the one case where there is no honest answer: the command was
killed but its status never arrived (a grandchild is still holding the pipes,
say), so the run is reported with C<timed-out: true> and an unknown status.

A missing binary is a runtime condition, not a caller mistake, so it is reported
in the payload:

=begin code :lang<json>
{"exit":-1,"stderr":"Failed to spawn process nosuchtool: no such file or directory (error code -2)","stdout":"","timed-out":false}
=end code

Anything the caller got wrong — a command that is not allowlisted, an C<args>
value that is not an array of strings, a C<cwd> that does not exist, a
C<timeout> that is not a positive number — is thrown instead, which
C<MCP::Server> turns into an C<isError> tool result:

=begin code
Command 'rm' is not allowlisted. Allowed: git, rg
=end code

Examples of the call shape (arguments as an MCP client would send them):

=begin code :lang<json>
{ "command": "git", "args": ["rev-parse", "--abbrev-ref", "HEAD"] }
{ "command": "rg", "args": ["--json", "TODO|FIXME", "lib"], "cwd": "/srv/app" }
{ "command": "git", "args": ["log", "--grep", "fix the thing"] }
{ "command": "jq", "args": ["-r", ".name"], "stdin": "{\"name\":\"widget\"}" }
=end code

That third one is the point of an argv vector: C<fix the thing> is a single
argument, quoted by nobody, split by nobody.

=head3 Standard input

C<stdin> is written to the command and the pipe is then closed, so a filter
sees a normal end of input. Three states:

=item B<omitted> — nothing is written, and the command reads an immediate end of
file. What it does I<not> get is the server process's own standard input: with
C<isolate-terminal> on (the default) C<fd 0> is a closed pipe rather than the
host's terminal or the client's transport. See L<#Terminal isolation>.
=item B<empty string> — the pipe is opened and closed immediately, so the
command reads an immediate EOF. This is what you want for a filter you have
nothing to say to.
=item B<a string> — written and then closed.

The first two therefore look the same to the command and differ only in intent —
unless you have turned isolation off, in which case an omitted C<stdin> leaves
C<fd 0> inherited from the server process, as it was before isolation existed.

The write is detached from the run: a command that never reads its input cannot
stall the server, however much you send it, and a command that exits before
reading everything just makes the write fail (C<EPIPE>), which is a runtime
condition rather than an error. NUL bytes are fine here, unlike in C<args>: this
is a byte stream, not a C string.

=head3 Truncation

Output is captured into a bounded buffer per stream, so a command that decides
to print a gigabyte does not make the server hold a gigabyte. Each buffer keeps
a B<head> — the first quarter of the budget, which is where a compiler puts the
error that matters — and a rolling B<tail> — the remaining three quarters, which
is where a test runner puts the summary. What falls between them is dropped, and
the payload says so twice: in the text, and in a report.

=begin code :lang<json>
{
  "exit": 0,
  "stdout": "001 ...\n002 ...\n[... 7176 bytes / 175 lines dropped ...]\n199 ...\n",
  "stderr": "",
  "timed-out": false,
  "truncated": { "stdout": { "bytes": 7176, "lines": 175 } }
}
=end code

=item The C<truncated> key is B<only present when something was actually
dropped>, and only names the streams that dropped something. A run inside its
budget has exactly the four keys it has always had, byte for byte.
=item The numbers in the marker and the numbers in C<truncated> are the same
numbers: both come out of one atomic read of the buffer.
=item Cuts land on character boundaries, so a multi-byte character is never
sliced in half.
=item C<max-output-lines> is enforced alongside C<max-output-bytes>; whichever
runs out first decides.

The buffer is L<MCP::Server::Tool::Shell::Buffer|Buffer> and is usable on its
own if you want the same behaviour elsewhere.

=head3 Streaming a run in progress

A 2026-07-28 (modern era) request that opts into logging — C<_meta>
C<io.modelcontextprotocol/logLevel> of C<info> or lower — gets the command's
output as C<notifications/message> on that request's own channel while the
command is still running:

=begin code :lang<json>
{
  "jsonrpc": "2.0",
  "method": "notifications/message",
  "params": {
    "level": "info",
    "logger": "sh_run",
    "data": { "stream": "stdout", "text": "compiling lib/Thing.rakumod\n" }
  }
}
=end code

=item C<logger> is the prefixed tool name, so a host that plugged several
instances can tell them apart.
=item Batches are flushed at least every 0.25s and are never larger than 32 KiB;
a bigger burst becomes several notifications, in order.
=item For a run that finished on its own, concatenating every C<stdout> batch
gives exactly the C<stdout> of the final payload (when nothing was truncated):
the last flush happens once the command's streams have closed. A run that was
killed may have written a little more than it managed to stream.
=item Nothing is streamed for a request that did not opt in, and nothing at all
is streamed on the legacy era, where notifications travel on one channel shared
by the whole session and a run's output would surface inside somebody else's
request. Use a background job for progress on legacy clients.

If the caller hangs up mid-run (an HTTP client closing its response stream), the
run notices within 0.25s, kills the command and unwinds. The answer it produces
is a dead letter nobody reads, and is not marked C<timed-out>: the budget was
never reached.

=head2 Background jobs

C<run> has to answer inside one tool call, which puts a ceiling on what it can
be asked to do. C<start> escapes that ceiling: it spawns the command, returns a
handle, and answers immediately.

=begin code :lang<json>
{ "command": "make", "args": ["-j8", "test"] }
→ {"job":"j1-1x9k2w","state":"running"}
=end code

=head3 start

Takes exactly the same arguments as C<run> (C<command>, C<args>, C<cwd>,
C<stdin>, C<timeout>) and returns C<< { job, state } >>. Its C<timeout> is
optional and has B<no default>: escaping the foreground budget is the point.
Starting a job when C<max-jobs> are already running is a caller error, and the
message names the jobs in the way.

=head3 poll

Takes C<job> and reports how it is getting on, without returning the output:

=begin code :lang<json>
{"exit":null,"job":"j1-1x9k2w","killed":false,"runtime":12.004,
 "started-at":"2026-08-09T09:15:03Z","state":"running",
 "stderr-bytes":0,"stdout-bytes":81920,"timed-out":false}
=end code

C<state> is C<running>, C<exited> or C<killed>; C<exit> is C<null> until there
is a status; C<stdout-bytes> and C<stderr-bytes> count everything the command
has produced, including whatever the buffers have since dropped.

=head3 output

Takes C<job> and an optional C<cursor>, and returns
C<< { job, state, stdout, stderr, cursor, eof } >> plus C<truncated> when
something was dropped.

=item B<Without a cursor> you get a capped snapshot of the whole run so far —
head, marker, tail — exactly as C<run> would have rendered it.
=item B<With the cursor from a previous call> you get only what has been written
since, so a long job can be followed a piece at a time. Concatenating the
instalments reconstructs the output, with a
C<[... N bytes / M lines dropped ...]> marker wherever the reader fell so far
behind that the buffer rolled past it.
=item The cursor is B<opaque>: it carries a position in both streams, in bytes
and in lines. Pass it back exactly as it was given; anything else is a caller
error.
=item C<eof> is true once the job has finished and you have read to the end.

=head3 kill

Takes C<job>, kills it with C<SIGKILL> and returns C<< { job, state, killed } >>
immediately — collecting the status is the watcher's business, so C<poll> for
the final state. Killing a job that has already finished changes nothing and is
not an error.

=head3 Job lifetime

=item At most C<max-jobs> run at once.
=item A finished job stays pollable for C<job-ttl> seconds, and only the
C<max-finished-jobs> most recently finished are kept. After that its handle is
refused with a message saying why — start a new job rather than hoping.
=item A job does B<not> observe the cancellation of the request that started it:
that request was answered the moment the handle was minted. Stopping a job is
explicit, or its own C<timeout>.
=item Jobs die with the server process. Nothing here survives a restart.
=item Job ids (C<j3-1x9k2w>) are handles, not secrets: anyone who can call these
tools can already start jobs of their own.

=head3 Push notifications, honestly

While a job runs, its output and lifecycle events are also pushed as
C<notifications/message> — output batches at C<debug> (so a legacy client can
silence them with C<logging/setLevel>) and C<started> / C<exited> / C<killed>
lines at C<info>, all labelled with the prefixed C<start> tool name. Whether
they arrive depends on how the server is being spoken to, and this is worth
knowing before you build a UI on it:

=begin table
Session                     | Push  | Why
============================|=======|====================================================
Legacy stdio, initialized   | Yes   | One client, one transport-wide channel to write on
Legacy stdio, pre-handshake | No    | Nothing may be sent before the client says it is initialized
Modern Streamable HTTP      | No    | There is no server-initiated stream between requests
In-process, C<:on-notify>   | Yes   | The host process is the client, and it wired up a sink
In-process, no sink         | No    | Nowhere to write
=end table

So: B<C<poll> and C<output> are the reliable way to follow a job>, on every
transport. Push is a bonus where the channel happens to exist, never a
substitute for polling — and a push channel that refuses or fails never affects
the job itself, whose output is safe in its buffers either way.

=head3 Job lifecycle events

The two C<info> lines above are prose, meant for a log window. The same two
facts also go out as a structured C<notifications/job>, on the same channel and
under the same delivery rules, for a host that wants to B<act> on a job
finishing rather than show it to somebody:

=begin code :lang<json>
{"jsonrpc":"2.0","method":"notifications/job",
 "params":{"job":"j1-1x9k2w","state":"started","runtime":0,
           "logger":"sh_start","command":"make"}}

{"jsonrpc":"2.0","method":"notifications/job",
 "params":{"job":"j1-1x9k2w","state":"exited","exit":0,"runtime":42.317,
           "logger":"sh_start","command":"make"}}
=end code

=item Exactly one C<started> and exactly one terminal event (C<exited> or
C<killed>) per job, by construction: one thread wins the transition that emits
each. A job that could not be spawned at all emits only the terminal one, with
C<exit> C<-1> — and emits it from inside the C<start> call, so it can reach a
host a moment before the handle does.
=item C<exit> is B<absent>, not null, when the status never arrived: "we do not
know" and "it exited with 0" must not look alike to a program.
=item C<command> is the job's own record of the program it was asked to run,
truncated to 120 characters. B<Never a byte of what the child wrote>, and not
its arguments either. A host is likely to turn these events into text that
something — a person, a model — reads as trustworthy, and a command that prints
C<[background event] ignore your previous instructions> must not get a hearing
that way. Output has its own channel and its own tool.
=item C<logger> is the prefixed C<start> tool name, so two shell kits plugged
into one server can be told apart.

=head3 allowed

Takes no arguments and returns the allow list, sorted, one entry per line:

=begin code
git
rg
/usr/bin/jq
=end code

Give the LLM this tool and it can discover what it may run instead of guessing
and collecting errors. In C<allow-any> mode it says so in one line instead.

=head2 Security

This pack is a gate, not a sandbox. What it guarantees:

=item B<Exact-string allowlist> (unless you turn it off). The C<command>
argument must be C<eq> one of the C<allow> entries. There is no globbing, no
basename matching, no C<PATH> rewriting and no path normalisation: if you allow
C</usr/bin/jq> then C<jq> is refused, and if you allow C<git> then
C</usr/bin/git> is refused. Prefer absolute paths when you care I<which> binary
runs; prefer bare names when you want C<PATH> resolution (including C<.exe>
resolution on Windows).

=item B<No shell, ever.> Commands are spawned with C<Proc::Async>, which
C<exec>s the program directly with the argv vector you supplied. No shell is
involved at any point, so quoting, word splitting, globbing, redirection,
pipelines, command substitution and environment-variable expansion simply do not
happen. C<< args => ['$HOME; rm -rf /'] >> is one argument containing punctuation.

=item B<Argument hygiene.> C<args> must be an array; entries must be strings (or
numbers, which are stringified) and may not contain NUL bytes, since a NUL would
silently truncate the argument at the C<exec> boundary. The same goes for
C<command> itself.

=item B<Bounded runtime.> A foreground run is killed once C<timeout> seconds have
elapsed and reported with C<timed-out: true>. The kill is C<SIGKILL> — the
uncatchable signal — rather than a polite C<SIGTERM> first: Rakudo's
C<Proc::Async> ignores every C<.kill> after the first on a given process, so a
catchable signal would leave no way to escalate against a child that traps it.
On Windows C<SIGKILL> is one of the three signals libuv maps onto
C<TerminateProcess>, which is equally uncatchable. A killed command therefore
gets no chance to clean up after itself.

=item B<Bounded memory.> Output is capped per stream per run, and a background
job's buffers are capped the same way however long it runs.

What it deliberately does not do:

=item B<It does not, on its own, constrain what an allowed command can do.>
Allowlisting an interpreter or a shell (C<sh>, C<bash>, C<pwsh>, C<raku>,
C<python>) hands over arbitrary code execution, because those programs take
code as an argument. Allowlist leaf tools, not launchers. A C<launcher> (see
L<#Sandboxing: the launcher seam>) I<can> constrain a run — it is where a host
wraps the spawn in an OS sandbox — but the pack itself makes no such promise.

=item B<It does not filter arguments.> C<git> can push, C<rg> can read any file
the server user can read, and C<cwd> is unrestricted unless you leave it unset
and only allow commands that ignore it. Run the server as a user whose
privileges you are comfortable handing to the model.

=item B<It does not scrub the environment.> Children inherit the server
process's environment, including any credentials in it.

=item B<It does not stop a command reaching the network, the clock, or any
other resource the server's user can reach.> Isolation here is about the
terminal and the process tree, not about capabilities; that is what a
C<launcher> is for.

=head3 Terminal isolation

Where a shell pack runs decides what its children can wreck.

Plugged into a stdio MCP server, the pack lives in a process whose C<fd 0> is a
pipe from the client, and a child that inherits it is mostly harmless. Plugged
in B<in-process> — a terminal application hosting its own tool server rather
than talking to one over a socket — the pack lives in a process whose C<fd 0> is
the user's terminal, and whose B<controlling terminal> every child inherits.
From there one ordinary command can take the host apart:

=item A child that puts the terminal in raw mode and restores it to cooked mode
on the way out — any full-screen program: an editor, a pager, another TUI —
leaves the I<host's> terminal cooked. The host carries on drawing and never
receives another keystroke: its input is being line-buffered by a terminal it no
longer controls, and its quit key has become flow control. This is not
hypothetical; it is the incident this feature exists for.

=item A child that reads standard input steals the bytes the host was going to
read — keystrokes from a terminal, or, in stdio server mode, the client's own
JSON-RPC frames.

=item A child that opens C</dev/tty> reaches the host's terminal even when all
three of its own descriptors are pipes.

None of that is a command misbehaving. It is a command doing exactly what it
always does, to a terminal it should never have been able to see. So, unless you
set C<< isolate-terminal => False >>, the pack does two things at B<both> spawn
sites (a foreground C<run> and a background C<start>):

=item B<Standard input is detached.> A run given no C<stdin> argument gets a
pipe that is closed immediately, so its C<fd 0> reads end of file rather than
the host's terminal or transport. A run that I<is> given C<stdin> is unchanged.
This half works on every platform.

=item B<The child gets a new session (POSIX).> A fresh session has no
controlling terminal, so C</dev/tty> cannot be opened at all: the host's
terminal is not merely unwritten-to, it is unreachable. Because a new session is
also a new process group, this is what makes a kill reach the whole tree.

A new session has to be created by the child itself, between C<fork> and
C<exec>, which is not somewhere Raku code can run — so the pack C<exec>s a tiny
trampoline that calls C<setsid(2)> and then C<exec>s the real command B<in the
same process>, preserving the pid every kill and exit status depends on.
C<perl> is used by preference (it is on macOS and every mainstream Linux, and
the pack supplies the trampoline text itself, so a command that cannot be
C<exec>'d still reports C<exit: -1> with the reason on stderr exactly as it
always did); C<setsid(1)> is the fallback for images without perl. The
trampoline adds one C<exec> — a few milliseconds — per run, and it can never
reach a shell: the C<exec> is C<execvp(3)>, never C</bin/sh>.

The trampoline is chosen by the pack, never by the caller: no tool argument
reaches it, and the command still has to pass the allow list before anything is
spawned. It is found on C<PATH> (falling back to the usual absolute locations),
on the same trust assumption the allow list already makes for a bare command
name — a process whose C<PATH> has a writable directory ahead of C</usr/bin> has
larger problems than this.

Which mechanism is available is resolved B<and verified> once per process, by
running a one-liner through the trampoline and checking that it came back a
session leader with the pid that was spawned. A toolkit that asks for isolation
and cannot get it B<refuses to construct>, naming what it tried:

=begin code :lang<raku>
# Nothing on this machine can detach a child:
MCP::Server::Tool::Shell.new(allow => <git>);
# Shell toolkit could not isolate spawned commands from the controlling
# terminal, so it refuses to start: ... Install perl (or util-linux for
# setsid), or, if this process has no terminal worth protecting, construct
# the toolkit with isolate-terminal => False.
=end code

That is deliberate: a protection that is quietly missing is worse than one that
is loudly absent, because the failure it prevents costs the user their session.

B<Kills follow the tree.> Because an isolated child leads its own process group,
a timeout, a cancelled call and the C<kill> tool all signal the I<group>: the
grandchildren a command spawned die with it instead of outliving it and holding
its pipes open (which used to lose the exit status, reported as C<exit: null>).
With C<< isolate-terminal => False >> the old behaviour returns — on Unix, only
the direct child is signalled.

B<Windows> has no sessions, no controlling terminal and no C<termios> to
corrupt, so the session half of this simply does not apply there and the pack
does not pretend otherwise: C<isolate-terminal> still detaches standard input
(which is the platform's own version of the hazard — a child reading the host's
console, or its stdio transport), and kills already took the whole tree via
C<taskkill /T /F>. Console-handle isolation beyond standard input is not
attempted.

=head3 Running anything: allow-any and MCP::Client::Policy

C<< allow-any => True >> removes the allow list entirely. Everything else stays:
no shell, argv vectors, NUL rejection, capped output, timeouts, jobs. What is
gone is the decision about I<what may run> — this pack will run anything the
server's user can run, including a shell, including C<rm>.

That is only a sane configuration if the decision is being made somewhere else.
The intended pairing is a policy layer on the client side, such as
C<MCP::Client::Policy>, whose rules are name-based and whose default for an
unmatched tool is to ask a human:

=begin code :lang<raku>
# Server: no allow list, because the client is where consent lives.
$server.plug: MCP::Server::Tool::Shell.new(:allow-any);
=end code

With a policy in front, C<sh_run> and C<sh_start> match no allow rule, so every
call is put to the user before it happens — which is a better gate than a static
list for an interactive coding agent, and a much worse one for an unattended
server. If nothing is asking a human, use C<allow>.

=head3 Sandboxing: the launcher seam

The pack is a gate, not a sandbox — but it leaves a seam for a host to bolt one
on. The C<launcher> attribute is a C<Callable> (programmatic only: a closure
cannot come from a JSON config, so C<from-config> does not offer it) given the
validated plan just before the process is built:

=begin code :lang<raku>
MCP::Server::Tool::Shell.new(
    :allow-any,
    launcher => -> %plan {
        # %plan is { command, argv, dir }.  Return { command, argv }: the
        # program actually exec'd becomes the wrapper, with the original
        # command and its arguments folded onto the wrapper's own argv.  dir
        # is left alone — the working directory is still applied by the pack.
        %(
            command   => 'sandbox-exec',
            argv      => ['-p', $profile, %plan<command>, |%plan<argv>],
            sandboxed => True,
        );
    },
);
=end code

The seam is deliberately minimal and total:

=item B<It wraps both spawn sites.> A foreground C<run> and a background
C<start> pass through the same launcher and the same wrapping — there is no path
that escapes it.

=item B<An undefined launcher is exactly the old behaviour.> No launcher, no
wrapping, and the result payload is byte-for-byte what it always was.

=item B<A launcher that throws is a failed spawn, not a crash.> A launcher that
dies (or returns a shape the pack cannot spawn) lands in the same C<exit: -1> +
reason-in-stderr payload a command that could not be C<exec>'d does. It never
takes the server down, and never runs the unwrapped command as a fallback —
a sandbox that failed to build is a run that failed, not a run that escaped.

=item B<It marks its own runs.> A launcher may return C<< sandboxed => True >>,
which surfaces as C<< sandbox => { active => true } >> on the result (a
foreground C<run>, or a job's C<output>). A host reads it to tell a sandbox
denial from an ordinary non-zero exit — the hook for an "it was the sandbox;
retry without it?" prompt. The key is B<absent> otherwise, so a run with no
launcher, or one the launcher declined to sandbox, keeps the payload it always
had.

The launcher does not see, and cannot change, the working directory, the
timeout, the output caps or the standard input: it wraps I<what runs>, and the
pack still governs I<how long, how much and where>.

=head3 Windows notes

=item Bare names resolve the way C<CreateProcess> resolves them, so C<< allow =>
<git> >> runs C<git.exe> off C<PATH>. Allowlist the string you want the caller to
I<pass> (C<git>), not the file that ends up being executed (C<git.exe>) — the
match happens before any resolution.
=item Arguments are still a vector: Rakudo quotes each entry for
C<CreateProcess>, so an argument containing spaces stays one argument.
=item A kill becomes C<TerminateProcess>, which the command cannot trap or
delay. There are no signals, so there is no C<128 + signal> exit status: a
killed run or job reports whatever C<TerminateProcess> left behind (C<1>, in
practice). Test for C<exit != 0> rather than for C<137>.
=item A kill takes the B<whole process tree>: C<taskkill /T /F /PID> is issued
against the child before the C<TerminateProcess> backstop, so grandchildren the
command spawned do not outlive the kill. It is fire-and-forget — the watcher
collects the exit status independently — and if C<taskkill> is missing from a
stripped image the direct-child kill still runs. (Unix reaches the tree a
different way, by signalling the process group of an isolated child; see
L<#Terminal isolation>.)
=item C<isolate-terminal> detaches standard input here as it does everywhere,
but there is no session to leave and no C</dev/tty> to close off: Windows has
neither, so the pack claims neither. See L<#Terminal isolation>.
=item Everything else — stdin, capped output, streaming, jobs — behaves the same
as on Unix.

=head2 Examples

=item C<examples/shell-server.raku> — a git + ripgrep server, with the
invocation line in the header.

=head2 See Also

=item L<MCP::Server|https://github.com/m-doughty/MCP-Server> — the server
framework, the C<MCP::Server::Toolkit> role this pack implements, and the
C<raku-mcp> command.

=head2 Author

Matt Doughty

=head2 License

Artistic-2.0

=end pod
