Revision history for MCP-Run

0.107     2026-10-24 03:14:19Z
  - Document that the limit on failed commands belongs to the hook and
    not to the distribution: the MCP server produces the output itself
    and compresses it whether the command succeeded or failed, with the
    same command-specific filters.
  - Stop the command's process group even when the calling program has
    installed a handler of its own for `SIGHUP`, `SIGINT` or `SIGTERM`.
    The signal is taken over for the duration of the command, the group
    is stopped, and the program's own handler is chained afterwards: the
    program still gets the signal it was sent, and the command no
    longer outlives the run. A signal the program ignores is left
    alone, as before.
  - Recognise the date column of `ls --time-style=` in every shape it
    produces: the locale default, `iso`, `long-iso`, `full-iso` and the
    date alone. The filter matches the date forms as alternatives
    instead of loosening the regex, so a strftime format that prints
    neither an ISO date nor an ISO time still stays uncollapsed.
  - Fix the SYNOPSIS of `MCP::Run::Bash`: it called `$server->run`, a
    method that exists neither in `MCP::Run::Bash` nor in `MCP::Run` nor
    in `MCP::Server`, so the first line a reader copied died at runtime
    with "Can't locate object method". The example calls
    `$server->to_stdio` now, and a new test, `t/07-pod.t`, checks that
    every method call in the SYNOPSIS of `MCP::Run` and `MCP::Run::Bash`
    exists.
  - Remove `test_ci.txt` from the distribution. The file was tracked in
    git and therefore shipped in the CPAN tarball, where it says
    nothing.
  - Apply `working_directory` with `chdir` in the child process instead of
    prefixing the command with `cd 'DIR' && `. The prefix bound only to the
    first command of a list: with `;`, newlines or a background `&` the
    other members ran in the server's directory, and when the `cd` failed
    the rest of the list ran anyway, there. A directory that is missing or
    cannot be entered is now an error (exit 1, `error` names the directory
    and the reason) and the command is not run (karr #42, found by
    langertha-raider).
  - Fix the ls filter doing nothing on every system whose `ls` marks the
    mode column. Fedora, Rocky and RHEL append a `.` there for the
    SELinux context, systems with POSIX ACLs a `+`, macOS an `@` for
    extended attributes -- and the filter's regex demanded whitespace
    directly after the nine rwx characters, so on those machines not one
    line was ever collapsed and `ls -la` came back at full length. Plain
    Debian appends nothing, which is the whole reason this survived: the
    only test that would have caught it runs `ls` on the build host, and
    on the development machine it passed. It took a CPAN smoker to
    report it (GitHub #3, karr #35). The same regex accepted only `d`
    and `-` as the type letter, so symlinks, devices, fifos, sockets and
    doors went through uncollapsed everywhere, and a listing holding
    nothing but such entries was not even recognised as a long listing.
    All types are now handled; for a symlink the whole `link -> target`
    is kept, because the target is the only thing the entry states.
  - Compress in a PostToolUse hook instead of rewriting the Bash
    command. `--install-claude` now registers the same command on two
    events: PostToolUse does the compression, PreToolUse keeps only what
    has to happen before the command runs -- the Co-Authored-By trailer
    and the `no-compress` marker. `--b64` and `--filter-files` are gone,
    and with them the hardcoded 1800-second timeout, the base64 round
    trip, and the host-side docker snippet that captured output into
    temp files and lost every byte of it whenever `docker run` failed.
    Both install modes now run the same code -- JSON in on stdin, JSON
    out -- and the mode only picks the command string written into
    settings.json. Upgrading is `--install-claude` again: it adds the
    missing hook and replaces entries naming the removed flags, which
    would otherwise make GetOptions exit non-zero and, from a PreToolUse
    hook, block every Bash call.
    Three limits come with the move and are documented in --help, in
    the POD and in the installed skill rather than left to be
    discovered. A command that exits non-zero is not compressed at all:
    the harness raises PostToolUseFailure there and ignores any
    replacement. That is the one thing the rewrite did better, and it is
    the noisiest output there is. Background commands have produced
    nothing yet when the hook runs. And two PostToolUse hooks do not
    chain -- each is handed the original output and whichever answers
    last wins, silently, so another output-replacing hook simply
    switches compression off.
  - Stop the self-bypass from silently switching off the Co-Authored-By
    trailer. It skipped any command mentioning `mcp-run-compress`, which
    existed so the hook would not wrap its own wrapper -- and once the
    wrapper was gone, all it still did was drop the trailer from commits
    whose message happens to name this tool. In this repository that is
    the common case, not the odd one. `no-compress` did the same thing:
    it means "do not compress the output", not "suspend the commit
    policy". Both now control compression only, and
    MCP_RUN_COMPRESS_NO_CO_AUTHORED is the single documented way to
    suppress the trailer.
  - Cap the replaced output at MCP_RUN_COMPRESS_MAX_BYTES, default
    29000. The harness caps what a hook is handed at 30 KB and files the
    rest away; the replacement is subject to that same cap, so
    compressing 100 KB down to 40 KB would have the harness file the
    result away too and show the model a 2 KB preview -- worse than not
    compressing. The cap trims the middle with a visible marker, and
    only when a raw file exists to point at: without one, cutting would
    destroy output the harness had already accepted. The compressed text
    names the raw file's path, which the harness stops providing once
    the persist fields are cleared.
  - Add the 26 filters the reference document had been promising without
    them existing, and delete the 18 entries that had no defensible
    specification behind them -- 70 documented commands against 48 real
    filters, and nothing had ever compared the two. Filters.pm is
    documentation only: no code loads it and no test read it, so it
    drifted quietly and told anyone checking whether their tool was
    supported the wrong answer for a third of the entries. A new test
    reduces both sides to a command identity and fails on any drift in
    either direction. Where a documented filter could not be built to a
    standard worth shipping -- `jq`, `sops`, `gcloud`, whose output *is*
    the answer, or `ty`, described as something it is not -- the entry
    was struck and the reason written down.
  - Decide the output filter and the command transform separately. They
    shared one selection, and the git-commit filter is the only one not
    anchored to the start of the command, so any compound command put
    them in competition: `cat msg.txt && git commit -m "fix"` either got
    its trailer and no filtering, or filtering and no trailer, decided
    by Perl's per-process hash order. Measured across twelve seeds:
    never both, nine times one and three times the other. The
    git-commit filter shapes no output at all -- it exists purely for
    its transform -- so it no longer takes part in choosing an output
    filter, and both now apply.
  - Stop the make filter from eating its own error lines. Its strip
    list discarded every `make[N]:` line, which was aimed at the
    Entering/Leaving directory chatter of a recursive build but took
    with it the line naming the failed target and Makefile line, the
    `No rule to make target` stop, the circular-dependency warning, and
    the "not remade because of errors" summary. On a failed recursive
    build the only survivor was the top-level `Error 2` -- the fact that
    it broke, with the answer to *where* removed. The rule now names the
    two chatter forms it was meant for, so a no-op build across twenty
    subdirectories still compresses to `make: ok`. Every other filter's
    strip list was checked the same way -- 322 patterns against 41
    shapes a diagnosis takes -- and make was the only one; a test keeps
    it that way, with an exception list that is empty on purpose.
  - Drop three match_output short-circuits that never fired and should
    not have. All three were written without /m, so `^` meant start of
    string and none could ever match past the first line -- `cpanm: ok`
    has never once been emitted. Adding /m was the obvious repair and
    the wrong one. The cpanm pattern would then fire on a run where one
    distribution failed to build, replacing the output -- including the
    only line mentioning the failure -- with `cpanm: ok`: not a missing
    hint but a false statement. The make circular-dependency pattern
    needs two `make[N]:` on one line, which never happens, and reports a
    warning as though the build had stopped. The gcc one looks for a
    capital `Error` on a line starting with `gcc`, and gcc writes
    lowercase `error:` after the filename. Behaviour is unchanged by
    their removal, precisely because they never fired; what is gone is
    three promises. The rule they violated is now written down:
    match_output replaces the *entire* output, so it is only ever honest
    for success, when there is nothing left to say. A test refuses any
    match_output pattern that anchors without /m.
  - Reject unknown attributes in register_filter instead of storing them
    in silence. `replace` had been accepted and never read since it was
    added; a typo in a filter definition behaved exactly the same way.
    `replace` is removed -- rewriting lines is `transform`, replacing
    everything is `match_output`, discarding lines is
    `strip_lines_matching`. Note for anyone calling register_filter from
    outside: an unknown attribute is now fatal rather than ignored.
  - Refuse a parsed filter that shadows a more specific one. A filter
    naming only a program matches every subcommand too, and the
    selection stops at the first parsed filter it finds, so a bare
    `kubectl` entry would decide `kubectl get` by hash order. The table
    has no such pair today -- the filters added here deliberately avoid
    it -- and now a test says so rather than a comment.
  - Say so when the head of the output was dropped. The tail-only
    truncation branch cut silently while every neighbouring path
    announces itself, and it stopped being dead code in this very
    release: the new `shopify theme push` filter keeps the last five
    lines, so sixty lines arrived as five with nothing to suggest the
    other fifty-five had existed. Silent truncation is the exact failure
    this tool exists to prevent -- the model takes what it is given for
    the whole output and has no reason to ask. The marker now leads the
    kept lines, where the following max_lines stage cannot cut it off
    and leave a number that lies. A test walks the filter table and
    insists that anything which truncates also says so; it found the
    shopify case on its own rather than being told about it.
  - Fix head_lines/tail_lines truncating to nothing. `min($n, @lines)`
    flattens the array into min's argument list, so it compared the line
    limit against the line *contents*: text lines yielded 0 and the
    stage returned an empty string, with a warning per line. No shipped
    filter reached the branch, which is why it survived; the first new
    filter to use it hit the bug immediately.
  - Report 128 + signal number when a command is killed by a signal,
    the way a shell does. `$? >> 8` is 0 for a signalled child, so
    execute() returned exit_code 0 and format_result derived no error
    flag from it: a build killed by the OOM killer came back looking
    like a successful run with oddly short output. Measured before the
    fix, SIGTERM, SIGKILL and SIGSEGV all reported success; they now
    report 143, 137 and 139.
  - Enforce the timeout even when the command refuses to die. The
    alarm was cleared before the SIGTERM was sent, leaving the final
    waitpid unguarded, so a child ignoring the signal blocked the
    server until it finished on its own — `trap "" TERM; sleep 12` at
    timeout 2 returned after 12 seconds, and would have held an MCP
    server for an hour just as happily. SIGTERM is now followed by a
    two second grace period and then SIGKILL; the same case returns
    after 4.
  - Run each command in its own process group so a timeout reaches its
    grandchildren. Only the bash itself used to be signalled, so
    `sleep 987 & wait` left the sleep running after the timeout had
    already reported 124. The child sets its own group before exec'ing
    (open3 with '-', then setpgrp, then exec), which needs no setsid
    binary. Note for anyone revisiting this: doing it from the parent
    after open3 does not work, setpgid on an already-exec'd child is
    refused with EACCES, and it fails silently.
  - Fall back to the configured default for any timeout the runner
    cannot enforce, instead of switching the timeout off. The tool
    schema accepts any integer and `0 // 30` is 0, so a client asking
    for `timeout: 0` reached alarm(0), which cancels the alarm rather
    than firing it — an unbounded command holding the server for as
    long as it liked. Infinities are the same trap through a different
    door: `1e400` is numeric and greater than zero, so it passed a
    plain positivity check, and alarm() then took it for a negative
    argument, warned, and ran unbounded anyway. Non-numeric values,
    NaN, infinities and anything alarm() cannot represent now fall back
    together. `0` does not mean unbounded, and says so in the POD and
    in the schema description. No `minimum` was added to the schema on
    purpose: MCP answers a validation failure with a bare
    `-32602 Invalid arguments` that never names the offending field, so
    a rejected call would tell the model less than a quietly corrected
    one — and the env-var path has no schema in front of it, so the two
    would have disagreed. There is a test pinning that decision.
  - Stop losing output when a signal interrupts the read loop.
    `while (my @ready = $select->can_read)` treats an interrupted
    select as end of input, so with any signal handler in the
    embedding program the rest of the command's output was dropped
    without a trace. The loop now runs on the handle count and retries;
    a sysread returning EINTR is no longer taken for EOF.
  - Close a second way around the timeout: a command that closed its
    own pipes (`exec >/dev/null 2>&1; sleep 20`) hit EOF immediately,
    disarmed the alarm and then blocked in an unguarded waitpid,
    running to completion and reporting success. The reap now happens
    inside the alarm window.
  - Collapse carriage-return overwrites before the line-based filters
    run. Every stage of the pipeline splits on /\n/, and a progress bar
    has no newlines at all — cargo, npm, pnpm, yarn, pip, docker, wget
    and curl draw thousands of states over each other with \r. Such a
    bar is one line, so max_lines cannot help, and 28 of the 48 filters
    set no truncate_lines_at: a measured 224 KB bar passed through
    entirely unchanged for `cargo build`, `make`, `npm install` and
    `docker build`, which is to say compression failed completely on
    the single most common shape of noisy output. Each line is now
    reduced to its last non-empty \r-separated state — what stayed on
    the screen — so nothing the user saw is lost. \r\n is normalised to
    \n first so Windows output is not mistaken for a progress bar. The
    stage is unconditional and identity-preserving on input without \r,
    verified byte for byte across 56 command variants.
  - Validate CO_AUTHORED_BY and ANTHROPIC_MODEL instead of splicing
    them into the command line unchecked. The trailer is written inside
    the double quotes of the `git commit -m "..."` argument, so a value
    containing a quote ended the string and turned whatever followed
    into further shell commands, and `$(...)` needed no quote at all to
    run. Demonstrated end to end with a stub git: the injected command
    executed. A value now has to look like a trailer — words, plus an
    optional <mail@host> — and anything else leaves the command
    untouched. Rejecting beats escaping here: a mangled trailer sits
    unnoticed in a real commit, a missing one does not.
  - Never let a broken installation block the Bash tool. The hook
    loaded its modules at compile time, so a missing dependency killed
    it before any runtime code, and exit 2 from a PreToolUse hook does
    not mean "error" to Claude Code, it means "block the tool call".
    Every Bash command in every session would have failed until the
    user repaired the install by hand — not hypothetical, this is
    exactly the state the development machine was found in. The hook
    now loads what it needs at runtime, answers any load failure with
    the pass-through response and exit 0, and says so in one line on
    stderr rather than dumping @INC. `--b64` runs the command
    uncompressed instead of swallowing it, since by then it is the only
    thing that would ever run it. `--filter-files` and
    `--install-claude` stay loud on purpose. Loading only the compressor
    rather than the whole MCP server stack also made the hook about
    twice as fast.
  - Print the raw captured output when docker-mode compression cannot
    run. The rewritten command captured stdout and stderr into temp
    files and left printing them to `docker run`; a docker daemon that
    was down, an image not yet pulled, a rate limit or a user outside
    the docker group meant nothing printed them and the trap deleted
    them. The exit code still came through, so a command whose output
    had vanished was indistinguishable from one that produced none. The
    mode degrades to uncompressed rather than to silent.
  - Install the hook and the skill independently in
    `mcp-run-compress --install-claude`. An already-installed hook
    returned early, so a deleted or outdated skill was never restored
    while the command cheerfully reported success — and the skill is
    what tells the model that it is looking at compressed output and
    how to opt out with `no-compress`.
  - Reject a tool call whose `command` is missing, empty or
    whitespace-only before either gate and before execute(). Such a
    request used to run `bash -c ""` and come back as "Exit code: 0",
    a malformed request answered as a successful run. Over the wire the
    reachable cases are the empty and whitespace-only string, which
    pass the schema's `type: string` unchallenged. The check sits in
    MCP::Run, so every future runner subclass inherits it, and
    whitespace-only counts as empty because the allowlist path has
    always said so.
  - Stop emitting an uninitialized warning when `allowed_commands` is
    set and the command is empty.
  - Collapse carriage returns for commands no filter matches, too. The
    pipeline returned early when nothing matched, so the normalisation
    never ran and a progress bar from bun, gradle, deno or any
    hand-written script still arrived whole -- 176 KB of it. Only that
    one stage runs for unmatched commands; the pass-through contract is
    otherwise untouched, because collapsing \r is display semantics
    rather than filtering. Cost on output that has no \r at all:
    1.1 ms for 9.9 MB, and the result is byte-identical.
  - Set the Co-Authored-By trailer whatever case it is written in.
    Detection was case-insensitive and the substitution behind it was
    not, so `co-authored-by:` entered the replacement branch, replaced
    nothing, and returned before the injection could run -- leaving no
    trailer at all. The substitution is now the condition, so
    "recognised but not replaced, therefore not injected either" cannot
    come back, and the trailer is normalised to the canonical spelling.
  - Merge compress() and process() into one pipeline. They were two
    near-identical copies of the same eleven stages, which is why the
    carriage-return hole had to be fixed twice and why a third fix
    could easily have landed in only one of them. Pinned by an
    equivalence test written before the merge: 54 commands x 16 output
    shapes x 4 stderr variants, 3456 comparisons, and it catches an
    injected divergence in either direction.
  - Declare Mojolicious in cpanfile. All three modules use Mojo::Base
    directly and it is not core; it only ever resolved because MCP
    happens to pull Mojolicious in. A direct dependency belongs in the
    metadata, not in someone else's dependency tree.
  - Refuse to start on an unusable MCP_RUN_TIMEOUT instead of guessing.
    The value was numified with `+ 0`, so `5m` silently became 5
    seconds and `abc` became 0, both with a Perl warning on the stderr
    of a stdio server and no word to the user. It is now whole seconds
    from 1 to the runner's own MAX_TIMEOUT, and anything else aborts
    with a message naming the value. This is configuration a human
    wrote, checked once at startup where a client still reports the
    failure -- deliberately stricter than the per-call timeout, which
    arrives mid-session from the model and therefore falls back
    silently rather than killing a running server. One narrowing worth
    knowing: a fractional value like `2.5` is refused here, while the
    per-call path rounds it up to 3.
  - Stop shipping .claude/ in the distribution. The agent skills, rules
    and worker definitions are instructions for working *on* MCP-Run
    and were a third of the tarball; they stay in the repository.
    CLAUDE.md still ships -- it describes the architecture and is the
    first file a packager or contributor reads.
  - Iterate filters in sorted order in transform_command, which stops
    at the first filter carrying a command_transform. There is exactly
    one such filter today, so Perl's per-process hash order decided
    nothing; fixed while it is still free. The filter selection in
    compress() is deliberately not sorted — it has an explicit
    precedence rule instead, and sorting there would change which
    filter wins.

0.106     2026-08-13 18:19:29Z
  - Fix mcp-run-bash dying on every tools/call in its default
    configuration. MCP::Run::format_result calls
    MCP::Run::Compress->new, but nothing under lib/ ever loaded that
    class, and bin/mcp-run-bash enables compression unconditionally.
    Every tools/call therefore hit "Can't locate object method new via
    package MCP::Run::Compress": under MCP 0.13 the server process
    died outright without answering, under 0.15 the client got
    -32603 Internal error. MCP::Run now loads MCP::Run::Compress at
    compile time, so a broken or missing compressor fails at server
    startup instead of mid-session. MCP_RUN_COMPRESS=0 was the only
    working configuration and is why the bug survived this long.
  - Add t/50-bash-bin.t, driving bin/mcp-run-bash as a subprocess over
    real stdio. None of the seven existing test files could have
    caught the above: the compress attribute defaults to off in the
    module, and every test instantiated the classes in-process, which
    loads MCP::Run::Compress and papers over the missing load. Only a
    fresh interpreter running the shipped script sees it. The test
    also asserts that the ls filter visibly rewrites the listing, so
    the crash cannot be "fixed" by quietly disabling compression.
  - Report mcp-run-bash and the distribution version in serverInfo
    instead of MCP::Server's PerlServer / 1.0.0 defaults. The defaults
    live on MCP::Run::Bash rather than in bin/mcp-run-bash, so library
    users are identified correctly too; passing name/version to the
    constructor still overrides them.
  - Require MCP 0.15 and rework t/20-integration.t for its protocol
    handling, fixing the smoker failures reported in GH #2. MCP 0.15
    made the protocol revision part of every request: params._meta
    must carry io.modelcontextprotocol/protocolVersion (checked
    against MCP::Constants::SUPPORTED_VERSIONS) and
    io.modelcontextprotocol/clientCapabilities, or MCP::Server
    rejects the request with "Missing protocol version" before
    dispatch. The test built a bare MCP::Server::Context and sent no
    _meta, so all four subtests failed. Only the test was wrong —
    MCP::Run does no protocol handling, and real clients are
    unaffected because the stdio transport serves the classic
    initialize handshake through MCP::Server::Legacy, which skips
    _check_meta entirely.
  - Follow the two 0.15 renames in the same test: the 'initialize'
    handshake became 'server/discover', and serverInfo moved out of
    the result body into result._meta, where it now accompanies every
    response. Assertions on server name, version, tool surface and
    execution are unchanged.
  - Add a 'protocol contract' subtest pinning both rejection paths
    (missing _meta, unsupported protocol revision) so a future
    protocol change fails with a legible message instead of
    scattering failures across every other subtest. The protocol
    revision is taken from MCP::Constants rather than hardcoded.
  - Pin the MCP prereq in cpanfile (was unversioned, which is why the
    smokers kept pulling the latest release). The suite targets the
    0.15 API only; there is no compatibility path back to 0.13.
  - Enforce that pin at runtime with a plain `use MCP 0.15` in
    t/20-integration.t. A cpanfile prereq is install-time metadata:
    prove never reads it and Makefile.PL only warns, so a stale MCP
    in @INC reached the suite untouched and aborted with three "not
    exported by the MCP::Constants module" import errors that named
    neither MCP nor a version. It now dies with "MCP version 0.15
    required--this is only version 0.13". No other test file needs
    the guard — they either avoid the protocol layer or, in the case
    of t/50-bash-bin.t, deliberately straddle both revisions.
  - GH #1 ("Can't call method has_scope on unblessed reference") was
    already fixed in 0.104 by passing MCP::Server::Context instances
    instead of raw hashrefs, and is not reproducible since. The
    smoker report predates that release.
  - Fix mcp-run-compress injecting the Co-Authored-By trailer into the
    wrong quote. The git-commit transform targeted the last double quote
    of the whole command line, so a compound command like
    `git commit -m "x" && git config user.name "y"` had the trailer
    written into the git config value (corrupting it) instead of the
    commit message. The injection now lands after the closing quote of
    the commit's own -m/--message argument; commands without one (e.g.
    `git commit --amend` opening the editor) are left alone. The flag
    match also covers bundled short options ending in the message flag
    (`git commit -am "..."`, -sm, -anm), which contain no literal `-m`
    and had lost the trailer entirely; the flag must now start an
    argument, so a `-m` inside a value like /tmp/msg-m is no longer
    mistaken for it.
  - Fix the same transform's replacement path swallowing the closing
    quote: s/Co-Authored-By: [^\n]+/.../g matched the quote too when
    the trailer ended a -m "..." message, leaving the shell with an
    unterminated string. The character class now stops at the quote
    ([^\n"]+), so replacing an existing Co-Authored-By line keeps the
    message intact.

0.105     2026-08-10 16:59:50Z
  - Migrate the release process fully into the [@Author::GETTY] bundle
    and drop the hand-rolled maint/release-after.pl. Its
    run_after_release hook created the GitHub release first, so the
    bundle's GitHub::CreateRelease plugin always hit 422
    already_exists and died with a generic "Unable to create GitHub
    release". GitHub::CreateRelease now owns the GitHub release (with
    ChangeLog notes), and a new [@Author::GETTY::Docker / compress]
    dist.ini section builds and pushes the raudssus/mcp-run-compress
    image (:latest, :0, :<VERSION>) as part of `dzil release`.
  - Dockerfile: rename the runtime stage to `compress`, matching the
    bundle's docker target and what the image actually ships (the
    mcp-run-compress hook only, not the mcp-run-bash runtime).
  - Make the ls-filter subtest in t/40-compress-bin.t deterministic:
    list a process-private tempdir instead of the shared /tmp, where a
    readdir race could make ls transiently exit 1 and fail the test
    intermittently.

