Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 96 additions & 8 deletions cmd/sop-mcp-server/check.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package main

import (
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strings"

Expand All @@ -19,19 +19,83 @@ 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] <runbooks.json>\n"

// runCheck implements "sop-mcp-server check <file>": 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 <runbooks.json>")
fmt.Fprint(errw, checkUsage)
return 2
}
store, names, err := loadRunbooks(args[0])
if err != nil {
if asJSON {
emitJSON(out, errw, checkResult{Valid: false, File: args[0], Error: err.Error()}, 1)
return 1
}
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)
}
return emitJSON(out, errw, res, 0)
}

noun := "workflows"
if len(names) == 1 {
noun = "workflow"
Expand All @@ -53,6 +117,35 @@ 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
}

// 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("", " ")
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
// group by ID, so the output does not depend on map order.
func orderedSteps(wf *verify.Workflow) []verify.Step {
Expand Down Expand Up @@ -81,8 +174,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)
}
85 changes: 85 additions & 0 deletions cmd/sop-mcp-server/cli.go
Original file line number Diff line number Diff line change
@@ -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
}
147 changes: 147 additions & 0 deletions cmd/sop-mcp-server/cli_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading