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>
297 lines
9.8 KiB
Go
297 lines
9.8 KiB
Go
// 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
|
|
}
|
|
}
|