0.104     2026-08-09 18:57:54Z
  - Fix "Use of uninitialized value in join or string" warning from
    MCP::Run::Compress::compress/process when a transform coderef
    returns undef (e.g. git diff --stat's summary-line drop). undef
    values are now filtered out before join, so dropping a line via
    `return undef` no longer warns.
  - Normalize undef $stdout/$stderr inputs to empty strings at the
    top of compress()/process() so a caller passing undef for an
    empty stream no longer trips warnings through the filter
    pipeline.
  - Fix filter resolution: when both a parsed_command filter and a
    legacy regex filter match the same command, the parsed_command
    filter now wins (it is strictly more specific). Previously hash
    iteration order decided, which meant `git diff --stat` could
    land on the bare `^git\s+diff\b` filter and skip the
    "N+M- filename" transform rewrite entirely.
  - Add t/30-no-warnings.t and t/40-compress-bin.t covering the
    regression and the previously untested bin/mcp-run-compress
    modes (--hook, --b64, --filter-files, --install-claude).
  - Fix t/20-integration.t: pass MCP::Server::Context instances to
    $server->handle() instead of raw hashes. MCP::Server's
    _handle_tools_list calls $context->has_scope, which crashes
    on an unblessed reference. The test was the bug, not the
    server.

0.103     2026-05-07 13:28:39Z
  - Fix MCP::Run::Compress->transform_command injecting Co-Authored-By
    *outside* the closing quote when the original git commit had no
    signature, which made bash run the model line as a separate command
    and choke on `<model@host>` as a redirection. The signature now
    lands inside the quoted commit message where it belongs.

0.102     2026-05-02 03:19:04Z
  - Pass the original command through to the compressor in
    MCP::Run::format_result so command-specific filters (ls, git,
    make, …) match when mcp-run-bash runs with compression enabled.
    Subclasses overriding format_result should accept the new
    fourth argument: format_result($tool, $result, $compress, $command).
  - Add CLAUDE.md and reframe README around mcp-run-bash as the
    primary product (stdio MCP server) and mcp-run-compress as the
    bonus PreToolUse hook for Claude Code, standalone-installable
    via the raudssus/mcp-run-compress Docker image.


0.101     2026-05-02 02:08:56Z
  - Fix "Wide character in subroutine entry" in bin/mcp-run-compress
    --hook when the Bash command contains non-ASCII characters: encode
    the command as UTF-8 octets before base64-encoding it.

0.100     2026-04-27 02:36:52Z

  - Add command_transform filter and process() method for git commit hooks
    - Register command_transform filter for git commit to auto-add/replace
      Co-Authored-By line using CO_AUTHORED_BY or ANTHROPIC_MODEL env vars
    - Add process() method that takes command, stdout, stderr and returns
      a hash with processed output
    - Add MCP_RUN_COMPRESS_NO_CO_AUTHORED to disable Co-Authored-By replacement
    - Update README with new environment variables and feature documentation

0.004     2026-04-25 04:02:17Z

  - Add C<validator> attribute to MCP::Run and MCP::Run::Bash. A coderef
    can be provided to perform custom command validation before execution.
    Return C<"1"> to allow, any other defined value to deny with a reason
    string. Undefined return is treated as denied.
  - Add parsed_command filter matching in MCP::Run::Compress using
    Getopt::Long for order-independent flag parsing. New filters can use
    C<< parsed_command->{program => 'git', subcommand => 'diff', flags => { stat => 1 } } >>
    instead of regex C<< command => '^git\s+diff\b' >>.
  - Add C<git diff --stat> filter with compact "N+M- filename" format.
  - Add filters: C<netstat>, C<ip addr/route/link>, C<mount>, C<lsblk>, C<blkid>.
  - Convert all existing filters (git, docker, kubectl, cargo, terraform,
    helm, npm, pip, pytest, curl, wget, ansible-playbook, rsync, iptables,
    ping, etc.) to use parsed_command approach.

0.003     2026-04-24 19:22:16Z
  - Ship a Docker image (raudssus/mcp-run-compress) so the Claude Code
    hook can be installed without a Perl toolchain on the host. The
    image is a multi-stage Dockerfile (perl:5.40-slim builder +
    slim runtime) with ENTRYPOINT mcp-run-compress
    - New env var MCP_RUN_COMPRESS_INSTALL_MODE selects `native`
      (default, in-process wrapper) or `docker` (pipe-through-docker).
      The shipped image bakes the var to `docker`, native Perl installs
      leave it unset, so the same `--install-claude` call produces the
      right hook in either context
    - New env var MCP_RUN_COMPRESS_IMAGE overrides the image ref the
      docker-mode hook uses. The shipped image pins itself to the exact
      version that built it, so `--install-claude` from inside the
      container writes a versioned hook into ~/.claude/settings.json
    - In docker mode, --hook rewrites Bash commands to a small host-side
      snippet that runs the original command on the host (host cwd,
      host env, host binaries) and streams stdout/stderr through a
      docker container for compression — the host only needs bash,
      coreutils (mktemp, base64) and docker
    - New --filter-files mode reads captured stdout/stderr from two
      files and emits compressed streams; used by the docker rewrite
    - Dist::Zilla release hook (maint/release-after.pl) builds and
      pushes both :VERSION and :latest tags to Docker Hub as part of
      `dzil release`, and uploads the CPAN tarball to the matching
      GitHub release
  - Declare JSON::MaybeXS and Path::Tiny as explicit runtime prereqs
    (previously pulled in transitively)
  - Add POD to bin/mcp-run-compress, rewrite README.md around the
    Docker-first install flow

0.002     2026-04-24 17:57:40Z

  - Add bin/mcp-run-bash — stdio entry point for MCP::Run::Bash,
    configurable via MCP_RUN_ALLOWED_COMMANDS, MCP_RUN_WORKING_DIRECTORY,
    MCP_RUN_TIMEOUT, MCP_RUN_COMPRESS and MCP_RUN_TOOL_NAME env vars.
    Compression is enabled by default; disable with MCP_RUN_COMPRESS=0
  - Add MCP::Run::run_stdio convenience constructor
  - Add bin/mcp-run-compress — wrap a shell command through
    MCP::Run::Compress filters. Ships a Claude Code PreToolUse hook
    (--hook) plus an --install-claude flag that registers the hook
    in ~/.claude/settings.json so every Bash tool call gets its
    output compressed automatically
    - Prefix a Bash command with `no-compress ` to bypass compression
      for that single invocation — the hook strips the prefix and
      passes the raw command through unwrapped
    - --install-claude also drops a minimal skill at
      ~/.claude/skills/bash-output-is-compressed-prefix-no-compress-to-bypass/
      that teaches Claude about the bypass

  - Add MCP::Run::Compress for automatic output compression
    - 8-stage filter pipeline (strip_ansi, match_output, transform,
      strip_lines, keep_lines, truncate, head/tail, max_lines, on_empty)
    - 30+ command-specific filters (ls, git, make, docker, kubectl, etc.)
    - Output detection to differentiate command output formats
    - Per-server and per-tool-call compression toggle
  - Add MCP::Run::Compress::Filters.pod documentation
  - Add Text::Trim dependency

0.001     2026-02-25 01:32:04Z

  - Initial release
  - MCP::Run base class with configurable run tool
  - MCP::Run::Bash implementation using IPC::Open3
  - Command whitelist via allowed_commands
  - Per-command working_directory and timeout
  - Timeout enforcement with alarm/SIGTERM (exit code 124)
