Add mlr mcp MCP server + agent playbook, with a --no-shell gate (#2098 PR7) (#2133)

* Plan: flesh out PR7 (MCP server + Agent Skill) design

stdio transport (no HTTP port), mlr mcp terminal in the main binary,
SDK-vs-handroll decision, tool list, in-process vs subprocess split,
run-tool safety (--no-shell prerequisite), single-sourced skill, tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add mlr mcp: MCP server + agent playbook; --no-shell gate (#2098 PR7)

New terminal `mlr mcp` runs a Model Context Protocol server over stdio
(spawned by MCP clients; no network port), exposing five tools --
list_capabilities, which, validate_dsl, describe_data, run -- plus an
agent playbook as MCP prompt/resource. Catalog tools are served
in-process from the help registries; the rest subprocess this same
binary with MLR_ERRORS_JSON=1, a timeout, and an output cap.

Prerequisite: a new --no-shell flag / MLR_NO_SHELL env var (one-way
gate) disables the DSL system/exec functions, piped redirects, and
--prepipe/--prepipex; the MCP server sets it on the commands it runs
unless started with --allow-shell.

Adds the github.com/modelcontextprotocol/go-sdk dependency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Force LF checkout for the embedded SKILL.md (Windows CI fix)

go:embed embeds checkout bytes, so a CRLF checkout on Windows made the
embedded playbook differ per platform and failed
TestPlaybookHasFrontmatter. Pin the file to eol=lf in .gitattributes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Move no-shell test DSL into per-case mlr files (Windows CI fix)

Inline single-quoted DSL in cmd files is mangled by the Windows shell
(single quotes are not quote characters there); the harness's
put -f ${CASEDIR}/mlr pattern avoids shell quoting entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
John Kerl 2026-07-03 19:41:49 -04:00 committed by GitHub
parent d415ca7655
commit 41f5188bd0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 1608 additions and 2 deletions

4
.gitattributes vendored
View file

@ -25,3 +25,7 @@ out binary
*.pprint-crlf binary *.pprint-crlf binary
*.dkvp-crlf binary *.dkvp-crlf binary
*.bin binary *.bin binary
# Embedded into the mlr binary via go:embed: bytes must be identical on all
# platforms, in particular not CRLF on Windows checkouts.
pkg/terminals/mcp/SKILL.md text eol=lf

View file

@ -40,6 +40,7 @@ nav:
- "Shell completion": "shell-completion.md" - "Shell completion": "shell-completion.md"
- "Syntax highlighting: vimrc": "vimrc.md" - "Syntax highlighting: vimrc": "vimrc.md"
- "The REPL": "repl.md" - "The REPL": "repl.md"
- "AI agents and the MCP server": "mcp-server.md"
- "Online help": "online-help.md" - "Online help": "online-help.md"
- "How to contribute": "contributing.md" - "How to contribute": "contributing.md"
- 'FAQs and examples': - 'FAQs and examples':

View file

@ -652,6 +652,16 @@ This is simply a copy of what you should see on running `man mlr` at a command p
since direct-to-screen output for large files has its since direct-to-screen output for large files has its
own overhead. own overhead.
--no-hash-records See --hash-records. --no-hash-records See --hash-records.
--no-shell Disable Miller's ability to run external commands:
the DSL `system` and `exec` functions, piped
redirects such as `tee | "command"`, and
`--prepipe`/`--prepipex` all fail cleanly instead of
executing. Equivalent to setting the `MLR_NO_SHELL`
environment variable to a truthy value. Intended for
running agent-constructed command lines (e.g. via
`mlr mcp`) without also granting arbitrary command
execution. Once disabled, shell-outs cannot be
re-enabled for the rest of the process.
--norc Do not load a .mlrrc file. --norc Do not load a .mlrrc file.
--nr-progress-mod {m} With m a positive integer: print filename and record --nr-progress-mod {m} With m a positive integer: print filename and record
count to os.Stderr every m input records. count to os.Stderr every m input records.

View file

@ -631,6 +631,16 @@
since direct-to-screen output for large files has its since direct-to-screen output for large files has its
own overhead. own overhead.
--no-hash-records See --hash-records. --no-hash-records See --hash-records.
--no-shell Disable Miller's ability to run external commands:
the DSL `system` and `exec` functions, piped
redirects such as `tee | "command"`, and
`--prepipe`/`--prepipex` all fail cleanly instead of
executing. Equivalent to setting the `MLR_NO_SHELL`
environment variable to a truthy value. Intended for
running agent-constructed command lines (e.g. via
`mlr mcp`) without also granting arbitrary command
execution. Once disabled, shell-outs cannot be
re-enabled for the rest of the process.
--norc Do not load a .mlrrc file. --norc Do not load a .mlrrc file.
--nr-progress-mod {m} With m a positive integer: print filename and record --nr-progress-mod {m} With m a positive integer: print filename and record
count to os.Stderr every m input records. count to os.Stderr every m input records.

111
docs/src/mcp-server.md Normal file
View file

@ -0,0 +1,111 @@
<!--- PLEASE DO NOT EDIT DIRECTLY. EDIT THE .md.in FILE PLEASE. --->
<div>
<span class="quicklinks">
Quick links:
&nbsp;
<a class="quicklink" href="../reference-main-flag-list/index.html">Flags</a>
&nbsp;
<a class="quicklink" href="../reference-verbs/index.html">Verbs</a>
&nbsp;
<a class="quicklink" href="../reference-dsl-builtin-functions/index.html">Functions</a>
&nbsp;
<a class="quicklink" href="../glossary/index.html">Glossary</a>
&nbsp;
<a class="quicklink" href="../release-docs/index.html">Release docs</a>
</span>
</div>
# AI agents and the MCP server
Miller ships with a built-in [Model Context Protocol](https://modelcontextprotocol.io)
server, so AI agents (Claude Code, Claude Desktop, Cursor, and other MCP
clients) can discover and drive Miller without scraping help text or guessing
at flags.
The server speaks JSON-RPC over stdin/stdout (MCP's "stdio" transport): the
MCP client spawns `mlr mcp` as a subprocess. No network port is opened, and
the server exits when the client disconnects. Example registration, for
Claude Code:
<pre class="pre-highlight-in-pair">
<b>claude mcp add miller -- mlr mcp</b>
</pre>
<pre class="pre-highlight-in-pair">
<b>mlr mcp --help</b>
</pre>
<pre class="pre-non-highlight-in-pair">
Usage: mlr mcp [options]
Runs a Model Context Protocol (MCP) server exposing Miller to AI agents.
The server speaks JSON-RPC over stdin/stdout (MCP "stdio" transport); it is
meant to be spawned by an MCP client rather than run interactively. Example
registration, for Claude Code:
claude mcp add miller -- mlr mcp
Tools exposed:
list_capabilities the mlr help --as-json catalog/index, filterable
which intent -> ranked matching verbs/functions/flags/keywords
validate_dsl parse/type-check a put/filter expression without running it
describe_data field names/types/cardinality/values of input data
run run an mlr command line
Also exposed: an agent playbook, as MCP prompt "miller-playbook" and MCP
resource "miller://playbook", encoding the discover -> constrain -> validate
-> run loop.
Commands started by the run/validate_dsl/describe_data tools are run with
MLR_NO_SHELL=1 -- the DSL system/exec functions, piped redirects, and
--prepipe/--prepipex fail cleanly -- and with MLR_ERRORS_JSON=1 so errors
come back structured.
Options:
--allow-shell Do not set MLR_NO_SHELL=1 on subprocesses, re-enabling
the DSL system/exec functions for agent-run commands.
--timeout {seconds} Default wall-clock limit for the run tool (default 60).
--max-output-bytes {n} Cap captured stdout/stderr per command (default 1048576).
-h or --help Show this message.
</pre>
## What the tools map to
Each MCP tool is a thin wrapper over a Miller feature you can also use
directly from the command line:
* `list_capabilities` is [`mlr help --as-json`](online-help.md) -- the
machine-readable catalog of verbs, DSL functions, flags, and keywords.
* `which` is `mlr which` -- natural-language intent to ranked capabilities.
* `validate_dsl` is `mlr put --explain` / `mlr filter --explain` -- parse and
type-check a DSL expression without reading any input.
* `describe_data` is [`mlr describe`](reference-verbs.md#describe) -- field
names, types, cardinality, and value domains for input data.
* `run` executes an `mlr` command line and reports exit code, output, and --
on failure -- the structured error document from `mlr --errors-json`.
The catalog tools are answered in-process; the others run this same `mlr`
binary as a subprocess, so agents see exactly what a terminal user sees.
## Sandboxing: --no-shell
Miller's DSL includes [`system` and `exec`](shell-commands.md), and
`--prepipe`/piped redirects also run external commands. So that an
agent-constructed command line doesn't imply arbitrary command execution,
subprocesses started by the MCP server run with `MLR_NO_SHELL=1`: those
features fail cleanly instead of executing. Start the server with
`mlr mcp --allow-shell` to turn that off.
The same gate is available outside the MCP server: pass `--no-shell` to any
`mlr` invocation, or set the `MLR_NO_SHELL` environment variable to a truthy
value. Note that Miller can still write files when asked to (`tee`, `split`,
DSL output redirection) -- the gate is specifically about executing external
commands.
## The agent playbook
The server also exposes a playbook -- as MCP prompt `miller-playbook` and MCP
resource `miller://playbook` -- encoding the loop that makes an agent
effective with Miller: **discover** capabilities from the catalog rather than
inventing them, **constrain** to the data's actual fields and values via
`describe_data`, **validate** DSL before running it, and **run** with
structured-error recovery. The same text lives in the Miller source tree at
`pkg/terminals/mcp/SKILL.md` in Agent Skill format.

62
docs/src/mcp-server.md.in Normal file
View file

@ -0,0 +1,62 @@
# AI agents and the MCP server
Miller ships with a built-in [Model Context Protocol](https://modelcontextprotocol.io)
server, so AI agents (Claude Code, Claude Desktop, Cursor, and other MCP
clients) can discover and drive Miller without scraping help text or guessing
at flags.
The server speaks JSON-RPC over stdin/stdout (MCP's "stdio" transport): the
MCP client spawns `mlr mcp` as a subprocess. No network port is opened, and
the server exits when the client disconnects. Example registration, for
Claude Code:
<pre class="pre-highlight-in-pair">
<b>claude mcp add miller -- mlr mcp</b>
</pre>
GENMD-RUN-COMMAND
mlr mcp --help
GENMD-EOF
## What the tools map to
Each MCP tool is a thin wrapper over a Miller feature you can also use
directly from the command line:
* `list_capabilities` is [`mlr help --as-json`](online-help.md) -- the
machine-readable catalog of verbs, DSL functions, flags, and keywords.
* `which` is `mlr which` -- natural-language intent to ranked capabilities.
* `validate_dsl` is `mlr put --explain` / `mlr filter --explain` -- parse and
type-check a DSL expression without reading any input.
* `describe_data` is [`mlr describe`](reference-verbs.md#describe) -- field
names, types, cardinality, and value domains for input data.
* `run` executes an `mlr` command line and reports exit code, output, and --
on failure -- the structured error document from `mlr --errors-json`.
The catalog tools are answered in-process; the others run this same `mlr`
binary as a subprocess, so agents see exactly what a terminal user sees.
## Sandboxing: --no-shell
Miller's DSL includes [`system` and `exec`](shell-commands.md), and
`--prepipe`/piped redirects also run external commands. So that an
agent-constructed command line doesn't imply arbitrary command execution,
subprocesses started by the MCP server run with `MLR_NO_SHELL=1`: those
features fail cleanly instead of executing. Start the server with
`mlr mcp --allow-shell` to turn that off.
The same gate is available outside the MCP server: pass `--no-shell` to any
`mlr` invocation, or set the `MLR_NO_SHELL` environment variable to a truthy
value. Note that Miller can still write files when asked to (`tee`, `split`,
DSL output redirection) -- the gate is specifically about executing external
commands.
## The agent playbook
The server also exposes a playbook -- as MCP prompt `miller-playbook` and MCP
resource `miller://playbook` -- encoding the loop that makes an agent
effective with Miller: **discover** capabilities from the catalog rather than
inventing them, **constrain** to the data's actual fields and values via
`describe_data`, **validate** DSL before running it, and **run** with
structured-error recovery. The same text lives in the Miller source tree at
`pkg/terminals/mcp/SKILL.md` in Agent Skill format.

View file

@ -313,6 +313,7 @@ These are flags which don't fit into any other category.
* `--no-dedupe-field-names`: By default, if an input record has a field named `x` and another also named `x`, the second will be renamed `x_2`, and so on. With this flag provided, the second `x`'s value will replace the first `x`'s value when the record is read. This flag has no effect on JSON input records, where duplicate keys always result in the last one's value being retained. * `--no-dedupe-field-names`: By default, if an input record has a field named `x` and another also named `x`, the second will be renamed `x_2`, and so on. With this flag provided, the second `x`'s value will replace the first `x`'s value when the record is read. This flag has no effect on JSON input records, where duplicate keys always result in the last one's value being retained.
* `--no-fflush`: Let buffered output not be written after every output record. The default is flush output after every record if the output is to the terminal, or less often if the output is to a file or a pipe. The default is a significant performance optimization for large files. Use this flag to allow less-frequent updates when output is to the terminal. This is unlikely to be a noticeable performance improvement, since direct-to-screen output for large files has its own overhead. * `--no-fflush`: Let buffered output not be written after every output record. The default is flush output after every record if the output is to the terminal, or less often if the output is to a file or a pipe. The default is a significant performance optimization for large files. Use this flag to allow less-frequent updates when output is to the terminal. This is unlikely to be a noticeable performance improvement, since direct-to-screen output for large files has its own overhead.
* `--no-hash-records`: See --hash-records. * `--no-hash-records`: See --hash-records.
* `--no-shell`: Disable Miller's ability to run external commands: the DSL `system` and `exec` functions, piped redirects such as `tee | "command"`, and `--prepipe`/`--prepipex` all fail cleanly instead of executing. Equivalent to setting the `MLR_NO_SHELL` environment variable to a truthy value. Intended for running agent-constructed command lines (e.g. via `mlr mcp`) without also granting arbitrary command execution. Once disabled, shell-outs cannot be re-enabled for the rest of the process.
* `--norc`: Do not load a .mlrrc file. * `--norc`: Do not load a .mlrrc file.
* `--nr-progress-mod {m}`: With m a positive integer: print filename and record count to os.Stderr every m input records. * `--nr-progress-mod {m}`: With m a positive integer: print filename and record count to os.Stderr every m input records.
* `--ofmt {format}`: E.g. `%.18f`, `%.0f`, `%9.6e`. Please use sprintf-style codes (https://pkg.go.dev/fmt) for floating-point numbers. If not specified, default formatting is used. See also the `fmtnum` function and the `format-values` verb. * `--ofmt {format}`: E.g. `%.18f`, `%.0f`, `%9.6e`. Please use sprintf-style codes (https://pkg.go.dev/fmt) for floating-point numbers. If not specified, default formatting is used. See also the `fmtnum` function and the `format-values` verb.

6
go.mod
View file

@ -25,6 +25,7 @@ require (
github.com/kshedden/statmodel v0.0.0-20210519035403-ee97d3e48df1 github.com/kshedden/statmodel v0.0.0-20210519035403-ee97d3e48df1
github.com/lestrrat-go/strftime v1.2.0 github.com/lestrrat-go/strftime v1.2.0
github.com/mattn/go-isatty v0.0.22 github.com/mattn/go-isatty v0.0.22
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/nine-lives-later/go-windows-terminal-sequences v1.0.4 github.com/nine-lives-later/go-windows-terminal-sequences v1.0.4
github.com/pkg/profile v1.7.0 github.com/pkg/profile v1.7.0
github.com/rivo/uniseg v0.4.7 github.com/rivo/uniseg v0.4.7
@ -42,10 +43,15 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/felixge/fgprof v0.9.3 // indirect github.com/felixge/fgprof v0.9.3 // indirect
github.com/golang/snappy v1.0.0 // indirect github.com/golang/snappy v1.0.0 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd // indirect github.com/google/pprof v0.0.0-20211214055906-6f57359322fd // indirect
github.com/kshedden/dstream v0.0.0-20190512025041-c4c410631beb // indirect github.com/kshedden/dstream v0.0.0-20190512025041-c4c410631beb // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/crypto v0.45.0 // indirect golang.org/x/crypto v0.45.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/tools v0.45.0 // indirect golang.org/x/tools v0.45.0 // indirect
gonum.org/v1/gonum v0.16.0 // indirect gonum.org/v1/gonum v0.16.0 // indirect
pault.ag/go/topsort v0.1.1 // indirect pault.ag/go/topsort v0.1.1 // indirect

16
go.sum
View file

@ -12,8 +12,14 @@ github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb h1:IT4JYU7k4ikYg1S
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW64qjjJq8M2u4dxNaBiDfKK+z/3eGDpXEQhc= github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW64qjjJq8M2u4dxNaBiDfKK+z/3eGDpXEQhc=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w= github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
@ -35,6 +41,8 @@ github.com/lestrrat-go/strftime v1.2.0 h1:8fAUYOeaJKCuLzNvUWBAo8t6I6hkFfodDTndEz
github.com/lestrrat-go/strftime v1.2.0/go.mod h1:GtsIA/7ddIGJjEdfadUafEb1sbutvlvpMdPCMglykYo= github.com/lestrrat-go/strftime v1.2.0/go.mod h1:GtsIA/7ddIGJjEdfadUafEb1sbutvlvpMdPCMglykYo=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/nine-lives-later/go-windows-terminal-sequences v1.0.4 h1:NC4H8hewgaktBqMI5yzy6L/Vln5/H7BEziyxaE2fX3Y= github.com/nine-lives-later/go-windows-terminal-sequences v1.0.4 h1:NC4H8hewgaktBqMI5yzy6L/Vln5/H7BEziyxaE2fX3Y=
github.com/nine-lives-later/go-windows-terminal-sequences v1.0.4/go.mod h1:eUQxpEiJy001RoaLXrNa5+QQLYiEgmEafwWuA3ppJSo= github.com/nine-lives-later/go-windows-terminal-sequences v1.0.4/go.mod h1:eUQxpEiJy001RoaLXrNa5+QQLYiEgmEafwWuA3ppJSo=
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
@ -43,14 +51,22 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=

View file

@ -631,6 +631,16 @@
since direct-to-screen output for large files has its since direct-to-screen output for large files has its
own overhead. own overhead.
--no-hash-records See --hash-records. --no-hash-records See --hash-records.
--no-shell Disable Miller's ability to run external commands:
the DSL `system` and `exec` functions, piped
redirects such as `tee | "command"`, and
`--prepipe`/`--prepipex` all fail cleanly instead of
executing. Equivalent to setting the `MLR_NO_SHELL`
environment variable to a truthy value. Intended for
running agent-constructed command lines (e.g. via
`mlr mcp`) without also granting arbitrary command
execution. Once disabled, shell-outs cannot be
re-enabled for the rest of the process.
--norc Do not load a .mlrrc file. --norc Do not load a .mlrrc file.
--nr-progress-mod {m} With m a positive integer: print filename and record --nr-progress-mod {m} With m a positive integer: print filename and record
count to os.Stderr every m input records. count to os.Stderr every m input records.

View file

@ -767,6 +767,16 @@ These are flags which don't fit into any other category.
since direct-to-screen output for large files has its since direct-to-screen output for large files has its
own overhead. own overhead.
--no-hash-records See --hash-records. --no-hash-records See --hash-records.
--no-shell Disable Miller's ability to run external commands:
the DSL `system` and `exec` functions, piped
redirects such as `tee | "command"`, and
`--prepipe`/`--prepipex` all fail cleanly instead of
executing. Equivalent to setting the `MLR_NO_SHELL`
environment variable to a truthy value. Intended for
running agent-constructed command lines (e.g. via
`mlr mcp`) without also granting arbitrary command
execution. Once disabled, shell-outs cannot be
re-enabled for the rest of the process.
--norc Do not load a .mlrrc file. --norc Do not load a .mlrrc file.
--nr-progress-mod {m} With m a positive integer: print filename and record --nr-progress-mod {m} With m a positive integer: print filename and record
count to os.Stderr every m input records. count to os.Stderr every m input records.

View file

@ -6,6 +6,7 @@ import (
"runtime" "runtime"
"strings" "strings"
"github.com/johnkerl/miller/v6/pkg/lib"
"github.com/johnkerl/miller/v6/pkg/mlrval" "github.com/johnkerl/miller/v6/pkg/mlrval"
"github.com/johnkerl/miller/v6/pkg/platform" "github.com/johnkerl/miller/v6/pkg/platform"
"github.com/johnkerl/miller/v6/pkg/version" "github.com/johnkerl/miller/v6/pkg/version"
@ -28,6 +29,9 @@ func BIF_hostname() *mlrval.Mlrval {
} }
func BIF_system(input1 *mlrval.Mlrval) *mlrval.Mlrval { func BIF_system(input1 *mlrval.Mlrval) *mlrval.Mlrval {
if !lib.ShellOutEnabled() {
return mlrval.FromErrorString("system: disabled by --no-shell / MLR_NO_SHELL")
}
if !input1.IsStringOrVoid() { if !input1.IsStringOrVoid() {
return mlrval.FromNotStringError("system", input1) return mlrval.FromNotStringError("system", input1)
} }
@ -45,6 +49,9 @@ func BIF_system(input1 *mlrval.Mlrval) *mlrval.Mlrval {
} }
func BIF_exec(mlrvals []*mlrval.Mlrval) *mlrval.Mlrval { func BIF_exec(mlrvals []*mlrval.Mlrval) *mlrval.Mlrval {
if !lib.ShellOutEnabled() {
return mlrval.FromErrorString("exec: disabled by --no-shell / MLR_NO_SHELL")
}
if len(mlrvals) == 0 { if len(mlrvals) == 0 {
return mlrval.FromErrorString("exec: zero-length input given") return mlrval.FromErrorString("exec: zero-length input given")

View file

@ -3396,6 +3396,15 @@ var MiscFlagSection = FlagSection{
}, },
}, },
{
name: "--no-shell",
help: "Disable Miller's ability to run external commands: the DSL `system` and `exec` functions, piped redirects such as `tee | \"command\"`, and `--prepipe`/`--prepipex` all fail cleanly instead of executing. Equivalent to setting the `MLR_NO_SHELL` environment variable to a truthy value. Intended for running agent-constructed command lines (e.g. via `mlr mcp`) without also granting arbitrary command execution. Once disabled, shell-outs cannot be re-enabled for the rest of the process.",
parser: func(args []string, argc int, pargi *int, options *TOptions) {
lib.DisableShellOut()
*pargi += 1
},
},
{ {
name: "-x", name: "-x",
help: "If any record has an error value in it, report it and stop the process. The default is to print the field value as `(error)` and continue.", help: "If any record has an error value in it, report it and stop the process. The default is to print the field value as `(error)` and continue.",

View file

@ -40,6 +40,10 @@ func Main() MainReturn {
// found then this function will not return. // found then this function will not return.
auxents.Dispatch(os.Args) auxents.Dispatch(os.Args)
if lib.IsTruthyEnvValue(os.Getenv("MLR_NO_SHELL")) {
lib.DisableShellOut()
}
wantJSON := climain.WantErrorsJSON(os.Args) wantJSON := climain.WantErrorsJSON(os.Args)
options, recordTransformers, err := climain.ParseCommandLine(os.Args) options, recordTransformers, err := climain.ParseCommandLine(os.Args)

View file

@ -20,6 +20,9 @@ import (
// free. // free.
func OpenOutboundHalfPipe(commandString string) (*os.File, error) { func OpenOutboundHalfPipe(commandString string) (*os.File, error) {
if !shellOutEnabled {
return nil, fmt.Errorf("piped redirects are disabled by --no-shell / MLR_NO_SHELL")
}
readPipe, writePipe, err := os.Pipe() readPipe, writePipe, err := os.Pipe()
if err != nil { if err != nil {
return nil, err return nil, err
@ -58,6 +61,9 @@ func OpenOutboundHalfPipe(commandString string) (*os.File, error) {
// free. // free.
func OpenInboundHalfPipe(commandString string) (*os.File, error) { func OpenInboundHalfPipe(commandString string) (*os.File, error) {
if !shellOutEnabled {
return nil, fmt.Errorf("--prepipe/--prepipex are disabled by --no-shell / MLR_NO_SHELL")
}
readPipe, writePipe, err := os.Pipe() readPipe, writePipe, err := os.Pipe()
if err != nil { if err != nil {
return nil, err return nil, err

39
pkg/lib/shell_gate.go Normal file
View file

@ -0,0 +1,39 @@
// Package-level gate for shell-outs, controlled by the --no-shell main flag
// or a truthy MLR_NO_SHELL environment variable.
//
// When disabled, every place Miller can execute an external command in the
// data path -- the DSL system() and exec() functions, piped output redirects
// (e.g. `tee | "command"`), and --prepipe/--prepipex -- fails cleanly instead
// of running the command. This exists so that automation driving Miller (in
// particular the `mlr mcp` server, which sets MLR_NO_SHELL on the subprocesses
// it spawns) can run agent-constructed command lines without also granting
// arbitrary command execution.
//
// The gate is one-way by design: it can be disabled during startup, but never
// re-enabled, so an argv-injected flag cannot override an environment-level
// opt-out.
package lib
var shellOutEnabled = true
// DisableShellOut turns off all shell-out capability for the remainder of the
// process lifetime.
func DisableShellOut() {
shellOutEnabled = false
}
// ShellOutEnabled reports whether shell-outs are permitted.
func ShellOutEnabled() bool {
return shellOutEnabled
}
// IsTruthyEnvValue returns true for non-empty strings commonly used as
// boolean env-var truthy values: "1", "true", "yes" (case-insensitive).
func IsTruthyEnvValue(v string) bool {
switch v {
case "1", "true", "True", "TRUE", "yes", "Yes", "YES":
return true
}
return false
}

View file

@ -393,7 +393,7 @@ func NewPipeWriteOutputHandler(
) (*FileOutputHandler, error) { ) (*FileOutputHandler, error) {
writePipe, err := lib.OpenOutboundHalfPipe(commandString) writePipe, err := lib.OpenOutboundHalfPipe(commandString)
if err != nil { if err != nil {
return nil, fmt.Errorf(`could not launch command "%s" for pipe-to`, commandString) return nil, fmt.Errorf(`could not launch command "%s" for pipe-to: %w`, commandString, err)
} }
return newOutputHandlerCommon( return newOutputHandlerCommon(

65
pkg/terminals/help/api.go Normal file
View file

@ -0,0 +1,65 @@
// Exported accessors over the machine-readable help catalog, for callers
// outside this package -- in particular the `mlr mcp` server, which serves
// the same catalog/index/search over MCP that `mlr help --as-json` and
// `mlr which` serve over the command line.
package help
import (
"github.com/johnkerl/miller/v6/pkg/cli"
"github.com/johnkerl/miller/v6/pkg/dsl/cst"
"github.com/johnkerl/miller/v6/pkg/transformers"
)
// BuildFullCatalog returns the entire help catalog: the same document emitted
// by `mlr help --as-json`.
func BuildFullCatalog() *CatalogForJSON {
return buildFullCatalog()
}
// CatalogSchemaVersion returns the current catalog-shape version; see the
// catalogSchemaVersion const.
func CatalogSchemaVersion() int {
return catalogSchemaVersion
}
// BuildIndex returns the lightweight capability index: the same list emitted
// by `mlr help --as-json --index`.
func BuildIndex() []IndexEntryForJSON {
return buildIndex()
}
// CollectVerbs returns the structured views of the named verbs, or all verbs
// if names is empty. Unknown names are skipped.
func CollectVerbs(names []string) []*transformers.VerbInfoForJSON {
return collectVerbs(names)
}
// CollectFunctions is CollectVerbs for DSL builtin functions.
func CollectFunctions(names []string) []*cst.FunctionInfoForJSON {
return collectFunctions(names)
}
// CollectFlags is CollectVerbs for main-level flags.
func CollectFlags(names []string) []*cli.FlagInfoForJSON {
return collectFlags(names)
}
// CollectKeywords is CollectVerbs for DSL keywords.
func CollectKeywords(names []string) []*cst.KeywordInfoForJSON {
return collectKeywords(names)
}
// WhichSearch scores every catalog item against the query and returns matches
// in descending score order, along with the same confidence signal `mlr
// which` reports via its exit code: true when the top result's name contains
// a query token.
func WhichSearch(query string) (results []WhichResultEntry, confident bool) {
tokens := whichTokenize(query)
if len(tokens) == 0 {
return nil, false
}
results = whichSearch(tokens)
confident = len(results) > 0 && results[0].nameHit
return results, confident
}

View file

@ -0,0 +1,83 @@
---
name: miller
description: >
Drive Miller (mlr) to process CSV/TSV/JSON/etc. data. Use when constructing
mlr command lines: discover capabilities from the catalog rather than
guessing, learn the data's shape before writing expressions, validate DSL
before running, and recover from failures via structured errors.
---
# Miller agent playbook
Miller (`mlr`) is a command-line data processor for CSV, TSV, JSON, JSON
Lines, and other tabular/record formats, with SQL-like verbs (`cut`, `sort`,
`join`, `stats1`, ...) and an awk-like DSL (`put`, `filter`).
Work this loop. Each step exists to prevent a specific, common failure.
## 1. Discover — never invent names
Everything valid is in the catalog; anything not in the catalog does not
exist. Hallucinated flag/function names are the top failure mode.
- Route an intent: `which` with e.g. `"join two files on a key"` → ranked
candidates. `confident: true` means a name matched; trust the top hit.
- Browse cheaply: `list_capabilities` with `index: true` → every
verb/function/flag/keyword with one-line summaries.
- Drill in: `list_capabilities` with `kind: "verb", names: ["join"]` → the
full entry. Prefer the structured `options` list (flag, arg, type, enum
`values`) when present; `usage_text` is the prose fallback.
- The whole catalog is cacheable against `(mlr_version,
catalog_schema_version)` — re-fetch only when either changes.
## 2. Constrain — learn the data before touching it
Call `describe_data` on the input first. It returns, per field: name, types
seen with counts, occurrence count, null count, cardinality, min/max, and —
for low-cardinality fields — every distinct value.
- Copy field names exactly from `describe_data`; never guess casing or
spelling.
- For flags like `-g` (group-by) and DSL comparisons, use values from the
`values` array, not values you expect to exist.
- Fields whose `count` is less than other fields' are absent in some records:
guard DSL with `is_present($field)`.
## 3. Validate — check DSL before spending a run
Before any `run` that includes `put` or `filter`, call `validate_dsl` with the
expression. Cost: parse-only, no data read. On `valid: false`, the `error`
document has `kind`, `hint`, and `did_you_mean` — apply the hint, don't
re-guess syntax.
## 4. Run — and read errors structurally
Call `run` with argv as a list, one element per shell word (no shell quoting):
{"args": ["--icsv", "--ojson", "cat", "data.csv"]}
Command-line shape rules that prevent most argv errors:
- Main flags (I/O formats etc.) come **before** the verb: `mlr --icsv sort -f name f.csv`.
- Format shorthands: `--icsv --ojson` (separate in/out), `--csv`/`--c2j` etc. (combined).
- Chain verbs with `then`: `["--icsv", "sort", "-f", "k", "then", "head", "-n", "3", "f.csv"]`.
- If a field value being compared in `filter` might collide with a verb flag,
end verb flags with `--` before filenames.
- Inline data goes in `stdin_text`; files go at the end of `args`.
On failure, `exit_code` is nonzero and `error` (when present) carries `kind`,
`hint`, and `did_you_mean``hint` is often a corrected command line; prefer
executing it over reasoning from the message. `stdout_truncated: true` means
the output exceeded the server's cap: narrow the query (e.g. `head`, `cut`)
rather than re-running the same command.
## Notes
- `run` cannot execute external commands (DSL `system`/`exec`, piped
redirects, `--prepipe`) unless the server was started with `--allow-shell`;
such calls fail cleanly. It **can** write files via `tee`, `split`, and DSL
output redirects — treat it as a write-capable tool.
- Long inputs: prefer `describe_data` + targeted verbs over dumping whole
files through `run`.
- One record format in, another out: Miller is format-to-format; there is no
separate conversion step.

View file

@ -0,0 +1,165 @@
// Entrypoint for `mlr mcp`: a Model Context Protocol server exposing Miller
// to AI agents.
//
// Transport is JSON-RPC 2.0 over stdin/stdout ("stdio" in MCP terms): the MCP
// client -- Claude Code, Claude Desktop, Cursor, etc. -- spawns `mlr mcp` as a
// subprocess and speaks the protocol over its standard streams. No network
// port is opened. Register with e.g.:
//
// claude mcp add miller -- mlr mcp
//
// Tools exposed (see server.go): list_capabilities, which, validate_dsl,
// describe_data, run. The first two are served in-process from the same
// registries behind `mlr help --as-json`; the rest shell out to this same mlr
// binary so agents see byte-identical behavior to a terminal. Subprocesses
// run with MLR_NO_SHELL=1 (unless --allow-shell) so an agent-constructed
// command line cannot execute external commands, plus MLR_ERRORS_JSON=1 so
// failures come back structured.
package mcp
import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/johnkerl/miller/v6/pkg/cli"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
const verbNameMCP = "mcp"
const (
defaultTimeoutSeconds = 60
defaultMaxOutputBytes = 1024 * 1024
)
// serverConfig carries the `mlr mcp` command-line options into the tool
// handlers.
type serverConfig struct {
// mlrExePath is the mlr binary used for the subprocess-backed tools
// (validate_dsl, describe_data, run): this same executable, so tool
// behavior is version-locked to the server.
mlrExePath string
// allowShell, when true, omits the MLR_NO_SHELL=1 injection on
// subprocesses, re-enabling the DSL system/exec functions, piped
// redirects, and --prepipe for agent-run commands.
allowShell bool
// timeoutSeconds is the default wall-clock limit for the run tool
// (overridable per-call via its timeout_seconds input).
timeoutSeconds int64
// maxOutputBytes caps captured stdout/stderr per subprocess; output
// beyond the cap is discarded and reported as truncated.
maxOutputBytes int64
}
func mcpUsage(o *os.File) {
fmt.Fprintf(o, "Usage: mlr mcp [options]\n")
fmt.Fprintf(o, "Runs a Model Context Protocol (MCP) server exposing Miller to AI agents.\n")
fmt.Fprintf(o, "\n")
fmt.Fprintf(o, "The server speaks JSON-RPC over stdin/stdout (MCP \"stdio\" transport); it is\n")
fmt.Fprintf(o, "meant to be spawned by an MCP client rather than run interactively. Example\n")
fmt.Fprintf(o, "registration, for Claude Code:\n")
fmt.Fprintf(o, "\n")
fmt.Fprintf(o, " claude mcp add miller -- mlr mcp\n")
fmt.Fprintf(o, "\n")
fmt.Fprintf(o, "Tools exposed:\n")
fmt.Fprintf(o, " list_capabilities the mlr help --as-json catalog/index, filterable\n")
fmt.Fprintf(o, " which intent -> ranked matching verbs/functions/flags/keywords\n")
fmt.Fprintf(o, " validate_dsl parse/type-check a put/filter expression without running it\n")
fmt.Fprintf(o, " describe_data field names/types/cardinality/values of input data\n")
fmt.Fprintf(o, " run run an mlr command line\n")
fmt.Fprintf(o, "\n")
fmt.Fprintf(o, "Also exposed: an agent playbook, as MCP prompt \"miller-playbook\" and MCP\n")
fmt.Fprintf(o, "resource \"miller://playbook\", encoding the discover -> constrain -> validate\n")
fmt.Fprintf(o, "-> run loop.\n")
fmt.Fprintf(o, "\n")
fmt.Fprintf(o, "Commands started by the run/validate_dsl/describe_data tools are run with\n")
fmt.Fprintf(o, "MLR_NO_SHELL=1 -- the DSL system/exec functions, piped redirects, and\n")
fmt.Fprintf(o, "--prepipe/--prepipex fail cleanly -- and with MLR_ERRORS_JSON=1 so errors\n")
fmt.Fprintf(o, "come back structured.\n")
fmt.Fprintf(o, "\n")
fmt.Fprintf(o, "Options:\n")
fmt.Fprintf(o, " --allow-shell Do not set MLR_NO_SHELL=1 on subprocesses, re-enabling\n")
fmt.Fprintf(o, " the DSL system/exec functions for agent-run commands.\n")
fmt.Fprintf(o, " --timeout {seconds} Default wall-clock limit for the run tool (default %d).\n", defaultTimeoutSeconds)
fmt.Fprintf(o, " --max-output-bytes {n} Cap captured stdout/stderr per command (default %d).\n", defaultMaxOutputBytes)
fmt.Fprintf(o, " -h or --help Show this message.\n")
}
// McpMain is the entrypoint called by the terminals dispatcher for `mlr mcp`.
func McpMain(args []string) int {
config := serverConfig{
timeoutSeconds: defaultTimeoutSeconds,
maxOutputBytes: defaultMaxOutputBytes,
}
args = args[1:] // strip "mcp"
argi := 0
argc := len(args)
for argi < argc {
opt := args[argi]
if !strings.HasPrefix(opt, "-") {
fmt.Fprintf(os.Stderr, "mlr mcp: extraneous argument \"%s\".\n", opt)
return 1
}
argi++
switch opt {
case "-h", "--help":
mcpUsage(os.Stdout)
return 0
case "--allow-shell":
config.allowShell = true
case "--timeout":
n, err := cli.VerbGetIntArg(verbNameMCP, opt, args, &argi, argc)
if err != nil || n <= 0 {
fmt.Fprintf(os.Stderr, "mlr mcp: option \"%s\" requires a positive integer argument.\n", opt)
return 1
}
config.timeoutSeconds = n
case "--max-output-bytes":
n, err := cli.VerbGetIntArg(verbNameMCP, opt, args, &argi, argc)
if err != nil || n <= 0 {
fmt.Fprintf(os.Stderr, "mlr mcp: option \"%s\" requires a positive integer argument.\n", opt)
return 1
}
config.maxOutputBytes = n
default:
fmt.Fprintf(os.Stderr, "mlr mcp: option \"%s\" not recognized.\n", opt)
return 1
}
}
exePath, err := os.Executable()
if err != nil {
fmt.Fprintf(os.Stderr, "mlr mcp: could not locate the mlr executable: %v\n", err)
return 1
}
config.mlrExePath = exePath
server := newServer(&config)
err = server.Run(context.Background(), &mcpsdk.StdioTransport{})
if err != nil && !isClientDisconnect(err) {
fmt.Fprintf(os.Stderr, "mlr mcp: %v\n", err)
return 1
}
return 0
}
// isClientDisconnect reports whether the server-session error just means the
// client closed the connection (EOF on stdin) -- the normal way an MCP stdio
// session ends -- as opposed to a real transport failure.
func isClientDisconnect(err error) bool {
if errors.Is(err, io.EOF) || errors.Is(err, mcpsdk.ErrConnectionClosed) {
return true
}
msg := err.Error()
return strings.Contains(msg, "EOF") || strings.Contains(msg, "connection closed")
}

134
pkg/terminals/mcp/server.go Normal file
View file

@ -0,0 +1,134 @@
// Server construction for `mlr mcp`: tool, prompt, and resource registration.
package mcp
import (
"context"
_ "embed"
"github.com/johnkerl/miller/v6/pkg/version"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
//go:embed SKILL.md
var playbookText string
const playbookPromptName = "miller-playbook"
const playbookResourceURI = "miller://playbook"
const serverInstructions = `Miller (mlr) processes CSV/TSV/JSON/etc. from the command line.
Work the loop: describe_data to learn the input's fields and values; which or
list_capabilities (index first, then one full entry) to pick a verb;
validate_dsl before running any put/filter expression; then run. On a run
error, branch on the structured error's kind/hint/did_you_mean rather than
re-guessing. Get the full playbook from the miller-playbook prompt or the
miller://playbook resource. Never invent flag or function names: everything
valid is in list_capabilities.`
func boolPtr(b bool) *bool { return &b }
// newServer assembles the MCP server: five tools, the playbook prompt, and
// the playbook resource.
func newServer(config *serverConfig) *mcpsdk.Server {
server := mcpsdk.NewServer(
&mcpsdk.Implementation{
Name: "miller",
Title: "Miller (mlr)",
Version: version.STRING,
},
&mcpsdk.ServerOptions{
Instructions: serverInstructions,
},
)
mcpsdk.AddTool(server, &mcpsdk.Tool{
Name: "list_capabilities",
Description: "List Miller's verbs, DSL functions, main flags, and DSL keywords " +
"as structured JSON (the `mlr help --as-json` catalog). " +
"With index=true, returns only {kind, name, summary} triples across the whole surface -- " +
"the cheap first call. With kind (and optionally names), returns full entries for " +
"just those items. With no arguments, returns the entire catalog (large). " +
"Results are fully cacheable against (mlr_version, catalog_schema_version).",
Annotations: &mcpsdk.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true, OpenWorldHint: boolPtr(false)},
}, listCapabilitiesHandler)
mcpsdk.AddTool(server, &mcpsdk.Tool{
Name: "which",
Description: "Route a natural-language intent (e.g. \"join two files on a key\") to ranked " +
"Miller capabilities: verbs, functions, flags, keywords. Returns {confident, results}; " +
"when confident is true the top result's name matched a query word. " +
"Use before drilling into list_capabilities.",
Annotations: &mcpsdk.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true, OpenWorldHint: boolPtr(false)},
}, whichHandler)
mcpsdk.AddTool(server, &mcpsdk.Tool{
Name: "validate_dsl",
Description: "Parse and type-check a Miller put/filter DSL expression without reading any " +
"input data (mlr put --explain). Returns {valid} on success, or {valid: false, error} " +
"with a structured error document: kind, hint, did_you_mean. " +
"Always validate before using an expression in run.",
Annotations: &mcpsdk.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true, OpenWorldHint: boolPtr(false)},
}, validateDSLHandler(config))
mcpsdk.AddTool(server, &mcpsdk.Tool{
Name: "describe_data",
Description: "Learn the shape of input data before constructing a command " +
"(mlr describe): per-field name, types seen with counts, occurrence count, null count, " +
"cardinality, min/max, and -- for low-cardinality fields -- every distinct value. " +
"Copy field names and values from here instead of guessing them.",
Annotations: &mcpsdk.ToolAnnotations{ReadOnlyHint: true, IdempotentHint: true},
}, describeDataHandler(config))
mcpsdk.AddTool(server, &mcpsdk.Tool{
Name: "run",
Description: "Run an mlr command line. Pass argv after the leading \"mlr\", one element per " +
"shell word, e.g. [\"--icsv\", \"--ojson\", \"cat\", \"data.csv\"]; optional inline input " +
"via stdin_text. Returns {exit_code, stdout, stderr} plus a parsed structured error " +
"when the command fails. Output is truncated at the server's --max-output-bytes; " +
"execution is time-limited. External-command execution (the DSL system/exec functions, " +
"piped redirects, --prepipe) is disabled unless the server was started with --allow-shell. " +
"Commands can still write files via tee/split/--from-less redirection outputs.",
Annotations: &mcpsdk.ToolAnnotations{DestructiveHint: boolPtr(true)},
}, runHandler(config))
server.AddPrompt(&mcpsdk.Prompt{
Name: playbookPromptName,
Title: "Miller agent playbook",
Description: "How to drive Miller effectively as an agent: the discover -> constrain -> validate -> run loop.",
}, playbookPromptHandler)
server.AddResource(&mcpsdk.Resource{
URI: playbookResourceURI,
Name: playbookPromptName,
Title: "Miller agent playbook",
Description: "How to drive Miller effectively as an agent: the discover -> constrain -> validate -> run loop.",
MIMEType: "text/markdown",
}, playbookResourceHandler)
return server
}
func playbookPromptHandler(_ context.Context, _ *mcpsdk.GetPromptRequest) (*mcpsdk.GetPromptResult, error) {
return &mcpsdk.GetPromptResult{
Description: "Miller agent playbook",
Messages: []*mcpsdk.PromptMessage{
{
Role: "user",
Content: &mcpsdk.TextContent{Text: playbookText},
},
},
}, nil
}
func playbookResourceHandler(_ context.Context, _ *mcpsdk.ReadResourceRequest) (*mcpsdk.ReadResourceResult, error) {
return &mcpsdk.ReadResourceResult{
Contents: []*mcpsdk.ResourceContents{
{
URI: playbookResourceURI,
MIMEType: "text/markdown",
Text: playbookText,
},
},
}, nil
}

View file

@ -0,0 +1,320 @@
package mcp
import (
"context"
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
// connectTestClient stands up the server and a client over in-memory
// transports and returns the connected client session.
func connectTestClient(t *testing.T, config *serverConfig) *mcpsdk.ClientSession {
t.Helper()
if config == nil {
config = &serverConfig{
timeoutSeconds: defaultTimeoutSeconds,
maxOutputBytes: defaultMaxOutputBytes,
}
}
server := newServer(config)
serverTransport, clientTransport := mcpsdk.NewInMemoryTransports()
serverSession, err := server.Connect(context.Background(), serverTransport, nil)
require.NoError(t, err)
t.Cleanup(func() { _ = serverSession.Wait() })
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "test-client", Version: "0"}, nil)
clientSession, err := client.Connect(context.Background(), clientTransport, nil)
require.NoError(t, err)
t.Cleanup(func() { _ = clientSession.Close() })
return clientSession
}
// callToolInto invokes a tool and unmarshals its structured content into out.
func callToolInto(t *testing.T, session *mcpsdk.ClientSession, name string, arguments map[string]any, out any) *mcpsdk.CallToolResult {
t.Helper()
result, err := session.CallTool(context.Background(), &mcpsdk.CallToolParams{
Name: name,
Arguments: arguments,
})
require.NoError(t, err)
require.False(t, result.IsError, "tool %s returned IsError; content: %+v", name, result.Content)
b, err := json.Marshal(result.StructuredContent)
require.NoError(t, err)
require.NoError(t, json.Unmarshal(b, out))
return result
}
func TestListToolsAndAnnotations(t *testing.T) {
session := connectTestClient(t, nil)
result, err := session.ListTools(context.Background(), &mcpsdk.ListToolsParams{})
require.NoError(t, err)
byName := map[string]*mcpsdk.Tool{}
for _, tool := range result.Tools {
byName[tool.Name] = tool
}
for _, name := range []string{"list_capabilities", "which", "validate_dsl", "describe_data", "run"} {
require.Contains(t, byName, name)
}
assert.True(t, byName["list_capabilities"].Annotations.ReadOnlyHint)
assert.True(t, byName["which"].Annotations.ReadOnlyHint)
require.NotNil(t, byName["run"].Annotations.DestructiveHint)
assert.True(t, *byName["run"].Annotations.DestructiveHint)
}
func TestListCapabilitiesIndex(t *testing.T) {
session := connectTestClient(t, nil)
var output listCapabilitiesOutput
callToolInto(t, session, "list_capabilities", map[string]any{"index": true}, &output)
assert.NotEmpty(t, output.MlrVersion)
assert.Greater(t, output.CatalogSchemaVersion, 0)
assert.NotEmpty(t, output.Index)
assert.Empty(t, output.Verbs)
kinds := map[string]bool{}
for _, entry := range output.Index {
kinds[entry.Kind] = true
}
for _, kind := range []string{"verb", "function", "flag", "keyword"} {
assert.True(t, kinds[kind], "index missing kind %s", kind)
}
}
func TestListCapabilitiesKindAndNames(t *testing.T) {
session := connectTestClient(t, nil)
var output listCapabilitiesOutput
callToolInto(t, session, "list_capabilities",
map[string]any{"kind": "verb", "names": []string{"cat"}}, &output)
require.Len(t, output.Verbs, 1)
assert.Equal(t, "cat", output.Verbs[0].Name)
assert.NotEmpty(t, output.Verbs[0].UsageText)
var full listCapabilitiesOutput
callToolInto(t, session, "list_capabilities", map[string]any{}, &full)
assert.NotEmpty(t, full.Verbs)
assert.NotEmpty(t, full.Functions)
assert.NotEmpty(t, full.Flags)
assert.NotEmpty(t, full.Keywords)
}
func TestListCapabilitiesBadKind(t *testing.T) {
session := connectTestClient(t, nil)
result, err := session.CallTool(context.Background(), &mcpsdk.CallToolParams{
Name: "list_capabilities",
Arguments: map[string]any{"kind": "nonesuch"},
})
require.NoError(t, err)
assert.True(t, result.IsError)
}
func TestWhich(t *testing.T) {
session := connectTestClient(t, nil)
var output whichOutput
callToolInto(t, session, "which", map[string]any{"query": "join two files on a key"}, &output)
require.NotEmpty(t, output.Results)
assert.True(t, output.Confident)
assert.Equal(t, "join", output.Results[0].Name)
}
func TestPlaybookPromptAndResource(t *testing.T) {
session := connectTestClient(t, nil)
promptResult, err := session.GetPrompt(context.Background(), &mcpsdk.GetPromptParams{
Name: playbookPromptName,
})
require.NoError(t, err)
require.Len(t, promptResult.Messages, 1)
text := promptResult.Messages[0].Content.(*mcpsdk.TextContent).Text
assert.Contains(t, text, "Miller agent playbook")
resourceResult, err := session.ReadResource(context.Background(), &mcpsdk.ReadResourceParams{
URI: playbookResourceURI,
})
require.NoError(t, err)
require.Len(t, resourceResult.Contents, 1)
assert.Equal(t, text, resourceResult.Contents[0].Text)
}
// ----------------------------------------------------------------
// Subprocess-backed tools. These need a built mlr binary; they are skipped
// when the repo-root binary is absent (e.g. `go test` before `make build`).
// repoRootMlrPath locates the mlr binary built at the repository root,
// relative to this source file.
func repoRootMlrPath(t *testing.T) string {
t.Helper()
_, thisFile, _, ok := runtime.Caller(0)
require.True(t, ok)
exeName := "mlr"
if runtime.GOOS == "windows" {
exeName = "mlr.exe"
}
path := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", exeName)
if _, err := os.Stat(path); err != nil {
t.Skipf("no built mlr binary at %s; run make build first", path)
}
return path
}
func subprocessTestConfig(t *testing.T) *serverConfig {
return &serverConfig{
mlrExePath: repoRootMlrPath(t),
timeoutSeconds: defaultTimeoutSeconds,
maxOutputBytes: defaultMaxOutputBytes,
}
}
func TestValidateDSL(t *testing.T) {
session := connectTestClient(t, subprocessTestConfig(t))
var good validateDSLOutput
callToolInto(t, session, "validate_dsl", map[string]any{"expression": "$z = $x + $y"}, &good)
assert.True(t, good.Valid)
assert.Nil(t, good.Error)
var bad validateDSLOutput
callToolInto(t, session, "validate_dsl", map[string]any{"expression": "$z = $x +"}, &bad)
assert.False(t, bad.Valid)
require.NotNil(t, bad.Error)
assert.Equal(t, "dsl-parse-error", bad.Error.Kind)
result, err := session.CallTool(context.Background(), &mcpsdk.CallToolParams{
Name: "validate_dsl",
Arguments: map[string]any{"expression": "$a=1", "kind": "nonesuch"},
})
require.NoError(t, err)
assert.True(t, result.IsError)
}
func TestDescribeData(t *testing.T) {
session := connectTestClient(t, subprocessTestConfig(t))
csvPath := filepath.Join(t.TempDir(), "data.csv")
require.NoError(t, os.WriteFile(csvPath, []byte("a,b\n1,x\n2,y\n2,x\n"), 0o644))
var output describeDataOutput
callToolInto(t, session, "describe_data",
map[string]any{"files": []string{csvPath}, "input_format": "csv"}, &output)
require.Len(t, output.Fields, 2)
assert.Equal(t, "a", output.Fields[0]["field_name"])
assert.Equal(t, float64(2), output.Fields[0]["distinct_count"])
}
func TestRun(t *testing.T) {
session := connectTestClient(t, subprocessTestConfig(t))
var ok runOutput
callToolInto(t, session, "run", map[string]any{
"args": []string{"--icsv", "--ojson", "cat"},
"stdin_text": "a,b\n1,2\n",
}, &ok)
assert.Equal(t, 0, ok.ExitCode)
assert.Contains(t, ok.Stdout, `"a": 1`)
assert.False(t, ok.StdoutTruncated)
// Unknown verb: structured error parsed from MLR_ERRORS_JSON stderr.
var bad runOutput
callToolInto(t, session, "run", map[string]any{"args": []string{"frobnicate"}}, &bad)
assert.NotEqual(t, 0, bad.ExitCode)
require.NotNil(t, bad.Error)
assert.Equal(t, "unknown-verb", bad.Error.Kind)
assert.Equal(t, "frobnicate", bad.Error.Token)
// system() is blocked by the injected MLR_NO_SHELL.
var blocked runOutput
callToolInto(t, session, "run", map[string]any{
"args": []string{"-n", "put", `end{print system("echo owned")}`},
}, &blocked)
assert.Equal(t, 0, blocked.ExitCode)
assert.Contains(t, blocked.Stdout, "(error)")
assert.NotContains(t, blocked.Stdout, "owned")
}
func TestRunAllowShell(t *testing.T) {
config := subprocessTestConfig(t)
config.allowShell = true
session := connectTestClient(t, config)
var output runOutput
callToolInto(t, session, "run", map[string]any{
"args": []string{"-n", "put", `end{print system("echo owned")}`},
}, &output)
assert.Equal(t, 0, output.ExitCode)
assert.Contains(t, output.Stdout, "owned")
}
func TestRunOutputCap(t *testing.T) {
config := subprocessTestConfig(t)
config.maxOutputBytes = 64
session := connectTestClient(t, config)
var output runOutput
callToolInto(t, session, "run", map[string]any{
"args": []string{"--ojson", "seqgen", "--start", "1", "--stop", "100"},
}, &output)
assert.Equal(t, 0, output.ExitCode)
assert.True(t, output.StdoutTruncated)
assert.LessOrEqual(t, len(output.Stdout), 64)
}
func TestRunTimeout(t *testing.T) {
config := subprocessTestConfig(t)
session := connectTestClient(t, config)
var output runOutput
callToolInto(t, session, "run", map[string]any{
"args": []string{"--ojson", "seqgen", "--start", "1", "--stop", "9000000000"},
"timeout_seconds": 1,
}, &output)
assert.True(t, output.TimedOut)
assert.NotEqual(t, 0, output.ExitCode)
}
// ----------------------------------------------------------------
// Pure helpers
func TestCapWriter(t *testing.T) {
w := &capWriter{limit: 5}
n, err := w.Write([]byte("abc"))
require.NoError(t, err)
assert.Equal(t, 3, n)
n, err = w.Write([]byte("defgh"))
require.NoError(t, err)
assert.Equal(t, 5, n)
assert.Equal(t, "abcde", w.buf.String())
assert.True(t, w.truncated)
}
func TestParseStructuredError(t *testing.T) {
se := parseStructuredError("some preamble\n{\n \"error\": \"boom\",\n \"kind\": \"generic\"\n}\n")
require.NotNil(t, se)
assert.Equal(t, "boom", se.Error)
assert.Equal(t, "generic", se.Kind)
assert.Nil(t, parseStructuredError("plain prose error\n"))
assert.Nil(t, parseStructuredError(""))
assert.Nil(t, parseStructuredError("{\"unrelated\": true}"))
}
func TestPlaybookHasFrontmatter(t *testing.T) {
assert.True(t, strings.HasPrefix(playbookText, "---\n"))
assert.Contains(t, playbookText, "name: miller")
}

View file

@ -0,0 +1,100 @@
// In-process MCP tools: list_capabilities and which. These serve straight
// from the compiled-in help registries -- no subprocess -- via the exported
// accessors in pkg/terminals/help.
package mcp
import (
"context"
"fmt"
"github.com/johnkerl/miller/v6/pkg/cli"
"github.com/johnkerl/miller/v6/pkg/dsl/cst"
"github.com/johnkerl/miller/v6/pkg/terminals/help"
"github.com/johnkerl/miller/v6/pkg/transformers"
"github.com/johnkerl/miller/v6/pkg/version"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
type listCapabilitiesInput struct {
Index bool `json:"index,omitempty" jsonschema:"Return only {kind name summary} triples across the whole surface -- the cheap first call."`
Kind string `json:"kind,omitempty" jsonschema:"Restrict to one catalog kind: verb / function / flag / keyword."`
Names []string `json:"names,omitempty" jsonschema:"With kind: return full entries for just these names. Unknown names are skipped."`
}
// listCapabilitiesOutput is the one output shape for all three call styles:
// index-only, kind-filtered, or the full catalog. mlr_version and
// catalog_schema_version together form a cache key: the content can only
// change when one of them does.
type listCapabilitiesOutput struct {
MlrVersion string `json:"mlr_version"`
CatalogSchemaVersion int `json:"catalog_schema_version"`
Index []help.IndexEntryForJSON `json:"index,omitempty"`
Verbs []*transformers.VerbInfoForJSON `json:"verbs,omitempty"`
Functions []*cst.FunctionInfoForJSON `json:"functions,omitempty"`
Flags []*cli.FlagInfoForJSON `json:"flags,omitempty"`
Keywords []*cst.KeywordInfoForJSON `json:"keywords,omitempty"`
}
func listCapabilitiesHandler(
_ context.Context,
_ *mcpsdk.CallToolRequest,
input listCapabilitiesInput,
) (*mcpsdk.CallToolResult, listCapabilitiesOutput, error) {
output := listCapabilitiesOutput{
MlrVersion: version.STRING,
CatalogSchemaVersion: help.CatalogSchemaVersion(),
}
if input.Index {
output.Index = help.BuildIndex()
return nil, output, nil
}
switch input.Kind {
case "":
catalog := help.BuildFullCatalog()
output.Verbs = catalog.Verbs
output.Functions = catalog.Functions
output.Flags = catalog.Flags
output.Keywords = catalog.Keywords
case "verb", "verbs":
output.Verbs = help.CollectVerbs(input.Names)
case "function", "functions":
output.Functions = help.CollectFunctions(input.Names)
case "flag", "flags":
output.Flags = help.CollectFlags(input.Names)
case "keyword", "keywords":
output.Keywords = help.CollectKeywords(input.Names)
default:
return nil, output, fmt.Errorf(
"unsupported kind %q: use verb, function, flag, or keyword", input.Kind)
}
return nil, output, nil
}
type whichInput struct {
Query string `json:"query" jsonschema:"Natural-language intent e.g. \"join two files on a key\"."`
}
type whichOutput struct {
Confident bool `json:"confident"`
Results []help.WhichResultEntry `json:"results"`
}
func whichHandler(
_ context.Context,
_ *mcpsdk.CallToolRequest,
input whichInput,
) (*mcpsdk.CallToolResult, whichOutput, error) {
if input.Query == "" {
return nil, whichOutput{}, fmt.Errorf("query must be non-empty")
}
results, confident := help.WhichSearch(input.Query)
if results == nil {
results = []help.WhichResultEntry{}
}
return nil, whichOutput{Confident: confident, Results: results}, nil
}

View file

@ -0,0 +1,297 @@
// Subprocess-backed MCP tools: validate_dsl, describe_data, and run. These
// shell out to the same mlr binary that is serving MCP (os.Executable()), so
// an agent sees byte-identical behavior to a terminal, and the CLI paths'
// os.Exit/global-state/panic behaviors stay isolated from the server process.
//
// Every subprocess runs with MLR_ERRORS_JSON=1 (parse errors come back as a
// structured JSON document on stderr) and, unless the server was started with
// --allow-shell, MLR_NO_SHELL=1 (the DSL system/exec functions, piped
// redirects, and --prepipe fail cleanly). The MLR_NO_SHELL gate is one-way in
// the child: an agent-supplied argv cannot re-enable it.
package mcp
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
// structuredError mirrors the JSON document `mlr --errors-json` emits on
// stderr (climain.StructuredError). It is redeclared here rather than
// imported: pkg/climain dispatches into pkg/terminals, which imports this
// package, so importing climain from here would be an import cycle -- and
// this is a wire shape parsed from a subprocess, not a shared internal type.
type structuredError struct {
Error string `json:"error"`
Kind string `json:"kind"`
Token string `json:"token,omitempty"`
Verb string `json:"verb,omitempty"`
Hint string `json:"hint,omitempty"`
DidYouMean []string `json:"did_you_mean,omitempty"`
}
// capWriter accumulates writes up to a byte limit, discarding (but counting)
// the excess. It never reports an error, so the subprocess is not killed by
// its own verbosity -- output beyond the cap is simply dropped and the result
// marked truncated.
type capWriter struct {
buf strings.Builder
limit int64
truncated bool
}
func (w *capWriter) Write(p []byte) (int, error) {
room := w.limit - int64(w.buf.Len())
if room <= 0 {
w.truncated = true
return len(p), nil
}
if int64(len(p)) > room {
w.truncated = true
w.buf.Write(p[:room])
} else {
w.buf.Write(p)
}
return len(p), nil
}
// mlrResult is what runMlrSubprocess reports back to the tool handlers.
type mlrResult struct {
exitCode int
stdout string
stdoutTruncated bool
stderr string
stderrTruncated bool
timedOut bool
structured *structuredError
}
// runMlrSubprocess executes this same mlr binary with the given argv (not
// including the executable name), an optional stdin payload, and a wall-clock
// limit. Stdin is always redirected -- never inherited -- since the server's
// own stdin carries the MCP transport.
func runMlrSubprocess(
ctx context.Context,
config *serverConfig,
argv []string,
stdinText string,
timeoutSeconds int64,
) (*mlrResult, error) {
if timeoutSeconds <= 0 {
timeoutSeconds = config.timeoutSeconds
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, config.mlrExePath, argv...)
cmd.Stdin = strings.NewReader(stdinText)
stdout := &capWriter{limit: config.maxOutputBytes}
stderr := &capWriter{limit: config.maxOutputBytes}
cmd.Stdout = stdout
cmd.Stderr = stderr
env := append(os.Environ(), "MLR_ERRORS_JSON=1")
if !config.allowShell {
env = append(env, "MLR_NO_SHELL=1")
}
cmd.Env = env
err := cmd.Run()
result := &mlrResult{
stdout: stdout.buf.String(),
stdoutTruncated: stdout.truncated,
stderr: stderr.buf.String(),
stderrTruncated: stderr.truncated,
timedOut: ctx.Err() == context.DeadlineExceeded,
}
if err != nil {
exitErr, ok := err.(*exec.ExitError)
if !ok && !result.timedOut {
// The command could not be started at all.
return nil, fmt.Errorf("could not run %s: %w", config.mlrExePath, err)
}
if ok {
result.exitCode = exitErr.ExitCode()
} else {
result.exitCode = -1
}
result.structured = parseStructuredError(result.stderr)
}
return result, nil
}
// parseStructuredError extracts the MLR_ERRORS_JSON document from a failed
// command's stderr, if one is present. The document is the final thing
// written to stderr, so scan forward for a line starting the JSON object and
// try each candidate.
func parseStructuredError(stderrText string) *structuredError {
lines := strings.Split(stderrText, "\n")
for i, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "{") {
candidate := strings.Join(lines[i:], "\n")
var se structuredError
if err := json.Unmarshal([]byte(candidate), &se); err == nil && se.Error != "" {
return &se
}
}
}
return nil
}
// ----------------------------------------------------------------
// validate_dsl
type validateDSLInput struct {
Expression string `json:"expression" jsonschema:"The DSL expression to check e.g. '$z = $x + $y'."`
Kind string `json:"kind,omitempty" jsonschema:"put (default) or filter."`
}
type validateDSLOutput struct {
Valid bool `json:"valid"`
Error *structuredError `json:"error,omitempty"`
Stderr string `json:"stderr,omitempty"`
}
func validateDSLHandler(config *serverConfig) mcpsdk.ToolHandlerFor[validateDSLInput, validateDSLOutput] {
return func(
ctx context.Context,
_ *mcpsdk.CallToolRequest,
input validateDSLInput,
) (*mcpsdk.CallToolResult, validateDSLOutput, error) {
kind := input.Kind
if kind == "" {
kind = "put"
}
if kind != "put" && kind != "filter" {
return nil, validateDSLOutput{}, fmt.Errorf("kind must be put or filter, not %q", kind)
}
if input.Expression == "" {
return nil, validateDSLOutput{}, fmt.Errorf("expression must be non-empty")
}
result, err := runMlrSubprocess(ctx, config, []string{kind, "--explain", input.Expression}, "", 0)
if err != nil {
return nil, validateDSLOutput{}, err
}
output := validateDSLOutput{Valid: result.exitCode == 0}
if !output.Valid {
output.Error = result.structured
if output.Error == nil {
output.Stderr = result.stderr
}
}
return nil, output, nil
}
}
// ----------------------------------------------------------------
// describe_data
type describeDataInput struct {
Files []string `json:"files" jsonschema:"Input file paths."`
InputFormat string `json:"input_format,omitempty" jsonschema:"csv / tsv / json / jsonl / dkvp / nidx / pprint / xtab etc.; Miller's default is dkvp."`
MaxValues int64 `json:"max_values,omitempty" jsonschema:"List a field's distinct values only if it has at most this many; 0 uses the describe verb's default of 20."`
ExtraArgs []string `json:"extra_args,omitempty" jsonschema:"Additional main-level flags placed before the verb e.g. [\"--ifs\" \";\"] or [\"--implicit-csv-header\"]."`
}
type describeDataOutput struct {
Fields []map[string]any `json:"fields"`
Error *structuredError `json:"error,omitempty"`
Stderr string `json:"stderr,omitempty"`
}
func describeDataHandler(config *serverConfig) mcpsdk.ToolHandlerFor[describeDataInput, describeDataOutput] {
return func(
ctx context.Context,
_ *mcpsdk.CallToolRequest,
input describeDataInput,
) (*mcpsdk.CallToolResult, describeDataOutput, error) {
if len(input.Files) == 0 {
return nil, describeDataOutput{}, fmt.Errorf("files must be non-empty")
}
argv := []string{}
if input.InputFormat != "" {
argv = append(argv, "-i", input.InputFormat)
}
argv = append(argv, input.ExtraArgs...)
argv = append(argv, "--ojson", "describe")
if input.MaxValues > 0 {
argv = append(argv, "-n", strconv.FormatInt(input.MaxValues, 10))
}
argv = append(argv, input.Files...)
result, err := runMlrSubprocess(ctx, config, argv, "", 0)
if err != nil {
return nil, describeDataOutput{}, err
}
if result.exitCode != 0 {
return nil, describeDataOutput{Error: result.structured, Stderr: result.stderr},
fmt.Errorf("mlr describe exited %d", result.exitCode)
}
var fields []map[string]any
if err := json.Unmarshal([]byte(result.stdout), &fields); err != nil {
return nil, describeDataOutput{}, fmt.Errorf("could not parse mlr describe output: %w", err)
}
return nil, describeDataOutput{Fields: fields}, nil
}
}
// ----------------------------------------------------------------
// run
type runInput struct {
Args []string `json:"args" jsonschema:"The mlr argv without the leading executable name; one element per shell word e.g. [\"--icsv\" \"--ojson\" \"cat\" \"data.csv\"]."`
StdinText string `json:"stdin_text,omitempty" jsonschema:"Text supplied to the command's stdin; commands never read the server's own stdin."`
TimeoutSeconds int64 `json:"timeout_seconds,omitempty" jsonschema:"Wall-clock limit for this call; 0 uses the server default."`
}
type runOutput struct {
ExitCode int `json:"exit_code"`
Stdout string `json:"stdout"`
StdoutTruncated bool `json:"stdout_truncated,omitempty"`
Stderr string `json:"stderr,omitempty"`
StderrTruncated bool `json:"stderr_truncated,omitempty"`
TimedOut bool `json:"timed_out,omitempty"`
Error *structuredError `json:"error,omitempty"`
}
func runHandler(config *serverConfig) mcpsdk.ToolHandlerFor[runInput, runOutput] {
return func(
ctx context.Context,
_ *mcpsdk.CallToolRequest,
input runInput,
) (*mcpsdk.CallToolResult, runOutput, error) {
if len(input.Args) == 0 {
return nil, runOutput{}, fmt.Errorf("args must be non-empty")
}
result, err := runMlrSubprocess(ctx, config, input.Args, input.StdinText, input.TimeoutSeconds)
if err != nil {
return nil, runOutput{}, err
}
return nil, runOutput{
ExitCode: result.exitCode,
Stdout: result.stdout,
StdoutTruncated: result.stdoutTruncated,
Stderr: result.stderr,
StderrTruncated: result.stderrTruncated,
TimedOut: result.timedOut,
Error: result.structured,
}, nil
}
}

View file

@ -15,6 +15,7 @@ const (
TerminalList = "terminal-list" TerminalList = "terminal-list"
Completion = "completion" Completion = "completion"
Help = "help" Help = "help"
Mcp = "mcp"
Regtest = "regtest" Regtest = "regtest"
Repl = "repl" Repl = "repl"
Script = "script" Script = "script"
@ -27,6 +28,7 @@ var Names = []string{
TerminalList, TerminalList,
Completion, Completion,
Help, Help,
Mcp,
Regtest, Regtest,
Repl, Repl,
Script, Script,

View file

@ -10,6 +10,7 @@ import (
"github.com/johnkerl/miller/v6/pkg/terminals/completion" "github.com/johnkerl/miller/v6/pkg/terminals/completion"
"github.com/johnkerl/miller/v6/pkg/terminals/help" "github.com/johnkerl/miller/v6/pkg/terminals/help"
"github.com/johnkerl/miller/v6/pkg/terminals/mcp"
"github.com/johnkerl/miller/v6/pkg/terminals/registry" "github.com/johnkerl/miller/v6/pkg/terminals/registry"
"github.com/johnkerl/miller/v6/pkg/terminals/regtest" "github.com/johnkerl/miller/v6/pkg/terminals/regtest"
"github.com/johnkerl/miller/v6/pkg/terminals/repl" "github.com/johnkerl/miller/v6/pkg/terminals/repl"
@ -36,6 +37,7 @@ func init() {
{registry.TerminalList, terminalListMain}, {registry.TerminalList, terminalListMain},
{registry.Completion, completion.CompletionMain}, {registry.Completion, completion.CompletionMain},
{registry.Help, help.HelpMain}, {registry.Help, help.HelpMain},
{registry.Mcp, mcp.McpMain},
{registry.Regtest, regtest.RegTestMain}, {registry.Regtest, regtest.RegTestMain},
{registry.Repl, repl.ReplMain}, {registry.Repl, repl.ReplMain},
{registry.Script, script.ScriptMain}, {registry.Script, script.ScriptMain},

View file

@ -282,7 +282,7 @@ heterogeneous input, `-n` cap, `-n 0`, null-vs-empty, bad-option); docs:
--- ---
## PR 7 — MCP server + Agent Skill (the loop) ## PR 7 — MCP server + Agent Skill (the loop) *(landed)*
**Goal.** Package the above so an agent gets both the *surface* and the *loop*. **Goal.** Package the above so an agent gets both the *surface* and the *loop*.
@ -293,6 +293,91 @@ heterogeneous input, `-n` cap, `-n 0`, null-vs-empty, bad-option); docs:
validate → run loop — the recipe is what makes a CLI "shine when driven by an validate → run loop — the recipe is what makes a CLI "shine when driven by an
agent," beyond the raw tool surface. agent," beyond the raw tool surface.
**Design (worked out; ready to build against).**
- **Transport: stdio, not HTTP.** MCP's standard transport for local tools is
JSON-RPC 2.0 over stdin/stdout: the client (Claude Code, Claude Desktop,
Cursor, …) spawns the server as a subprocess. No localhost port, no auth
story, no firewall prompts, works offline, dies with the client. MCP's
streamable-HTTP transport exists for *remote* servers only; if ever wanted it
could be a later opt-in flag, but nothing here needs it.
- **Entry point: a new terminal in the existing binary — `mlr mcp`** — not a
separate executable. Registration is `claude mcp add miller -- mlr mcp`
(or the equivalent JSON config). Shipping inside `mlr` means zero extra
install and the server is version-locked to the binary, which is exactly
what the `mlr_version` + `catalog_schema_version` cache keying assumes.
- **Dependency decision (the main open call before starting):** official
`modelcontextprotocol/go-sdk`, vs. hand-rolling the small protocol subset
needed (`initialize`, `tools/list`, `tools/call`, `ping`) over
`encoding/json` — a few hundred lines, stable wire format. SDK leans toward
spec conformance as MCP evolves; hand-rolling fits Miller's
near-stdlib-only ethos.
- **Tools** — thin wrappers over PR1PR6, nothing new underneath:
- `list_capabilities` — PR1 catalog / PR2 index, with kind/name filters so
an agent fetches one verb entry cheaply.
- `which` — intent → ranked verbs (PR2).
- `validate_dsl` — the PR5 `--explain` path; failures return the PR4
structured-error document.
- `describe_data` — the PR6 verb with `--ojson`.
- `run` — execute an mlr command line; returns stdout (size-capped, with a
truncation note), stderr, exit code, and the parsed `--errors-json`
document when one fired.
- **Execution model: in-process for pure lookups, subprocess for execution.**
Catalog and `which` are pure functions over compiled-in registries — serve
in-process. `validate_dsl`, `describe_data`, and `run` shell out to the same
binary via `os.Executable()`: the CLI paths call `os.Exit`, mutate global
option state, and can panic, so subprocess isolation is simpler and
guarantees the agent sees byte-identical behavior to a terminal.
- **Safety wrinkle — `run` is arbitrary-code-execution by design.** The DSL
has `system()` and `exec()`, and verbs like `tee`/`split` write files. MCP
clients prompt per tool call, but the server should still enforce: a
timeout, an output cap, and — as a small prerequisite piece of this PR — a
new `--no-shell`-style flag in Miller itself that the server sets by default
(with explicit opt-out), so `system`/`exec` fail cleanly unless the user
asks. The `run` tool carries the MCP `destructiveHint` annotation.
- **Skill half, single-sourced twice.** A playbook encoding *describe →
index/which → fetch entry → validate → run*, branching on structured error
`kind` and `did_you_mean`. Ship the same content as an in-repo Agent Skill
(SKILL.md) and as an MCP prompt/resource exposed by the server itself, so
agents that only see the server still get the loop.
- **Tests.** Golden-transcript tests that spawn `mlr mcp` and drive it over
stdio (initialize → tools/list → each tool), plus unit tests on the tool
handlers. The `MLR_AGENT` open question below lands here: the server makes
it mostly moot (it sets flags explicitly), but it's worth resolving for the
skill-without-server case.
**Landed.** As designed above, with these notes:
- Dependency call resolved: the official `modelcontextprotocol/go-sdk` (v1.6).
- `--no-shell` / `MLR_NO_SHELL` prerequisite landed as a one-way gate in
`pkg/lib/shell_gate.go` (can be disabled at startup, never re-enabled, so
agent argv cannot override an env-level opt-out), enforced at all three
data-path shell-out sites: `BIF_system`/`BIF_exec` (clean error values),
`lib.Open{Outbound,Inbound}HalfPipe` (piped redirects, `--prepipe`).
Regression cases in `test/cases/no-shell/`.
- Server is `pkg/terminals/mcp/`, registered as terminal `mcp`. Subprocess
tools inject `MLR_ERRORS_JSON=1` always and `MLR_NO_SHELL=1` unless
`--allow-shell`; per-run wall-clock timeout (`--timeout`, default 60s,
per-call overridable) and stdout/stderr byte cap (`--max-output-bytes`,
default 1 MiB) with explicit `*_truncated`/`timed_out` flags in the output.
Subprocess stdin is always redirected (inline `stdin_text` or empty), never
inherited -- the server's own stdin carries the MCP transport.
- The help package grew an exported API (`pkg/terminals/help/api.go`) so the
server serves catalog/index/which in-process from the same registries; the
structured-error DTO is mirrored (not imported) in the mcp package since
climain→terminals→mcp would cycle.
- Playbook is `pkg/terminals/mcp/SKILL.md` (Agent Skill frontmatter),
go:embed-ed and exposed as prompt `miller-playbook` + resource
`miller://playbook`.
- Tests are SDK in-memory-transport unit tests (`server_test.go`) covering
every tool plus annotations, output-cap, timeout, allow-shell, and
no-shell-blocks-system paths; subprocess-backed cases skip when the
repo-root binary is absent. Regtest golden: `mlr mcp --help`
(`test/cases/mcp/`). Docs: `docs/src/mcp-server.md.in`, in the mkdocs nav
under "Miller in more detail".
- `MLR_AGENT` open question: resolved as not-needed for the server (it sets
env explicitly); skill-without-server users set `MLR_HELP_JSON` /
`MLR_ERRORS_JSON` / `MLR_NO_SHELL` individually.
--- ---
## Open questions (carry into the relevant PR; not blocking the roadmap) ## Open questions (carry into the relevant PR; not blocking the roadmap)

1
test/cases/mcp/help/cmd Normal file
View file

@ -0,0 +1 @@
mlr mcp --help

View file

View file

@ -0,0 +1,31 @@
Usage: mlr mcp [options]
Runs a Model Context Protocol (MCP) server exposing Miller to AI agents.
The server speaks JSON-RPC over stdin/stdout (MCP "stdio" transport); it is
meant to be spawned by an MCP client rather than run interactively. Example
registration, for Claude Code:
claude mcp add miller -- mlr mcp
Tools exposed:
list_capabilities the mlr help --as-json catalog/index, filterable
which intent -> ranked matching verbs/functions/flags/keywords
validate_dsl parse/type-check a put/filter expression without running it
describe_data field names/types/cardinality/values of input data
run run an mlr command line
Also exposed: an agent playbook, as MCP prompt "miller-playbook" and MCP
resource "miller://playbook", encoding the discover -> constrain -> validate
-> run loop.
Commands started by the run/validate_dsl/describe_data tools are run with
MLR_NO_SHELL=1 -- the DSL system/exec functions, piped redirects, and
--prepipe/--prepipex fail cleanly -- and with MLR_ERRORS_JSON=1 so errors
come back structured.
Options:
--allow-shell Do not set MLR_NO_SHELL=1 on subprocesses, re-enabling
the DSL system/exec functions for agent-run commands.
--timeout {seconds} Default wall-clock limit for the run tool (default 60).
--max-output-bytes {n} Cap captured stdout/stderr per command (default 1048576).
-h or --help Show this message.

View file

@ -0,0 +1 @@
mlr -n put -f ${CASEDIR}/mlr

View file

@ -0,0 +1 @@
MLR_NO_SHELL=1

View file

View file

@ -0,0 +1 @@
(error)

View file

@ -0,0 +1 @@
end{print exec("echo",["hi"])}

View file

@ -0,0 +1 @@
mlr --no-shell -n put -f ${CASEDIR}/mlr

View file

@ -0,0 +1 @@
mlr: could not launch command "cat" for pipe-to: piped redirects are disabled by --no-shell / MLR_NO_SHELL

View file

View file

@ -0,0 +1 @@
end{print | "cat", "hello"}

View file

View file

@ -0,0 +1 @@
mlr --no-shell --prepipe cat cat test/input/abixy

View file

@ -0,0 +1 @@
mlr: --prepipe/--prepipex are disabled by --no-shell / MLR_NO_SHELL

View file

View file

View file

@ -0,0 +1 @@
mlr -n put -f ${CASEDIR}/mlr

View file

View file

@ -0,0 +1 @@
hi

View file

@ -0,0 +1 @@
end{print system("echo hi")}

View file

@ -0,0 +1 @@
mlr -n --no-shell put -f ${CASEDIR}/mlr

View file

View file

@ -0,0 +1 @@
(error)

View file

@ -0,0 +1 @@
end{print system("echo hi")}