mirror of
https://github.com/johnkerl/miller.git
synced 2026-07-17 16:38:54 +00:00
* 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:
parent
d415ca7655
commit
41f5188bd0
52 changed files with 1608 additions and 2 deletions
4
.gitattributes
vendored
4
.gitattributes
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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':
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
111
docs/src/mcp-server.md
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
<!--- PLEASE DO NOT EDIT DIRECTLY. EDIT THE .md.in FILE PLEASE. --->
|
||||||
|
<div>
|
||||||
|
<span class="quicklinks">
|
||||||
|
Quick links:
|
||||||
|
|
||||||
|
<a class="quicklink" href="../reference-main-flag-list/index.html">Flags</a>
|
||||||
|
|
||||||
|
<a class="quicklink" href="../reference-verbs/index.html">Verbs</a>
|
||||||
|
|
||||||
|
<a class="quicklink" href="../reference-dsl-builtin-functions/index.html">Functions</a>
|
||||||
|
|
||||||
|
<a class="quicklink" href="../glossary/index.html">Glossary</a>
|
||||||
|
|
||||||
|
<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
62
docs/src/mcp-server.md.in
Normal 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.
|
||||||
|
|
@ -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
6
go.mod
|
|
@ -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
16
go.sum
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
10
man/mlr.1
10
man/mlr.1
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
39
pkg/lib/shell_gate.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
65
pkg/terminals/help/api.go
Normal 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
|
||||||
|
}
|
||||||
83
pkg/terminals/mcp/SKILL.md
Normal file
83
pkg/terminals/mcp/SKILL.md
Normal 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.
|
||||||
165
pkg/terminals/mcp/mcp_main.go
Normal file
165
pkg/terminals/mcp/mcp_main.go
Normal 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
134
pkg/terminals/mcp/server.go
Normal 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
|
||||||
|
}
|
||||||
320
pkg/terminals/mcp/server_test.go
Normal file
320
pkg/terminals/mcp/server_test.go
Normal 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")
|
||||||
|
}
|
||||||
100
pkg/terminals/mcp/tools_catalog.go
Normal file
100
pkg/terminals/mcp/tools_catalog.go
Normal 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
|
||||||
|
}
|
||||||
297
pkg/terminals/mcp/tools_exec.go
Normal file
297
pkg/terminals/mcp/tools_exec.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
|
|
||||||
|
|
@ -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 PR1–PR6, 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
1
test/cases/mcp/help/cmd
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
mlr mcp --help
|
||||||
0
test/cases/mcp/help/experr
Normal file
0
test/cases/mcp/help/experr
Normal file
31
test/cases/mcp/help/expout
Normal file
31
test/cases/mcp/help/expout
Normal 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.
|
||||||
1
test/cases/no-shell/exec-envvar/cmd
Normal file
1
test/cases/no-shell/exec-envvar/cmd
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
mlr -n put -f ${CASEDIR}/mlr
|
||||||
1
test/cases/no-shell/exec-envvar/env
Normal file
1
test/cases/no-shell/exec-envvar/env
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
MLR_NO_SHELL=1
|
||||||
0
test/cases/no-shell/exec-envvar/experr
Normal file
0
test/cases/no-shell/exec-envvar/experr
Normal file
1
test/cases/no-shell/exec-envvar/expout
Normal file
1
test/cases/no-shell/exec-envvar/expout
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
(error)
|
||||||
1
test/cases/no-shell/exec-envvar/mlr
Normal file
1
test/cases/no-shell/exec-envvar/mlr
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
end{print exec("echo",["hi"])}
|
||||||
1
test/cases/no-shell/pipe-to/cmd
Normal file
1
test/cases/no-shell/pipe-to/cmd
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
mlr --no-shell -n put -f ${CASEDIR}/mlr
|
||||||
1
test/cases/no-shell/pipe-to/experr
Normal file
1
test/cases/no-shell/pipe-to/experr
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
mlr: could not launch command "cat" for pipe-to: piped redirects are disabled by --no-shell / MLR_NO_SHELL
|
||||||
0
test/cases/no-shell/pipe-to/expout
Normal file
0
test/cases/no-shell/pipe-to/expout
Normal file
1
test/cases/no-shell/pipe-to/mlr
Normal file
1
test/cases/no-shell/pipe-to/mlr
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
end{print | "cat", "hello"}
|
||||||
0
test/cases/no-shell/pipe-to/should-fail
Normal file
0
test/cases/no-shell/pipe-to/should-fail
Normal file
1
test/cases/no-shell/prepipe/cmd
Normal file
1
test/cases/no-shell/prepipe/cmd
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
mlr --no-shell --prepipe cat cat test/input/abixy
|
||||||
1
test/cases/no-shell/prepipe/experr
Normal file
1
test/cases/no-shell/prepipe/experr
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
mlr: --prepipe/--prepipex are disabled by --no-shell / MLR_NO_SHELL
|
||||||
0
test/cases/no-shell/prepipe/expout
Normal file
0
test/cases/no-shell/prepipe/expout
Normal file
0
test/cases/no-shell/prepipe/should-fail
Normal file
0
test/cases/no-shell/prepipe/should-fail
Normal file
1
test/cases/no-shell/shell-allowed/cmd
Normal file
1
test/cases/no-shell/shell-allowed/cmd
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
mlr -n put -f ${CASEDIR}/mlr
|
||||||
0
test/cases/no-shell/shell-allowed/experr
Normal file
0
test/cases/no-shell/shell-allowed/experr
Normal file
1
test/cases/no-shell/shell-allowed/expout
Normal file
1
test/cases/no-shell/shell-allowed/expout
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
hi
|
||||||
1
test/cases/no-shell/shell-allowed/mlr
Normal file
1
test/cases/no-shell/shell-allowed/mlr
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
end{print system("echo hi")}
|
||||||
1
test/cases/no-shell/system/cmd
Normal file
1
test/cases/no-shell/system/cmd
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
mlr -n --no-shell put -f ${CASEDIR}/mlr
|
||||||
0
test/cases/no-shell/system/experr
Normal file
0
test/cases/no-shell/system/experr
Normal file
1
test/cases/no-shell/system/expout
Normal file
1
test/cases/no-shell/system/expout
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
(error)
|
||||||
1
test/cases/no-shell/system/mlr
Normal file
1
test/cases/no-shell/system/mlr
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
end{print system("echo hi")}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue