Rand Stats

MCP::Server::Tool::Shell

zef:apogee

Actions Status

MCP::Server::Tool::Shell

A command runner for MCP::Server.

Six tools — run, start, poll, output, kill and allowed — let an LLM invoke local programs. Which programs is your decision: either an exact allow list, or 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.

Synopsis

Plug it into a server you are already building:

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

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

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, ...

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

raku-mcp --tool=Shell='{"allow":["git","rg"],"timeout":10}'
raku-mcp --config=mcp.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 }
  }
}

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

Wiring that into Claude Code:

{
  "mcpServers": {
    "dev-tools": {
      "command": "raku-mcp",
      "args": ["--config=/home/me/mcp.json"]
    }
  }
}

Configuration

KeyTypeDefaultMeaning
allowArray of StrrequiredExact argv0 strings that may be executed
allow-anyBoolFalseRun anything, with no allow list at all
cwdStr (directory)noneDefault working directory for every run
timeoutReal (seconds)30Wall-clock budget for a foreground run
max-output-bytesInt (bytes)131072Retained output per stream, per run
max-output-linesInt (lines)2000Retained lines per stream, per run
max-jobsInt4Background jobs that may run at once
max-finished-jobsInt16Finished jobs that stay pollable
job-ttlReal (seconds)600How long a finished job stays pollable

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:

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, job-ttl, max-finished-jobs, max-jobs,
# max-output-bytes, max-output-lines, timeout

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

Tools

run

Runs one command in the foreground and waits for it.

ArgumentTypeRequiredMeaning
commandstringyesMust be `eq` an entry of the allow list
argsarray of stringnoOne array entry per argument
cwdstringnoWorking directory, overriding the default
stdinstringnoText to write to standard input, then close
timeoutnumbernoSeconds, overriding the server default

The result is a JSON object as text content:

{"exit":0,"stderr":"","stdout":"main\n","timed-out":false}
FieldTypeMeaning
exitIntProcess exit status; see below
stdoutStrWhat the command wrote to standard output, capped
stderrStrWhat the command wrote to standard error, capped
timed-outBoolTrue when the command was killed for exceeding the budget
truncatedObjectOnly present when output was dropped; see L<#Truncation>

exit reports:

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

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

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

Command 'rm' is not allowlisted. Allowed: git, rg

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

{ "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\"}" }

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

Standard input

stdin is written to the command and the pipe is then closed, so a filter sees a normal end of input. Three states, and they are all different:

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 (EPIPE), which is a runtime condition rather than an error. NUL bytes are fine here, unlike in args: this is a byte stream, not a C string.

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 head — the first quarter of the budget, which is where a compiler puts the error that matters — and a rolling 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.

{
  "exit": 0,
  "stdout": "001 ...\n002 ...\n[... 7176 bytes / 175 lines dropped ...]\n199 ...\n",
  "stderr": "",
  "timed-out": false,
  "truncated": { "stdout": { "bytes": 7176, "lines": 175 } }
}

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

Streaming a run in progress

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

{
  "jsonrpc": "2.0",
  "method": "notifications/message",
  "params": {
    "level": "info",
    "logger": "sh_run",
    "data": { "stream": "stdout", "text": "compiling lib/Thing.rakumod\n" }
  }
}

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 timed-out: the budget was never reached.

Background jobs

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

{ "command": "make", "args": ["-j8", "test"] }
 {"job":"j1-1x9k2w","state":"running"}

start

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

poll

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

{"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}

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

output

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

kill

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

Job lifetime

Push notifications, honestly

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

SessionPushWhy
Legacy stdio, initializedYesOne client, one transport-wide channel to write on
Legacy stdio, pre-handshakeNoNothing may be sent before the client says it is initialized
Modern Streamable HTTPNoThere is no server-initiated stream between requests
No transport (in-process)NoNowhere to write

So: poll and output are the reliable way to follow a job, on every transport. Push is a bonus where the transport happens to support it, 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.

allowed

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

git
rg
/usr/bin/jq

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

Security

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

What it deliberately does not do:

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

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 what may run — this pack will run anything the server's user can run, including a shell, including 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 MCP::Client::Policy, whose rules are name-based and whose default for an unmatched tool is to ask a human:

# Server: no allow list, because the client is where consent lives.
$server.plug: MCP::Server::Tool::Shell.new(:allow-any);

With a policy in front, sh_run and 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 allow.

Windows notes

Examples

See Also

Author

Matt Doughty

License

Artistic-2.0