From 632171dc4ae341c6115b4efdf517748f4ddf75bd Mon Sep 17 00:00:00 2001 From: Gerard Louis Recinto Date: Mon, 5 Oct 2026 00:10:32 -0700 Subject: [PATCH 1/2] sop-mcp-server: help, version, unknown-command errors, and --json for check and demo Thanks, Gerard Recinto --- cmd/sop-mcp-server/check.go | 95 +++++++++++++++++++-- cmd/sop-mcp-server/cli.go | 85 +++++++++++++++++++ cmd/sop-mcp-server/cli_test.go | 147 +++++++++++++++++++++++++++++++++ cmd/sop-mcp-server/demo.go | 105 ++++++++++++++++------- cmd/sop-mcp-server/main.go | 19 +---- cmd/sop-mcp-server/setup.go | 10 ++- 6 files changed, 406 insertions(+), 55 deletions(-) create mode 100644 cmd/sop-mcp-server/cli.go create mode 100644 cmd/sop-mcp-server/cli_test.go diff --git a/cmd/sop-mcp-server/check.go b/cmd/sop-mcp-server/check.go index d2c8bb58d..d5a1e713a 100644 --- a/cmd/sop-mcp-server/check.go +++ b/cmd/sop-mcp-server/check.go @@ -1,9 +1,9 @@ package main import ( + "encoding/json" "fmt" "io" - "os" "sort" "strings" @@ -19,19 +19,84 @@ func loadRunbooks(path string) (*runbookstore.Store, []string, error) { return store, names, err } +// checkResult is the machine-readable form of "check --json". +type checkResult struct { + Valid bool `json:"valid"` + File string `json:"file"` + Error string `json:"error,omitempty"` + Workflows []checkWorkflow `json:"workflows,omitempty"` +} + +type checkWorkflow struct { + Name string `json:"name"` + Steps []checkStep `json:"steps"` + Safety []checkRule `json:"safety"` + Reachability []checkReach `json:"reachability"` +} + +type checkStep struct { + ID string `json:"id"` + Requires []string `json:"requires"` + Establishes []string `json:"establishes"` +} + +type checkRule struct { + Name string `json:"name"` + Forbidden string `json:"forbidden"` + Requires string `json:"requires"` +} + +type checkReach struct { + Name string `json:"name"` + Target string `json:"target"` +} + +const checkUsage = "usage: sop-mcp-server check [--json] \n" + // runCheck implements "sop-mcp-server check ": it loads the file and -// prints what the barrier will enforce. It returns the process exit code. +// prints what the barrier will enforce. With --json it prints the same as one +// JSON document, including the error for a file that does not load, so a script +// can read the reason. It returns the process exit code. func runCheck(args []string, out, errw io.Writer) int { + if isHelp(args) { + fmt.Fprint(out, checkUsage) + return 0 + } + args, asJSON := popFlag(args, "--json") if len(args) != 1 { - fmt.Fprintln(errw, "usage: sop-mcp-server check ") + fmt.Fprint(errw, checkUsage) return 2 } store, names, err := loadRunbooks(args[0]) if err != nil { - fmt.Fprintln(errw, "check:", err) + if asJSON { + writeJSON(out, checkResult{Valid: false, File: args[0], Error: err.Error()}) + } else { + fmt.Fprintln(errw, "check:", err) + } return 1 } + if asJSON { + res := checkResult{Valid: true, File: args[0]} + for _, name := range names { + wf, _ := store.Workflow(name) + cw := checkWorkflow{Name: name, Steps: []checkStep{}, Safety: []checkRule{}, Reachability: []checkReach{}} + for _, s := range orderedSteps(wf) { + cw.Steps = append(cw.Steps, checkStep{ID: string(s.ID), Requires: sortedStates(s.Requires), Establishes: sortedStates(s.Establishes)}) + } + for _, r := range wf.Safety { + cw.Safety = append(cw.Safety, checkRule{Name: r.Name, Forbidden: string(r.Forbidden), Requires: string(r.Requires)}) + } + for _, r := range wf.Reachability { + cw.Reachability = append(cw.Reachability, checkReach{Name: r.Name, Target: string(r.Target)}) + } + res.Workflows = append(res.Workflows, cw) + } + writeJSON(out, res) + return 0 + } + noun := "workflows" if len(names) == 1 { noun = "workflow" @@ -53,6 +118,23 @@ func runCheck(args []string, out, errw io.Writer) int { return 0 } +// sortedStates returns states as sorted strings, never nil, so the JSON shows +// an empty list rather than null. +func sortedStates(in []verify.State) []string { + out := make([]string, len(in)) + for i, st := range in { + out[i] = string(st) + } + sort.Strings(out) + return out +} + +func writeJSON(w io.Writer, v any) { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} + // orderedSteps lists the steps that need nothing first, then the rest, each // group by ID, so the output does not depend on map order. func orderedSteps(wf *verify.Workflow) []verify.Step { @@ -81,8 +163,3 @@ func needs(s verify.Step) string { sort.Strings(req) return "needs " + strings.Join(req, ", ") } - -// checkMain wires runCheck to the real streams. -func checkMain(args []string) int { - return runCheck(args, os.Stdout, os.Stderr) -} diff --git a/cmd/sop-mcp-server/cli.go b/cmd/sop-mcp-server/cli.go new file mode 100644 index 000000000..f1b6f0983 --- /dev/null +++ b/cmd/sop-mcp-server/cli.go @@ -0,0 +1,85 @@ +package main + +import ( + "fmt" + "io" + "runtime/debug" +) + +const usageText = `sop-mcp-server runs the Joltrin verification barrier as an MCP server. + +Usage: + sop-mcp-server serve over stdio, which is how an agent launches it + sop-mcp-server setup [--apply] register it with Claude Code, Codex and the Gemini CLI + [--lessons DIR] [--runbooks FILE] + sop-mcp-server check [--json] FILE show what a runbook file enforces, or why it does not load + sop-mcp-server demo [--json] watch the barrier block a database drop until a backup is validated + sop-mcp-server version print the version + sop-mcp-server help print this text + +Environment: + SOP_RUNBOOKS JSON file with your own runbooks (default: the built-in db-maintenance example) + SOP_LESSONS_DIR folder that turns on memory of earlier blocks + +Exit codes: 0 success, 1 failure, 2 usage error. +` + +// version reports the module version the binary was built from, which is the +// release tag for a published binary or for go install, and "dev" for a build +// from a working copy. +func version() string { + if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" && bi.Main.Version != "(devel)" { + return bi.Main.Version + } + return "dev" +} + +// dispatch runs a command and reports the exit code. serve is true when the +// arguments ask for the server itself, in which case the caller starts it. An +// unknown word is a usage error and never starts a server, because a typo that +// silently starts one just sits waiting on stdin. +func dispatch(args []string, out, errw io.Writer) (code int, serve bool) { + if len(args) == 0 { + return 0, true + } + switch args[0] { + case "stdio", "serve": + return 0, true + case "help", "-h", "--help": + fmt.Fprint(out, usageText) + return 0, false + case "version", "-v", "--version": + fmt.Fprintf(out, "sop-mcp-server %s\n", version()) + return 0, false + case "setup": + return setupMain(args[1:], out, errw), false + case "check": + return runCheck(args[1:], out, errw), false + case "demo": + return runDemoArgs(args[1:], out, errw), false + } + fmt.Fprintf(errw, "sop-mcp-server: unknown command %q\nRun \"sop-mcp-server help\" for the commands.\n", args[0]) + return 2, false +} + +// popFlag removes every occurrence of flag from args and reports whether it was +// there, so a flag works before or after the file name. +func popFlag(args []string, flag string) (rest []string, found bool) { + for _, a := range args { + if a == flag { + found = true + continue + } + rest = append(rest, a) + } + return rest, found +} + +func isHelp(args []string) bool { + for _, a := range args { + if a == "-h" || a == "--help" || a == "help" { + return true + } + } + return false +} diff --git a/cmd/sop-mcp-server/cli_test.go b/cmd/sop-mcp-server/cli_test.go new file mode 100644 index 000000000..6b0b07b73 --- /dev/null +++ b/cmd/sop-mcp-server/cli_test.go @@ -0,0 +1,147 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +func cli(t *testing.T, args ...string) (code int, serve bool, out, errw string) { + t.Helper() + var o, e bytes.Buffer + code, serve = dispatch(args, &o, &e) + return code, serve, o.String(), e.String() +} + +func TestNoArgumentsAndTheStdioAliasesServe(t *testing.T) { + for _, args := range [][]string{nil, {"stdio"}, {"serve"}} { + code, serve, out, errw := cli(t, args...) + if !serve || code != 0 || out != "" || errw != "" { + t.Errorf("%v: serve=%v code=%d out=%q err=%q, want a silent start", args, serve, code, out, errw) + } + } +} + +func TestHelpListsEveryCommandAndNeverStartsAServer(t *testing.T) { + for _, arg := range []string{"help", "-h", "--help"} { + code, serve, out, _ := cli(t, arg) + if serve || code != 0 { + t.Errorf("%s: serve=%v code=%d", arg, serve, code) + } + for _, want := range []string{"setup", "check", "demo", "version", "SOP_RUNBOOKS", "SOP_LESSONS_DIR"} { + if !strings.Contains(out, want) { + t.Errorf("%s: help is missing %q:\n%s", arg, want, out) + } + } + } +} + +func TestVersionPrintsOneLine(t *testing.T) { + for _, arg := range []string{"version", "-v", "--version"} { + code, serve, out, _ := cli(t, arg) + if serve || code != 0 || !strings.HasPrefix(out, "sop-mcp-server ") || strings.Count(out, "\n") != 1 { + t.Errorf("%s: serve=%v code=%d out=%q", arg, serve, code, out) + } + } +} + +func TestAnUnknownCommandIsAUsageErrorNotASilentServer(t *testing.T) { + code, serve, out, errw := cli(t, "bogus") + if serve || code != 2 || out != "" { + t.Errorf("serve=%v code=%d out=%q", serve, code, out) + } + if !strings.Contains(errw, `unknown command "bogus"`) || !strings.Contains(errw, "help") { + t.Errorf("stderr should name the command and point at help:\n%s", errw) + } +} + +func TestSubcommandHelpIsNotAnError(t *testing.T) { + for _, sub := range []string{"setup", "check", "demo"} { + code, serve, out, errw := cli(t, sub, "--help") + if serve || code != 0 { + t.Errorf("%s --help: serve=%v code=%d err=%q", sub, serve, code, errw) + } + if !strings.Contains(out+errw, "Usage") && !strings.Contains(out+errw, "usage") { + t.Errorf("%s --help should show usage:\nout=%s\nerr=%s", sub, out, errw) + } + } +} + +func TestCheckJSONIsMachineReadable(t *testing.T) { + path := writeRunbook(t, goodRunbook) + for _, args := range [][]string{{"check", "--json", path}, {"check", path, "--json"}} { + code, _, out, errw := cli(t, args...) + if code != 0 { + t.Fatalf("%v: exit %d, stderr:\n%s", args, code, errw) + } + var got struct { + Valid bool `json:"valid"` + Workflows []struct { + Name string `json:"name"` + Steps []struct { + ID string `json:"id"` + Requires []string `json:"requires"` + Establishes []string `json:"establishes"` + } `json:"steps"` + Safety []struct { + Name string `json:"name"` + Forbidden string `json:"forbidden"` + Requires string `json:"requires"` + } `json:"safety"` + } `json:"workflows"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("%v: not JSON: %v\n%s", args, err, out) + } + if !got.Valid || len(got.Workflows) != 1 || got.Workflows[0].Name != "deploy" || + len(got.Workflows[0].Steps) != 3 || len(got.Workflows[0].Safety) != 1 || + got.Workflows[0].Safety[0].Name != "no-deploy-without-approval" { + t.Errorf("%v: unexpected result: %+v", args, got) + } + } +} + +func TestCheckJSONReportsALoadErrorAsJSONToo(t *testing.T) { + code, _, out, _ := cli(t, "check", "--json", writeRunbook(t, badRunbook)) + if code != 1 { + t.Errorf("exit %d, want 1", code) + } + var got struct { + Valid bool `json:"valid"` + Error string `json:"error"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil || got.Valid || !strings.Contains(got.Error, `"typo"`) { + t.Errorf("want {valid:false,error:...}, got %q (%v)", out, err) + } +} + +func TestDemoJSONListsEachDecisionInOrder(t *testing.T) { + code, _, out, _ := cli(t, "demo", "--json") + if code != 0 { + t.Fatalf("exit %d", code) + } + var got struct { + Steps []struct { + Step string `json:"step"` + Decision string `json:"decision"` + Reason string `json:"reason"` + } `json:"steps"` + Trace []string `json:"trace"` + } + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("not JSON: %v\n%s", err, out) + } + want := []string{"blocked", "allowed", "allowed", "allowed"} + if len(got.Steps) != 4 { + t.Fatalf("steps = %+v", got.Steps) + } + for i, d := range want { + if got.Steps[i].Decision != d { + t.Errorf("step %d (%s) decision = %s, want %s", i, got.Steps[i].Step, got.Steps[i].Decision, d) + } + } + if got.Steps[0].Reason == "" || strings.Join(got.Trace, ",") != "take_backup,validate_backup,drop_prod_db" { + t.Errorf("reason or trace wrong: %+v", got) + } +} diff --git a/cmd/sop-mcp-server/demo.go b/cmd/sop-mcp-server/demo.go index 8b1ca6d77..fccef2e29 100644 --- a/cmd/sop-mcp-server/demo.go +++ b/cmd/sop-mcp-server/demo.go @@ -3,50 +3,99 @@ package main import ( "fmt" "io" - "os" "github.com/sharedcode/joltrin/v5/tools/runbookstore" "github.com/sharedcode/joltrin/v5/verify" ) -// runDemo implements "sop-mcp-server demo": the barrier blocking a database -// drop until a backup is taken and validated, using the same db-maintenance -// runbook and the same check the server runs. It needs no Go and no agent, and -// finishes at once. It returns the process exit code. -func runDemo(out io.Writer) int { +// demoStep is one decision the barrier made during the demo. +type demoStep struct { + Step string `json:"step"` + Decision string `json:"decision"` // "blocked" or "allowed" + Reason string `json:"reason,omitempty"` + Say string `json:"-"` // what the agent says before this step, for the text output +} + +type demoResult struct { + Steps []demoStep `json:"steps"` + Trace []string `json:"trace"` +} + +const demoUsage = "usage: sop-mcp-server demo [--json]\n" + +// playDemo runs the barrier on the db-maintenance runbook: an agent tries to +// drop the production database, is blocked, takes and validates a backup, and +// is then allowed. It is the same runbook and the same check the server runs. +func playDemo() (demoResult, error) { wf, err := runbookstore.DBMaintenanceWorkflow() if err != nil { - fmt.Fprintln(out, "demo:", err) - return 1 + return demoResult{}, err } trace := verify.NewTrace() + var res demoResult - fmt.Fprintln(out, `agent: "backup looks fine, dropping prod now"`) - err = wf.CheckAndCommit(trace, "drop_prod_db") - if !verify.IsViolation(err) { - fmt.Fprintln(out, "demo: drop_prod_db should have been blocked, got:", err) - return 1 + attempts := []struct{ step, say string }{ + {"drop_prod_db", `agent: "backup looks fine, dropping prod now"`}, + {"take_backup", "agent: takes a real backup first, then validates it"}, + {"validate_backup", ""}, + {"drop_prod_db", "agent: backup is validated, retrying the drop"}, } - fmt.Fprintf(out, " drop_prod_db BLOCKED %v\n", err) - - fmt.Fprintln(out, "\nagent: takes a real backup first, then validates it") - for _, step := range []verify.StepID{"take_backup", "validate_backup"} { - if err := wf.CheckAndCommit(trace, step); err != nil { - fmt.Fprintf(out, "demo: %s: %v\n", step, err) - return 1 + for i, a := range attempts { + err := wf.CheckAndCommit(trace, verify.StepID(a.step)) + switch { + case err == nil: + res.Steps = append(res.Steps, demoStep{Step: a.step, Decision: "allowed", Say: a.say}) + case verify.IsViolation(err) && i == 0: + res.Steps = append(res.Steps, demoStep{Step: a.step, Decision: "blocked", Reason: err.Error(), Say: a.say}) + default: + return demoResult{}, fmt.Errorf("%s: %w", a.step, err) } - fmt.Fprintf(out, " %-17s ALLOWED\n", step) } + for _, s := range trace.ExecutedSteps() { + res.Trace = append(res.Trace, string(s)) + } + return res, nil +} - fmt.Fprintln(out, "\nagent: backup is validated, retrying the drop") - if err := wf.CheckAndCommit(trace, "drop_prod_db"); err != nil { - fmt.Fprintf(out, "demo: drop_prod_db: %v\n", err) +// runDemoArgs implements "sop-mcp-server demo": it needs no Go and no agent and +// finishes at once. With --json it prints each decision as one JSON document. +// It returns the process exit code. +func runDemoArgs(args []string, out, errw io.Writer) int { + if isHelp(args) { + fmt.Fprint(out, demoUsage) + return 0 + } + args, asJSON := popFlag(args, "--json") + if len(args) != 0 { + fmt.Fprint(errw, demoUsage) + return 2 + } + res, err := playDemo() + if err != nil { + fmt.Fprintln(errw, "demo:", err) return 1 } - fmt.Fprintln(out, " drop_prod_db ALLOWED") - fmt.Fprintf(out, "\ntrace: %v\n", trace.ExecutedSteps()) + if asJSON { + writeJSON(out, res) + return 0 + } + for i, s := range res.Steps { + if s.Say != "" { + if i > 0 { + fmt.Fprintln(out) + } + fmt.Fprintln(out, s.Say) + } + switch s.Decision { + case "blocked": + fmt.Fprintf(out, " %-17s BLOCKED %s\n", s.Step, s.Reason) + default: + fmt.Fprintf(out, " %-17s ALLOWED\n", s.Step) + } + } + fmt.Fprintf(out, "\ntrace: %v\n", res.Trace) return 0 } -// demoMain wires runDemo to the real output. -func demoMain() int { return runDemo(os.Stdout) } +// runDemo is the text form, kept for tests. +func runDemo(out io.Writer) int { return runDemoArgs(nil, out, io.Discard) } diff --git a/cmd/sop-mcp-server/main.go b/cmd/sop-mcp-server/main.go index 2dc41fbb6..8b1a360f3 100644 --- a/cmd/sop-mcp-server/main.go +++ b/cmd/sop-mcp-server/main.go @@ -59,21 +59,10 @@ func newStore(runbooksPath string) (*runbookstore.Store, []string, error) { } func main() { - // "sop-mcp-server setup" registers this binary with your agent by its full - // path. Anything else starts the server, which agents launch with no arguments. - if len(os.Args) > 1 && os.Args[1] == "setup" { - os.Exit(setupMain(os.Args[2:])) - } - // "sop-mcp-server check runbooks.json" loads a runbook file and prints what - // the barrier will enforce, without starting a server. - if len(os.Args) > 1 && os.Args[1] == "check" { - os.Exit(checkMain(os.Args[2:])) - } - - // "sop-mcp-server demo" shows the barrier blocking a database drop until a - // backup is validated. It needs no agent and finishes at once. - if len(os.Args) > 1 && os.Args[1] == "demo" { - os.Exit(demoMain()) + // Anything but the bare command or "stdio" is handled before a server + // starts. An unknown word is a usage error, not a silent server. + if code, serve := dispatch(os.Args[1:], os.Stdout, os.Stderr); !serve { + os.Exit(code) } store, names, err := newStore(os.Getenv("SOP_RUNBOOKS")) diff --git a/cmd/sop-mcp-server/setup.go b/cmd/sop-mcp-server/setup.go index 06057793b..af0b5c7c7 100644 --- a/cmd/sop-mcp-server/setup.go +++ b/cmd/sop-mcp-server/setup.go @@ -1,6 +1,7 @@ package main import ( + "errors" "flag" "fmt" "io" @@ -84,6 +85,9 @@ func runSetup(args []string, out, errw io.Writer, exe string, lessons := fs.String("lessons", "", "folder for the memory of earlier blocks (sets SOP_LESSONS_DIR)") runbooks := fs.String("runbooks", "", "JSON file with your own runbooks (sets SOP_RUNBOOKS)") if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } return 2 } @@ -178,7 +182,7 @@ func runSetup(args []string, out, errw io.Writer, exe string, } // setupMain wires runSetup to the real environment. -func setupMain(args []string) int { +func setupMain(args []string, out, errw io.Writer) int { exe, err := os.Executable() if err == nil { if resolved, rerr := filepath.EvalSymlinks(exe); rerr == nil { @@ -186,7 +190,7 @@ func setupMain(args []string) int { } } if err != nil { - fmt.Fprintln(os.Stderr, "setup: cannot find this binary's path:", err) + fmt.Fprintln(errw, "setup: cannot find this binary's path:", err) return 1 } // The CLIs run at the same time, so their output is held back and shown @@ -200,5 +204,5 @@ func setupMain(args []string) int { } return err } - return runSetup(args, os.Stdout, os.Stderr, exe, exec.LookPath, run) + return runSetup(args, out, errw, exe, exec.LookPath, run) } From d1be21beb9fc4b5b59197089d2897cd178d4f43d Mon Sep 17 00:00:00 2001 From: Gerard Louis Recinto Date: Mon, 5 Oct 2026 00:31:27 -0700 Subject: [PATCH 2/2] a failed JSON write is a failed command, and the demo test keeps its errors Thanks, Gerard Recinto --- cmd/sop-mcp-server/check.go | 25 ++++++++++++++++++------- cmd/sop-mcp-server/demo.go | 8 ++++---- cmd/sop-mcp-server/demo_test.go | 20 +++++++++++++++++++- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/cmd/sop-mcp-server/check.go b/cmd/sop-mcp-server/check.go index d5a1e713a..a1a6e6946 100644 --- a/cmd/sop-mcp-server/check.go +++ b/cmd/sop-mcp-server/check.go @@ -70,10 +70,10 @@ func runCheck(args []string, out, errw io.Writer) int { store, names, err := loadRunbooks(args[0]) if err != nil { if asJSON { - writeJSON(out, checkResult{Valid: false, File: args[0], Error: err.Error()}) - } else { - fmt.Fprintln(errw, "check:", err) + emitJSON(out, errw, checkResult{Valid: false, File: args[0], Error: err.Error()}, 1) + return 1 } + fmt.Fprintln(errw, "check:", err) return 1 } @@ -93,8 +93,7 @@ func runCheck(args []string, out, errw io.Writer) int { } res.Workflows = append(res.Workflows, cw) } - writeJSON(out, res) - return 0 + return emitJSON(out, errw, res, 0) } noun := "workflows" @@ -129,10 +128,22 @@ func sortedStates(in []verify.State) []string { return out } -func writeJSON(w io.Writer, v any) { +// writeJSON writes v as indented JSON and returns the write error, so a closed +// pipe or a full disk is a failed command and not silent, truncated output. +func writeJSON(w io.Writer, v any) error { enc := json.NewEncoder(w) enc.SetIndent("", " ") - _ = enc.Encode(v) + return enc.Encode(v) +} + +// emitJSON is writeJSON for a command: it reports a write failure on errw and +// turns it into the exit code. ok is the code to return when the write works. +func emitJSON(out, errw io.Writer, v any, ok int) int { + if err := writeJSON(out, v); err != nil { + fmt.Fprintln(errw, "check: could not write the result:", err) + return 1 + } + return ok } // orderedSteps lists the steps that need nothing first, then the rest, each diff --git a/cmd/sop-mcp-server/demo.go b/cmd/sop-mcp-server/demo.go index fccef2e29..c563729fe 100644 --- a/cmd/sop-mcp-server/demo.go +++ b/cmd/sop-mcp-server/demo.go @@ -76,7 +76,10 @@ func runDemoArgs(args []string, out, errw io.Writer) int { return 1 } if asJSON { - writeJSON(out, res) + if err := writeJSON(out, res); err != nil { + fmt.Fprintln(errw, "demo: could not write the result:", err) + return 1 + } return 0 } for i, s := range res.Steps { @@ -96,6 +99,3 @@ func runDemoArgs(args []string, out, errw io.Writer) int { fmt.Fprintf(out, "\ntrace: %v\n", res.Trace) return 0 } - -// runDemo is the text form, kept for tests. -func runDemo(out io.Writer) int { return runDemoArgs(nil, out, io.Discard) } diff --git a/cmd/sop-mcp-server/demo_test.go b/cmd/sop-mcp-server/demo_test.go index e8ba56105..93f9162a1 100644 --- a/cmd/sop-mcp-server/demo_test.go +++ b/cmd/sop-mcp-server/demo_test.go @@ -2,13 +2,14 @@ package main import ( "bytes" + "errors" "strings" "testing" ) func TestDemoBlocksTheDropThenAllowsItInOrder(t *testing.T) { var out bytes.Buffer - if code := runDemo(&out); code != 0 { + if code := runDemoArgs(nil, &out, &out); code != 0 { t.Fatalf("exit %d:\n%s", code, out.String()) } s := out.String() @@ -27,3 +28,20 @@ func TestDemoBlocksTheDropThenAllowsItInOrder(t *testing.T) { } } } + +// A command whose output cannot be written must fail, not exit 0 with the +// result cut off. A closed pipe is the usual cause. +type brokenWriter struct{} + +func (brokenWriter) Write([]byte) (int, error) { return 0, errors.New("broken pipe") } + +func TestAFailedJSONWriteIsAFailedCommand(t *testing.T) { + var errw bytes.Buffer + if code := runDemoArgs([]string{"--json"}, brokenWriter{}, &errw); code != 1 || !strings.Contains(errw.String(), "broken pipe") { + t.Errorf("demo --json: exit %d, stderr %q", code, errw.String()) + } + errw.Reset() + if code := runCheck([]string{"--json", writeRunbook(t, goodRunbook)}, brokenWriter{}, &errw); code != 1 || !strings.Contains(errw.String(), "broken pipe") { + t.Errorf("check --json: exit %d, stderr %q", code, errw.String()) + } +}