From 66d818c6f8095aa490fd01ec33c30cde8eb180e3 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sun, 6 Sep 2026 05:37:07 +0300 Subject: [PATCH 1/6] test(capabilities): pin discovery boundaries Signed-off-by: NovusEdge --- internal/capabilities/build.go | 7 + internal/capabilities/build_test.go | 269 +++++++++++++++++++++++++++ internal/capabilities/load.go | 7 + internal/capabilities/model.go | 82 ++++++++ internal/cli/capabilities_test.go | 94 ++++++++++ internal/mcpsrv/capabilities_test.go | 82 ++++++++ internal/mcpsrv/table_test.go | 1 + 7 files changed, 542 insertions(+) create mode 100644 internal/capabilities/build.go create mode 100644 internal/capabilities/build_test.go create mode 100644 internal/capabilities/load.go create mode 100644 internal/capabilities/model.go create mode 100644 internal/cli/capabilities_test.go create mode 100644 internal/mcpsrv/capabilities_test.go diff --git a/internal/capabilities/build.go b/internal/capabilities/build.go new file mode 100644 index 00000000..fb011bb5 --- /dev/null +++ b/internal/capabilities/build.go @@ -0,0 +1,7 @@ +package capabilities + +// Build evaluates the supplied host and metadata observations. Its behavior +// is implemented in the capabilities implementation task. +func Build(in Input) Report { + return Report{} +} diff --git a/internal/capabilities/build_test.go b/internal/capabilities/build_test.go new file mode 100644 index 00000000..d079766e --- /dev/null +++ b/internal/capabilities/build_test.go @@ -0,0 +1,269 @@ +package capabilities + +import ( + "errors" + "os" + "path/filepath" + "slices" + "strconv" + "testing" + + "github.com/novusedge/stoat/internal/core" +) + +func passingChecks() []core.HostCheck { + return []core.HostCheck{ + {Name: "qemu-system-x86_64", OK: true}, + {Name: "qemu-img", OK: true}, + {Name: "/dev/kvm", OK: true}, + {Name: "ssh", OK: true}, + } +} + +func target(name, mode, access string) *Target { + return &Target{Name: name, Mode: mode, AgentAccess: access} +} + +func reportEntry(t *testing.T, entries []Capability, name string) Capability { + t.Helper() + for _, entry := range entries { + if entry.Name == name { + return entry + } + } + t.Fatalf("report omitted %q", name) + return Capability{} +} + +func profileEntry(t *testing.T, entries []Profile, name string) Profile { + t.Helper() + for _, entry := range entries { + if entry.Name == name { + return entry + } + } + t.Fatalf("report omitted profile %q", name) + return Profile{} +} + +func TestBuildReportsProfilesAndTargetPolicy(t *testing.T) { + report := Build(Input{ + Version: "v-test", + ProjectState: "absent", + HostChecks: passingChecks(), + Target: target("work", "cloud", "observe"), + }) + + if report.Schema != 1 { + t.Errorf("Schema = %d, want 1", report.Schema) + } + if report.StoatVersion != "v-test" { + t.Errorf("StoatVersion = %q, want v-test", report.StoatVersion) + } + if report.Target == nil { + t.Fatal("Target is nil, want stored target snapshot") + } + if *report.Target != *target("work", "cloud", "observe") { + t.Errorf("Target = %+v, want work/cloud/observe", *report.Target) + } + if !report.AccessPolicy.MCPAgentAccessEnforced { + t.Error("MCPAgentAccessEnforced = false, want true") + } + if report.AccessPolicy.CLIAgentAccessEnforced { + t.Error("CLIAgentAccessEnforced = true, want false") + } + if !slices.Equal(report.AccessPolicy.CLICommands, []string{"exec", "cp"}) { + t.Errorf("CLICommands = %v, want [exec cp]", report.AccessPolicy.CLICommands) + } + if report.Profiles == nil || report.Capabilities == nil || report.Unavailable == nil { + t.Fatalf("report lists must be non-nil: profiles=%v capabilities=%v unavailable=%v", report.Profiles, report.Capabilities, report.Unavailable) + } + for _, entry := range report.Profiles { + if entry.Requirements == nil || entry.Limits == nil || entry.Evidence == nil { + t.Errorf("profile %q has nil list: %+v", entry.Name, entry) + } + } + for _, entry := range append(slices.Clone(report.Capabilities), report.Unavailable...) { + if entry.Requirements == nil || entry.Limits == nil || entry.Evidence == nil { + t.Errorf("entry %q has nil list: %+v", entry.Name, entry) + } + } + + for _, want := range []struct { + name, status, limit string + }{ + {name: "mcp.guest.observe", status: "supported"}, + {name: "mcp.guest.manage", status: "limited", limit: "manage"}, + {name: "mcp.guest.exec", status: "limited", limit: "exec"}, + } { + entry := reportEntry(t, report.Capabilities, want.name) + if entry.Status != want.status { + t.Errorf("%s status = %q, want %q", want.name, entry.Status, want.status) + } + if want.limit != "" { + if len(entry.Limits) != 1 || entry.Limits[0].Code != "agent_access_required" || entry.Limits[0].Value != want.limit { + t.Errorf("%s limits = %+v, want agent_access_required=%s", want.name, entry.Limits, want.limit) + } + } + } + snapshot := reportEntry(t, report.Capabilities, "vm.snapshot") + if snapshot.Status != "supported" { + t.Errorf("vm.snapshot status = %q, want supported for cloud target", snapshot.Status) + } +} + +func TestBuildKeepsRuntimeProposalsUnavailable(t *testing.T) { + report := Build(Input{Version: "v-test", HostChecks: passingChecks()}) + if len(report.Unavailable) != 2 { + t.Fatalf("Unavailable has %d entries, want exactly 2: %+v", len(report.Unavailable), report.Unavailable) + } + for _, name := range []string{"runtime.fork", "runtime.continuation"} { + entry := reportEntry(t, report.Unavailable, name) + if entry.Status != "unsupported" { + t.Errorf("%s status = %q, want unsupported", name, entry.Status) + } + if entry.Reason == nil || entry.Reason.Code != "not_implemented" { + t.Errorf("%s reason = %+v, want not_implemented", name, entry.Reason) + } + for _, current := range report.Capabilities { + if current.Name == name { + t.Errorf("%s appears in current capabilities", name) + } + } + } +} + +func TestCapabilitiesBoundaries(t *testing.T) { + t.Run("access levels follow ordered MCP policy", func(t *testing.T) { + for _, tc := range []struct { + access string + status [3]string + }{ + {access: "none", status: [3]string{"limited", "limited", "limited"}}, + {access: "observe", status: [3]string{"supported", "limited", "limited"}}, + {access: "manage", status: [3]string{"supported", "supported", "limited"}}, + {access: "exec", status: [3]string{"supported", "supported", "supported"}}, + } { + t.Run(tc.access, func(t *testing.T) { + report := Build(Input{HostChecks: passingChecks(), Target: target("work", "cloud", tc.access)}) + for i, name := range []string{"mcp.guest.observe", "mcp.guest.manage", "mcp.guest.exec"} { + if got := reportEntry(t, report.Capabilities, name).Status; got != tc.status[i] { + t.Errorf("%s status = %q, want %q", name, got, tc.status[i]) + } + } + }) + } + }) + + t.Run("live target has disk limit", func(t *testing.T) { + report := Build(Input{HostChecks: passingChecks(), Target: target("work", "live", "observe")}) + entry := reportEntry(t, report.Capabilities, "vm.snapshot") + if entry.Status != "limited" || len(entry.Limits) != 1 || entry.Limits[0].Code != "disk_required" { + t.Errorf("vm.snapshot = %+v, want limited with disk_required", entry) + } + }) + + for _, mode := range []string{"", "mystery"} { + t.Run("unknown mode "+mode, func(t *testing.T) { + report := Build(Input{HostChecks: passingChecks(), Target: target("work", mode, "observe")}) + entry := reportEntry(t, report.Capabilities, "vm.snapshot") + if entry.Status != "unknown" || entry.Reason == nil || entry.Reason.Code != "target_mode_unknown" { + t.Errorf("vm.snapshot = %+v, want unknown/target_mode_unknown", entry) + } + }) + } + + t.Run("partial required host observations are unknown", func(t *testing.T) { + report := Build(Input{HostChecks: []core.HostCheck{{Name: "qemu-system-x86_64", OK: true}}}) + profile := profileEntry(t, report.Profiles, "qemu-x86_64") + if profile.Status != "unknown" || profile.Reason == nil || profile.Reason.Code != "host_probe_unavailable" { + t.Errorf("qemu-x86_64 = %+v, want unknown/host_probe_unavailable", profile) + } + }) + + t.Run("failed fully observed host requirement is limited", func(t *testing.T) { + report := Build(Input{HostChecks: []core.HostCheck{ + {Name: "qemu-system-x86_64", OK: true}, + {Name: "qemu-img", OK: false}, + {Name: "/dev/kvm", OK: true}, + }}) + profile := profileEntry(t, report.Profiles, "qemu-x86_64") + if profile.Status != "limited" || profile.Reason != nil { + t.Errorf("qemu-x86_64 = %+v, want limited without a reason", profile) + } + if len(profile.Limits) != 1 || profile.Limits[0].Code != "host_requirement_missing" || profile.Limits[0].Value != "qemu-img" { + t.Errorf("qemu-x86_64 limits = %+v, want failed qemu-img", profile.Limits) + } + }) + + t.Run("empty host observations are unknown", func(t *testing.T) { + report := Build(Input{HostChecks: []core.HostCheck{}}) + profile := profileEntry(t, report.Profiles, "qemu-x86_64") + if profile.Status != "unknown" || profile.Reason == nil || profile.Reason.Code != "host_probe_unavailable" { + t.Errorf("qemu-x86_64 = %+v, want unknown/host_probe_unavailable", profile) + } + if report.AccessPolicy.CLIAgentAccessEnforced { + t.Error("CLIAgentAccessEnforced = true, want false") + } + }) + + t.Run("metadata loader is adversarial and read-only", func(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + root := os.Getenv("STOAT_HOME") + vmDir := filepath.Join(root, "requested") + if err := os.MkdirAll(vmDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(vmDir, "vm.toml"), []byte("name = \"stored\"\nmode = \"cloud\"\nallow_exec = true\n"), 0o644); err != nil { + t.Fatal(err) + } + fixtures := map[string][]byte{ + filepath.Join(vmDir, "qemu.pid"): []byte(strconv.Itoa(os.Getpid()) + "\n"), + filepath.Join(vmDir, "secrets.toml"): []byte("sentinel = \"keep-me\"\n"), + filepath.Join(root, "stoat.lock"): []byte("recipe-lock-sentinel\n"), + } + before := make(map[string][]byte, len(fixtures)) + for path, body := range fixtures { + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + before[path] = slices.Clone(body) + } + + loaded, err := LoadTarget("requested") + if err != nil { + t.Fatalf("LoadTarget(requested) error = %v", err) + } + if loaded.Name != "requested" || loaded.Mode != "cloud" || loaded.AgentAccess != "exec" { + t.Errorf("LoadTarget(requested) = %+v, want canonical requested/cloud/exec", loaded) + } + for path, want := range before { + got, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatal(readErr) + } + if !slices.Equal(got, want) { + t.Errorf("LoadTarget changed %s: got %q, want %q", path, got, want) + } + } + + for _, name := range []string{"", "../escape", "bad/name", "-bad", "_bad", "bad name"} { + if _, err := LoadTarget(name); !errors.Is(err, core.ErrInvalidSpec) { + t.Errorf("LoadTarget(%q) error = %v, want ErrInvalidSpec", name, err) + } + } + if _, err := LoadTarget("missing"); !errors.Is(err, core.ErrNotFound) { + t.Errorf("LoadTarget(missing) error = %v, want ErrNotFound", err) + } + brokenDir := filepath.Join(root, "broken") + if err := os.MkdirAll(brokenDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(brokenDir, "vm.toml"), []byte("name = \"broken\"\nmode = \"cloud\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := LoadTarget("broken"); !errors.Is(err, core.ErrBroken) { + t.Errorf("LoadTarget(broken) error = %v, want ErrBroken", err) + } + }) +} diff --git a/internal/capabilities/load.go b/internal/capabilities/load.go new file mode 100644 index 00000000..1ef4134a --- /dev/null +++ b/internal/capabilities/load.go @@ -0,0 +1,7 @@ +package capabilities + +// LoadTarget is the metadata-only VM loader. Its behavior is implemented in +// the capabilities implementation task. +func LoadTarget(name string) (Target, error) { + return Target{}, nil +} diff --git a/internal/capabilities/model.go b/internal/capabilities/model.go new file mode 100644 index 00000000..0cd0acd2 --- /dev/null +++ b/internal/capabilities/model.go @@ -0,0 +1,82 @@ +package capabilities + +import "github.com/novusedge/stoat/internal/core" + +// Report is the versioned capability discovery document shared by the CLI and +// MCP adapters. +type Report struct { + Schema int `json:"schema"` + StoatVersion string `json:"stoat_version"` + Host Host `json:"host"` + Target *Target `json:"target,omitempty"` + AccessPolicy AccessPolicy `json:"access_policy"` + Profiles []Profile `json:"profiles"` + Capabilities []Capability `json:"capabilities"` + Unavailable []Capability `json:"unavailable"` +} + +type Host struct { + OS string `json:"os"` + Arch string `json:"arch"` + ProjectState string `json:"project_state"` +} + +type Target struct { + Name string `json:"name"` + Mode string `json:"mode"` + AgentAccess string `json:"agent_access"` +} + +type AccessPolicy struct { + MCPAgentAccessEnforced bool `json:"mcp_agent_access_enforced"` + CLIAgentAccessEnforced bool `json:"cli_agent_access_enforced"` + CLICommands []string `json:"cli_commands"` +} + +type Profile struct { + Name string `json:"name"` + Status string `json:"status"` + Scope string `json:"scope"` + Requirements []Requirement `json:"requirements"` + Limits []Limit `json:"limits"` + Reason *Reason `json:"reason,omitempty"` + Evidence []Evidence `json:"evidence"` +} + +type Capability struct { + Name string `json:"name"` + Status string `json:"status"` + Scope string `json:"scope"` + Requirements []Requirement `json:"requirements"` + Limits []Limit `json:"limits"` + Reason *Reason `json:"reason,omitempty"` + Evidence []Evidence `json:"evidence"` +} + +type Requirement struct { + Kind string `json:"kind"` + Name string `json:"name"` + Value string `json:"value,omitempty"` +} + +type Limit struct { + Code string `json:"code"` + Value string `json:"value,omitempty"` +} + +type Reason struct { + Code string `json:"code"` +} + +type Evidence struct { + Kind string `json:"kind"` + Source string `json:"source"` + Result string `json:"result"` +} + +type Input struct { + Version string + ProjectState string + HostChecks []core.HostCheck + Target *Target +} diff --git a/internal/cli/capabilities_test.go b/internal/cli/capabilities_test.go new file mode 100644 index 00000000..50353674 --- /dev/null +++ b/internal/cli/capabilities_test.go @@ -0,0 +1,94 @@ +package cli + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/novusedge/stoat/internal/config" +) + +func TestCapabilitiesCLIUsesHumanAndJSONForms(t *testing.T) { + cliRoot(t) + + var humanOut, humanErr bytes.Buffer + if code := Main([]string{"capabilities"}, "v-test", strings.NewReader(""), &humanOut, &humanErr); code != ExitOK { + t.Fatalf("human capabilities exit = %d, want ExitOK; stdout=%q stderr=%q", code, humanOut.String(), humanErr.String()) + } + if strings.Contains(humanOut.String(), "{") { + t.Fatalf("human capabilities emitted a JSON object: %q", humanOut.String()) + } + for _, header := range []string{"NAME", "STATUS", "SCOPE"} { + if !strings.Contains(strings.ToUpper(humanOut.String()), header) { + t.Errorf("human capabilities omitted %s header: %q", header, humanOut.String()) + } + } + + var jsonOut, jsonErr bytes.Buffer + if code := Main([]string{"--json", "capabilities"}, "v-test", strings.NewReader(""), &jsonOut, &jsonErr); code != ExitOK { + t.Fatalf("JSON capabilities exit = %d, want ExitOK; stdout=%q stderr=%q", code, jsonOut.String(), jsonErr.String()) + } + var envelope struct { + Cmd string `json:"cmd"` + OK bool `json:"ok"` + Data struct { + Schema int `json:"schema"` + } `json:"data"` + } + if err := json.Unmarshal(bytes.TrimSpace(jsonOut.Bytes()), &envelope); err != nil { + t.Fatalf("JSON capabilities output is not one envelope: %v; output=%q", err, jsonOut.String()) + } + if envelope.Cmd != "capabilities" || !envelope.OK || envelope.Data.Schema != 1 { + t.Errorf("JSON capabilities envelope = %+v, want cmd=capabilities ok=true data.schema=1", envelope) + } +} + +func TestCapabilitiesGlobalVMInProjectPreservesStalePID(t *testing.T) { + projectRoot(t, twoVMs) + if err := (&config.VM{Name: "outside", Mode: "cloud"}).Save(); err != nil { + t.Fatal(err) + } + pidPath := filepath.Join(config.Root(), "outside", "qemu.pid") + want := []byte(strconv.Itoa(os.Getpid()) + "\n") + if err := os.WriteFile(pidPath, want, 0o600); err != nil { + t.Fatal(err) + } + + var out, errOut bytes.Buffer + if code := Main([]string{"capabilities", "outside"}, "v-test", strings.NewReader(""), &out, &errOut); code != ExitOK { + t.Fatalf("capabilities global VM exit = %d, want ExitOK; stdout=%q stderr=%q", code, out.String(), errOut.String()) + } + got, err := os.ReadFile(pidPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Errorf("capabilities changed stale PID: got %q, want %q", got, want) + } +} + +func TestCapabilitiesTargetlessDoesNotInitializeStoatHome(t *testing.T) { + t.Chdir(t.TempDir()) + root := filepath.Join(t.TempDir(), "stoat-home") + t.Setenv("STOAT_HOME", root) + + var humanOut, humanErr bytes.Buffer + if code := Main([]string{"capabilities"}, "v-test", strings.NewReader(""), &humanOut, &humanErr); code != ExitOK { + t.Fatalf("targetless human capabilities exit = %d, want ExitOK; stdout=%q stderr=%q", code, humanOut.String(), humanErr.String()) + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatalf("targetless human capabilities initialized STOAT_HOME: stat error = %v", err) + } + + var jsonOut, jsonErr bytes.Buffer + if code := Main([]string{"--json", "capabilities"}, "v-test", strings.NewReader(""), &jsonOut, &jsonErr); code != ExitOK { + t.Fatalf("targetless JSON capabilities exit = %d, want ExitOK; stdout=%q stderr=%q", code, jsonOut.String(), jsonErr.String()) + } + if _, err := os.Stat(root); !os.IsNotExist(err) { + t.Fatalf("targetless JSON capabilities initialized STOAT_HOME: stat error = %v", err) + } +} diff --git a/internal/mcpsrv/capabilities_test.go b/internal/mcpsrv/capabilities_test.go new file mode 100644 index 00000000..c7f114a4 --- /dev/null +++ b/internal/mcpsrv/capabilities_test.go @@ -0,0 +1,82 @@ +package mcpsrv + +import ( + "encoding/json" + "slices" + "testing" +) + +func TestCapabilitiesMCPToolIsOptionalVMReadOnly(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + var capabilityToolName = "capabilities" + var toolFound bool + var toolInputSchema json.RawMessage + var annotationsReadOnly, annotationsDestructive, annotationsOpenWorld bool + for _, tool := range listTools(t) { + if tool.Name != capabilityToolName { + continue + } + toolFound = true + if tool.Annotations == nil || !tool.Annotations.ReadOnlyHint { + t.Error("capabilities is not marked read-only") + } else { + annotationsReadOnly = tool.Annotations.ReadOnlyHint + } + if tool.Annotations == nil || tool.Annotations.DestructiveHint == nil || *tool.Annotations.DestructiveHint { + t.Error("capabilities is marked destructive") + } else { + annotationsDestructive = *tool.Annotations.DestructiveHint + } + if tool.Annotations == nil || tool.Annotations.OpenWorldHint == nil || *tool.Annotations.OpenWorldHint { + t.Error("capabilities is marked open-world") + } else { + annotationsOpenWorld = *tool.Annotations.OpenWorldHint + } + var err error + toolInputSchema, err = json.Marshal(tool.InputSchema) + if err != nil { + t.Fatalf("marshal capabilities input schema: %v", err) + } + } + if !toolFound { + t.Fatal("capabilities tool is not registered") + } + if !annotationsReadOnly || annotationsDestructive || annotationsOpenWorld { + t.Fatalf("capabilities annotations = read-only %v, destructive %v, open-world %v", annotationsReadOnly, annotationsDestructive, annotationsOpenWorld) + } + var schema struct { + Properties map[string]json.RawMessage `json:"properties"` + Required []string `json:"required"` + AdditionalProperties *bool `json:"additionalProperties"` + } + if err := json.Unmarshal(toolInputSchema, &schema); err != nil { + t.Fatalf("decode capabilities input schema: %v; schema=%s", err, toolInputSchema) + } + if _, ok := schema.Properties["vm"]; !ok { + t.Fatalf("capabilities input schema has no optional vm property: %s", toolInputSchema) + } + if slices.Contains(schema.Required, "vm") { + t.Errorf("capabilities input schema requires optional vm: %s", toolInputSchema) + } + if schema.AdditionalProperties == nil || *schema.AdditionalProperties { + t.Errorf("capabilities input schema additionalProperties = %v, want false", schema.AdditionalProperties) + } + + res := callTool(t, capabilityToolName, map[string]any{}) + if res.IsError { + t.Fatalf("capabilities without vm failed: %+v", res.Content) + } + raw, err := json.Marshal(res.StructuredContent) + if err != nil { + t.Fatal(err) + } + var out struct { + Schema int `json:"schema"` + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("capabilities output is not a report: %v; output=%s", err, raw) + } + if out.Schema != 1 { + t.Errorf("capabilities schema = %d, want 1; output=%s", out.Schema, raw) + } +} diff --git a/internal/mcpsrv/table_test.go b/internal/mcpsrv/table_test.go index ed85fbf9..45b11721 100644 --- a/internal/mcpsrv/table_test.go +++ b/internal/mcpsrv/table_test.go @@ -17,6 +17,7 @@ var toolTable = []toolSpec{ {"guest_info", classRead, LevelNone}, {"recipe_schema", classRead, LevelNone}, {"search_recipes", classRead, LevelNone}, + {"capabilities", classRead, LevelNone}, // Mutating, host side (Task 7). {"create", classMutate, LevelNone}, From 8a4b7207fec50d8cae5d16c772b08acc01979324 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sun, 6 Sep 2026 05:53:12 +0300 Subject: [PATCH 2/6] feat(capabilities): add read-only discovery Signed-off-by: NovusEdge --- docs/reference/cli.md | 14 +++ docs/reference/json.md | 57 ++++++++++ docs/reference/mcp.md | 16 ++- internal/capabilities/build.go | 171 +++++++++++++++++++++++++++++- internal/capabilities/load.go | 31 +++++- internal/capabilities/model.go | 26 +++++ internal/cli/cli.go | 11 ++ internal/cli/grammar.go | 19 ++-- internal/cli/project.go | 9 +- internal/cli/run_capabilities.go | 45 ++++++++ internal/cli/wire/capabilities.go | 6 ++ internal/mcpsrv/tools_read.go | 33 ++++++ 12 files changed, 424 insertions(+), 14 deletions(-) create mode 100644 internal/cli/run_capabilities.go create mode 100644 internal/cli/wire/capabilities.go diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 0c1c1540..21837301 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -64,11 +64,25 @@ order, when given no VM argument. A bare VM argument resolves against | [`logs`](#stoat-logs-name--n-n) | Tail a VM's log, or stoat's own | 0, 1 | | [`screenshot`](#stoat-screenshot-name--o-path) | Write the VM's screen to a PNG | 0, 1 | | [`doctor`](#stoat-doctor) | Check host prerequisites | 0, 1 | +| [`capabilities`](#stoat-capabilities-vm) | Report current agent capabilities | 0, 1 | | [`version`](#stoat-version) | Print the stoat version | 0 | | [`help`](#stoat-help) | Show the usage message | 0 | Anything not on this list, a missing VM name, or extra arguments is a **usage error** (exit 2), printed to stderr together with the full usage text. +## stoat capabilities [VM] + +Reports the host checks, implemented Stoat surfaces, target access limits, and +unavailable runtime proposals. It reads stored VM metadata only and does not +start or connect to a VM. + +Omit the VM name for host and project scope. In a project, a bare name first +resolves against the current stoat.toml and then against a global VM name. +Without --json, Stoat prints a NAME, STATUS, SCOPE table. Add --json for the +standard result envelope with the schema 1 capability report. + +MCP enforces agent_access. Direct CLI stoat exec and stoat cp do not enforce it. + ## `stoat ls` Lists every VM directory under the data root, plus any directory whose `vm.toml` failed to parse (shown with a `broken` state and a one-line reason). Broken VMs are real entries, not hidden. diff --git a/docs/reference/json.md b/docs/reference/json.md index 492ba31c..a70e5100 100644 --- a/docs/reference/json.md +++ b/docs/reference/json.md @@ -612,3 +612,60 @@ and five MCP tools (`project_status`, `project_up`, `project_down`, `project_apply`, `project_wait`) alongside `start`, `stop`, `apply_recipes` and `wait`, which keep their existing inputs and outputs. All additions; the contract stays 3. + +## Capability discovery + +stoat capabilities [VM] --json returns a report with schema 1 in the usual +result envelope. The command reads host checks and one VM's stored metadata. +It does not start, connect to, or mutate a VM. Omit VM for host and project +scope; a target adds its stored name, mode, and normalized agent_access values. + +The report has these fields: + +| Field | Meaning | +|---|---| +| schema | Capability report schema, currently 1. | +| stoat_version | Build version supplied by Stoat. | +| host | os, arch, and project_state (available, absent, or unknown). | +| target | Optional stored VM snapshot with name, mode, and agent_access. | +| access_policy | mcp_agent_access_enforced, cli_agent_access_enforced, and cli_commands (exec, cp). | +| profiles | Implemented runtime profiles and their host or guest requirements. | +| capabilities | Implemented current surfaces and their requirements, limits, and evidence. | +| unavailable | Explicitly unavailable surfaces. | + +Each profile or capability has name, status, scope, requirements, limits, +optional reason, and evidence. Status is supported, limited, unsupported, or +unknown. limited carries a limit code; unsupported and unknown carry a reason +code. Every list is [] when empty, never null. + +The stable reason codes are not_implemented, host_probe_unavailable, +agent_access_unknown, target_mode_unknown, and project_state_unknown. The +stable limit codes are agent_access_required, target_required, disk_required, +project_file_required, and host_requirement_missing. MCP enforces +agent_access; direct CLI exec and cp do not. A live target limits vm.snapshot +with disk_required. runtime.fork and runtime.continuation always appear under +unavailable with unsupported/not_implemented. + +~~~json +{ + "schema": 1, + "stoat_version": "dev", + "host": {"os": "linux", "arch": "amd64", "project_state": "absent"}, + "access_policy": { + "mcp_agent_access_enforced": true, + "cli_agent_access_enforced": false, + "cli_commands": ["exec", "cp"] + }, + "profiles": [], + "capabilities": [], + "unavailable": [ + {"name": "runtime.fork", "status": "unsupported", "scope": "runtime", "requirements": [], "limits": [], "reason": {"code": "not_implemented"}, "evidence": [{"kind": "implementation", "source": "runtime.fork", "result": "not_implemented"}]}, + {"name": "runtime.continuation", "status": "unsupported", "scope": "runtime", "requirements": [], "limits": [], "reason": {"code": "not_implemented"}, "evidence": [{"kind": "implementation", "source": "runtime.continuation", "result": "not_implemented"}]} + ] +} +~~~ + +The example abbreviates profiles and capabilities; a real report always +contains the implemented entries. The proposal evidence identifies the report +entry, while its status and reason state that the runtime surface is +unavailable. diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index 8fbe1b0c..68ba801d 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -2,7 +2,14 @@ `stoat mcp` serves Stoat's Model Context Protocol server over stdio. MCP clients launch it as a subprocess, normally with the working directory of the -project they should manage. The server uses the same Go wire types as +project they should manage. + +The read-only capabilities tool reads host checks and stored VM metadata. Its +optional vm argument selects one VM; omit it for host and project scope. It +does not start or connect to a VM, and it reports MCP access limits plus +unavailable fork and continuation proposals. Discovery does not mutate a VM. + +The server uses the same Go wire types as `--json`; see [JSON output](json.md). ## Install a client entry @@ -91,6 +98,9 @@ that run guest code are marked by their required level. ### Host and recipe tools +The capabilities tool reads host checks and optional stored VM metadata. It +uses no VM connection or guest mutation. + | Tool | Purpose | |---|---| | `list_vms`, `vm_status` | List VMs or inspect one VM, including recipe state and health. | @@ -100,6 +110,7 @@ that run guest code are marked by their required level. | `add_recipe`, `update_recipe`, `remove_recipe` | Add, repin, or remove remote recipes. `add_recipe` accepts curated index names only; it refuses Git URLs. `remove_recipe` has no force option. | | `list_guests`, `guest_info` | Inspect loaded guest definitions and their package/service commands. | | `doctor`, `logs` | Check host prerequisites or tail a VM's console/apply log. | +| `capabilities` | Read host checks and optional stored VM metadata; report current capabilities and limits. | | `create`, `start`, `stop`, `destroy`, `update`, `clone` | Manage VM definitions and lifecycle. `destroy` deletes the VM and its disk. | | `snapshot`, `restore`, `forward`, `wait`, `prune` | Manage disk snapshots, port forwards, state waits, and stale files. `prune` is dry-run unless `apply=true`. | | `project_status`, `project_up`, `project_down`, `project_apply`, `project_wait` | Inspect or operate on every VM declared by the server working directory's `stoat.toml`, in declaration order. A failure stops the run and later VMs are marked skipped. | @@ -122,6 +133,9 @@ Guest operations require a running VM. `copy_to` and `copy_from` restrict the host side to the VM's configured shared directory. `pkg_install` uses the loaded guest definition's package manager. `svc` uses its service templates. +agent_access is enforced by MCP guest tools. Direct CLI stoat exec and stoat cp +remain outside that policy. + ## Project tools and scope Project tools use the directory in which the server was started. They do not diff --git a/internal/capabilities/build.go b/internal/capabilities/build.go index fb011bb5..88931821 100644 --- a/internal/capabilities/build.go +++ b/internal/capabilities/build.go @@ -1,7 +1,172 @@ package capabilities -// Build evaluates the supplied host and metadata observations. Its behavior -// is implemented in the capabilities implementation task. +import ( + "runtime" + + "github.com/novusedge/stoat/internal/core" +) + +// Build evaluates the supplied host and metadata observations without I/O. func Build(in Input) Report { - return Report{} + report := Report{ + Schema: 1, + StoatVersion: in.Version, + Host: Host{OS: runtime.GOOS, Arch: runtime.GOARCH, ProjectState: in.ProjectState}, + AccessPolicy: AccessPolicy{ + MCPAgentAccessEnforced: true, + CLIAgentAccessEnforced: false, + CLICommands: []string{"exec", "cp"}, + }, + Profiles: []Profile{}, + Capabilities: []Capability{}, + Unavailable: []Capability{}, + } + if in.Target != nil { + target := *in.Target + report.Target = &target + } + + report.Profiles = append(report.Profiles, qemuProfile(in.HostChecks)) + report.Profiles = append(report.Profiles, + Profile{Name: "recipe-sh", Status: StatusSupported, Scope: ScopeRuntime, + Requirements: []Requirement{}, Limits: []Limit{}, Evidence: []Evidence{implementationEvidence("recipe.runtime.sh")}}, + Profile{Name: "recipe-python3", Status: StatusSupported, Scope: ScopeRuntime, + Requirements: []Requirement{requirement("guest_runtime", "python3", "")}, Limits: []Limit{}, Evidence: []Evidence{implementationEvidence("recipe.runtime.python3")}}, + ) + + add := func(entry Capability) { report.Capabilities = append(report.Capabilities, entry) } + add(currentEntry("vm.lifecycle", StatusSupported, ScopeVM, targetRequirement(), nil, nil)) + + snapshot := currentEntry("vm.snapshot", StatusSupported, ScopeVM, targetRequirement(), nil, nil) + if in.Target != nil { + snapshot.Evidence = append(snapshot.Evidence, configEvidence("vm.mode", in.Target.Mode)) + switch in.Target.Mode { + case "live": + snapshot.Status = StatusLimited + snapshot.Limits = append(snapshot.Limits, Limit{Code: LimitDiskRequired}) + case "disk", "cloud": + case "": + snapshot.Status = StatusUnknown + snapshot.Reason = &Reason{Code: ReasonTargetModeUnknown} + default: + snapshot.Status = StatusUnknown + snapshot.Reason = &Reason{Code: ReasonTargetModeUnknown} + } + } + add(snapshot) + + add(currentEntry("recipes", StatusSupported, ScopeVM, targetRequirement(), nil, nil)) + + project := currentEntry("project.operations", StatusSupported, ScopeProject, nil, nil, nil) + project.Evidence = append(project.Evidence, configEvidence("project.scope", in.ProjectState)) + switch in.ProjectState { + case "available": + case "absent": + project.Status = StatusLimited + project.Limits = append(project.Limits, Limit{Code: LimitProjectFileRequired}) + case "unknown", "": + project.Status = StatusUnknown + project.Reason = &Reason{Code: ReasonProjectStateUnknown} + } + add(project) + + for _, name := range []string{"mcp.guest.observe", "mcp.guest.manage", "mcp.guest.exec"} { + need := name[len("mcp.guest."):] + entry := currentEntry(name, StatusSupported, ScopeVM, targetRequirement(), nil, nil) + entry.Requirements = append(entry.Requirements, requirement("mcp_agent_access", "agent_access", need)) + if in.Target != nil { + entry.Evidence = append(entry.Evidence, configEvidence("vm.agent_access", in.Target.AgentAccess)) + have, ok := accessRank[in.Target.AgentAccess] + want := accessRank[need] + if !ok { + entry.Status = StatusUnknown + entry.Reason = &Reason{Code: ReasonAgentAccessUnknown} + } else if have < want { + entry.Status = StatusLimited + entry.Limits = append(entry.Limits, Limit{Code: LimitAgentAccessRequired, Value: need}) + } + } + add(entry) + } + + cli := currentEntry("cli.guest.shell", StatusSupported, ScopeCLI, targetRequirement(), nil, nil) + add(cli) + add(currentEntry("host.diagnostics", StatusSupported, ScopeHost, nil, nil, nil)) + + for _, name := range []string{"runtime.fork", "runtime.continuation"} { + report.Unavailable = append(report.Unavailable, Capability{ + Name: name, Status: StatusUnsupported, Scope: ScopeRuntime, + Requirements: []Requirement{}, Limits: []Limit{}, Reason: &Reason{Code: ReasonNotImplemented}, + Evidence: []Evidence{{Kind: "implementation", Source: name, Result: ReasonNotImplemented}}, + }) + } + return report +} + +var accessRank = map[string]int{"none": 0, "observe": 1, "manage": 2, "exec": 3} + +func requirement(kind, name, value string) Requirement { + return Requirement{Kind: kind, Name: name, Value: value} +} + +func targetRequirement() []Requirement { + return []Requirement{{Kind: "target", Name: "vm"}} +} + +func implementationEvidence(source string) Evidence { + return Evidence{Kind: "implementation", Source: source, Result: "implemented"} +} + +func configEvidence(source, result string) Evidence { + return Evidence{Kind: "config", Source: source, Result: result} +} + +func currentEntry(name, status, scope string, requirements []Requirement, limits []Limit, reason *Reason) Capability { + if requirements == nil { + requirements = []Requirement{} + } + if limits == nil { + limits = []Limit{} + } + return Capability{Name: name, Status: status, Scope: scope, Requirements: requirements, Limits: limits, Reason: reason, Evidence: []Evidence{implementationEvidence(name)}} +} + +func qemuProfile(checks []core.HostCheck) Profile { + requirements := []Requirement{ + requirement("host_tool", "qemu-system-x86_64", ""), + requirement("host_tool", "qemu-img", ""), + requirement("host_device", "/dev/kvm", ""), + } + p := Profile{Name: "qemu-x86_64", Status: StatusUnknown, Scope: ScopeHost, Requirements: requirements, Limits: []Limit{}, Evidence: []Evidence{}} + byName := make(map[string]core.HostCheck, len(checks)) + for _, c := range checks { + if _, exists := byName[c.Name]; !exists { + byName[c.Name] = c + } + } + missing := false + for _, req := range requirements { + c, ok := byName[req.Name] + if !ok { + missing = true + continue + } + result := "failed" + if c.OK { + result = "available" + } + p.Evidence = append(p.Evidence, Evidence{Kind: "host_check", Source: c.Name, Result: result}) + } + if missing { + p.Reason = &Reason{Code: ReasonHostProbeUnavailable} + return p + } + p.Status = StatusSupported + for _, req := range requirements { + if c := byName[req.Name]; !c.OK { + p.Status = StatusLimited + p.Limits = append(p.Limits, Limit{Code: LimitHostRequirementMissing, Value: req.Name}) + } + } + return p } diff --git a/internal/capabilities/load.go b/internal/capabilities/load.go index 1ef4134a..8b46fe47 100644 --- a/internal/capabilities/load.go +++ b/internal/capabilities/load.go @@ -1,7 +1,32 @@ package capabilities -// LoadTarget is the metadata-only VM loader. Its behavior is implemented in -// the capabilities implementation task. +import ( + "fmt" + "os" + "path/filepath" + "regexp" + + "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/core" +) + +var targetNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`) + +// LoadTarget reads one VM's stored metadata without inspecting runtime state. func LoadTarget(name string) (Target, error) { - return Target{}, nil + if !targetNameRE.MatchString(name) { + return Target{}, fmt.Errorf("%w: VM name %q", core.ErrInvalidSpec, name) + } + path := filepath.Join(config.Root(), name, "vm.toml") + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return Target{}, fmt.Errorf("%w: %s", core.ErrNotFound, name) + } + return Target{}, fmt.Errorf("%w: %s: %v", core.ErrBroken, name, err) + } + v, err := config.Load(name) + if err != nil { + return Target{}, fmt.Errorf("%w: %s: %v", core.ErrBroken, name, err) + } + return Target{Name: name, Mode: v.Mode, AgentAccess: v.AgentAccess}, nil } diff --git a/internal/capabilities/model.go b/internal/capabilities/model.go index 0cd0acd2..87241289 100644 --- a/internal/capabilities/model.go +++ b/internal/capabilities/model.go @@ -2,6 +2,32 @@ package capabilities import "github.com/novusedge/stoat/internal/core" +const ( + StatusSupported = "supported" + StatusLimited = "limited" + StatusUnsupported = "unsupported" + StatusUnknown = "unknown" + + ScopeHost = "host" + ScopeVM = "vm" + ScopeProject = "project" + ScopeRuntime = "runtime" + ScopeMCP = "mcp" + ScopeCLI = "cli" + + ReasonNotImplemented = "not_implemented" + ReasonHostProbeUnavailable = "host_probe_unavailable" + ReasonAgentAccessUnknown = "agent_access_unknown" + ReasonTargetModeUnknown = "target_mode_unknown" + ReasonProjectStateUnknown = "project_state_unknown" + + LimitAgentAccessRequired = "agent_access_required" + LimitTargetRequired = "target_required" + LimitDiskRequired = "disk_required" + LimitProjectFileRequired = "project_file_required" + LimitHostRequirementMissing = "host_requirement_missing" +) + // Report is the versioned capability discovery document shared by the CLI and // MCP adapters. type Report struct { diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 08bd5fc2..4cd40982 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -425,6 +425,15 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ // lines: they are all already gated on it. a.Quiet = true } + // Capabilities is deliberately dispatched before generic setup: discovery + // reads host checks and stored metadata, so it must not create a data root, + // install recipes, generate keys, initialize logs, or load guest overrides. + if a.Cmd == "capabilities" { + if err := resolveScope(a); err != nil { + return a.fail(stdout, stderr, err) + } + return runCapabilities(a, version, stdout, stderr) + } if len(a.Params) > 0 { resolved, err := resolveParamEdits(a.Params, stdin, stderr, !jsonMode && streamIsTTY(stdin)) if err != nil { @@ -542,6 +551,8 @@ func Main(args []string, version string, stdin io.Reader, stdout, stderr io.Writ return runUpdate(a, stdout, stderr) case "doctor": return runDoctor(a, stdout, stderr) + case "capabilities": + return runCapabilities(a, version, stdout, stderr) case "mcp": return runMCP(a, version, stdout, stderr) case "status": diff --git a/internal/cli/grammar.go b/internal/cli/grammar.go index 35e13c14..45886c4b 100644 --- a/internal/cli/grammar.go +++ b/internal/cli/grammar.go @@ -61,12 +61,13 @@ type grammar struct { Recipe recipeCmd `cmd:"" help:"author recipes"` Guest recipeGuestCmd `cmd:"" help:"list or show guest OS definitions"` - Logs logsCmd `cmd:"" help:"tail a VM's log, or stoat's own"` - Screenshot screenshotCmd `cmd:"" help:"write the VM's screen to a PNG"` - Doctor doctorCmd `cmd:"" help:"check host prerequisites"` - MCP mcpCmd `cmd:"" name:"mcp" help:"serve MCP, or configure a client to launch it"` - Version versionCmd `cmd:"" help:"print the stoat version"` - Help helpCmd `cmd:"" help:"show this message"` + Logs logsCmd `cmd:"" help:"tail a VM's log, or stoat's own"` + Screenshot screenshotCmd `cmd:"" help:"write the VM's screen to a PNG"` + Doctor doctorCmd `cmd:"" help:"check host prerequisites"` + Capabilities capabilitiesCmd `cmd:"" help:"report current agent capabilities"` + MCP mcpCmd `cmd:"" name:"mcp" help:"serve MCP, or configure a client to launch it"` + Version versionCmd `cmd:"" help:"print the stoat version"` + Help helpCmd `cmd:"" help:"show this message"` } // mcpCmd defaults to serve, because every MCP client launches the server as @@ -106,6 +107,9 @@ type initCmd struct { } type imagesCmd struct{} type doctorCmd struct{} +type capabilitiesCmd struct { + VM string `arg:"" optional:"" help:"vm name; omit for host scope"` +} type versionCmd struct{} type getCmd struct { @@ -355,6 +359,9 @@ func (g *grammar) toArgs(path string) (*Args, error) { case "images", "doctor", "version", "status": + case "capabilities": + a.VM = g.Capabilities.VM + case "help": // The `help` COMMAND has to render the text itself; only the -h/--help // FLAG path gets it from kong's own buffer. diff --git a/internal/cli/project.go b/internal/cli/project.go index b852430a..1fa22d93 100644 --- a/internal/cli/project.go +++ b/internal/cli/project.go @@ -3,6 +3,7 @@ package cli import ( "fmt" + "github.com/novusedge/stoat/internal/capabilities" "github.com/novusedge/stoat/internal/project" ) @@ -16,7 +17,7 @@ var vmCommands = map[string]bool{ "get": true, "up": true, "down": true, "ssh": true, "ssh-command": true, "rm": true, "clone": true, "exec": true, "cp": true, "forward": true, "snapshot": true, "wait": true, "apply": true, - "update": true, "logs": true, + "update": true, "logs": true, "capabilities": true, } // fanOutCommands are the commands that act on every declared VM when given no @@ -49,6 +50,12 @@ func resolveScope(a *Args) error { } // The name may still be a global VM that this project never declared; // only refuse when the data root does not have it either. + if a.Cmd == "capabilities" { + if _, err := capabilities.LoadTarget(a.VM); err != nil { + return err + } + return nil + } if _, err := coreGet(a.VM); err != nil { return fmt.Errorf("no VM %q in %s or ~/.stoat/vms", a.VM, project.FileName) } diff --git a/internal/cli/run_capabilities.go b/internal/cli/run_capabilities.go new file mode 100644 index 00000000..64724d1a --- /dev/null +++ b/internal/cli/run_capabilities.go @@ -0,0 +1,45 @@ +package cli + +import ( + "fmt" + "io" + + "github.com/novusedge/stoat/internal/capabilities" + "github.com/novusedge/stoat/internal/cli/wire" + "github.com/novusedge/stoat/internal/core" +) + +func runCapabilities(a *Args, version string, stdout, stderr io.Writer) int { + var target *capabilities.Target + if a.VM != "" { + loaded, err := capabilities.LoadTarget(a.VM) + if err != nil { + return a.fail(stdout, stderr, err) + } + target = &loaded + } + projectState := "absent" + if a.Project != nil { + projectState = "available" + } + report := capabilities.Build(capabilities.Input{ + Version: version, + ProjectState: projectState, + HostChecks: core.Doctor(), + Target: target, + }) + if a.JSON { + return a.ok(stdout, wire.Capabilities(report)) + } + fmt.Fprintln(stdout, "NAME STATUS SCOPE") + for _, profile := range report.Profiles { + fmt.Fprintf(stdout, "%-28s %-11s %s\n", profile.Name, profile.Status, profile.Scope) + } + for _, entry := range report.Capabilities { + fmt.Fprintf(stdout, "%-28s %-11s %s\n", entry.Name, entry.Status, entry.Scope) + } + for _, entry := range report.Unavailable { + fmt.Fprintf(stdout, "%-28s %-11s %s\n", entry.Name, entry.Status, entry.Scope) + } + return ExitOK +} diff --git a/internal/cli/wire/capabilities.go b/internal/cli/wire/capabilities.go new file mode 100644 index 00000000..4da8d951 --- /dev/null +++ b/internal/cli/wire/capabilities.go @@ -0,0 +1,6 @@ +package wire + +import "github.com/novusedge/stoat/internal/capabilities" + +// Capabilities aliases the shared report so the CLI and MCP use one schema. +type Capabilities = capabilities.Report diff --git a/internal/mcpsrv/tools_read.go b/internal/mcpsrv/tools_read.go index e0c49817..4ee0d2b1 100644 --- a/internal/mcpsrv/tools_read.go +++ b/internal/mcpsrv/tools_read.go @@ -6,6 +6,7 @@ import ( "io" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/novusedge/stoat/internal/capabilities" "github.com/novusedge/stoat/internal/cli/wire" "github.com/novusedge/stoat/internal/core" ) @@ -16,6 +17,10 @@ type vmIn struct { VM string `json:"vm" jsonschema:"name of the VM"` } +type capabilitiesIn struct { + VM string `json:"vm,omitempty" jsonschema:"optional VM name; omit for host scope"` +} + type listRecipesIn struct { OS string `json:"os,omitempty" jsonschema:"only recipes applicable to this guest OS"` Backend string `json:"backend,omitempty" jsonschema:"only recipes applicable to this backend"` @@ -130,6 +135,34 @@ func (s *srv) registerRead(server *mcp.Server) { return wire.FromDoctor(core.Doctor()), nil }) + register(server, "capabilities", classRead, + "Read host checks and stored VM metadata to report current capabilities, MCP access limits, and unavailable fork or continuation proposals. It does not start or connect to a VM. Read-only.", + func(ctx context.Context, in capabilitiesIn) (wire.Capabilities, error) { + var target *capabilities.Target + if in.VM != "" { + name, err := checkVMName(in.VM) + if err != nil { + return wire.Capabilities{}, err + } + loaded, err := capabilities.LoadTarget(name) + if err != nil { + return wire.Capabilities{}, err + } + target = &loaded + } + projectState := "absent" + switch { + case s.projErr != nil: + projectState = "unknown" + case s.proj != nil: + projectState = "available" + } + return wire.Capabilities(capabilities.Build(capabilities.Input{ + Version: s.opts.Version, ProjectState: projectState, + HostChecks: core.Doctor(), Target: target, + })), nil + }) + register(server, "plan_recipes", classRead, "Report what apply_recipes would do to a VM, without running anything: one entry per recipe with run or skip and the reason. It is computed on the host, so it works on a stopped VM. Call it before apply_recipes. Read-only.", func(ctx context.Context, in planRecipesIn) (wire.ApplyPlanList, error) { From a31fa27346ea241ce0268c7ef1b48b4a63d5c96f Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sun, 6 Sep 2026 06:01:57 +0300 Subject: [PATCH 3/6] docs(mcp): align capabilities reference Signed-off-by: NovusEdge --- docs/reference/cli.md | 2 +- docs/reference/mcp.md | 18 +++++------------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 21837301..2ae6f617 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -63,8 +63,8 @@ order, when given no VM argument. A bare VM argument resolves against | [`guest show`](#stoat-guest-show-name) | Print one guest's merged definition | 0, 1 | | [`logs`](#stoat-logs-name--n-n) | Tail a VM's log, or stoat's own | 0, 1 | | [`screenshot`](#stoat-screenshot-name--o-path) | Write the VM's screen to a PNG | 0, 1 | -| [`doctor`](#stoat-doctor) | Check host prerequisites | 0, 1 | | [`capabilities`](#stoat-capabilities-vm) | Report current agent capabilities | 0, 1 | +| [`doctor`](#stoat-doctor) | Check host prerequisites | 0, 1 | | [`version`](#stoat-version) | Print the stoat version | 0 | | [`help`](#stoat-help) | Show the usage message | 0 | diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index 68ba801d..2a88cbb0 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -2,14 +2,7 @@ `stoat mcp` serves Stoat's Model Context Protocol server over stdio. MCP clients launch it as a subprocess, normally with the working directory of the -project they should manage. - -The read-only capabilities tool reads host checks and stored VM metadata. Its -optional vm argument selects one VM; omit it for host and project scope. It -does not start or connect to a VM, and it reports MCP access limits plus -unavailable fork and continuation proposals. Discovery does not mutate a VM. - -The server uses the same Go wire types as +project they should manage. The server uses the same Go wire types as `--json`; see [JSON output](json.md). ## Install a client entry @@ -98,8 +91,10 @@ that run guest code are marked by their required level. ### Host and recipe tools -The capabilities tool reads host checks and optional stored VM metadata. It -uses no VM connection or guest mutation. +The read-only capabilities tool reads host checks and stored VM metadata. Its +optional vm argument selects one VM; omit it for host and project scope. It +does not start or connect to a VM, and it reports MCP access limits plus +unavailable fork and continuation proposals. Discovery does not mutate a VM. | Tool | Purpose | |---|---| @@ -133,9 +128,6 @@ Guest operations require a running VM. `copy_to` and `copy_from` restrict the host side to the VM's configured shared directory. `pkg_install` uses the loaded guest definition's package manager. `svc` uses its service templates. -agent_access is enforced by MCP guest tools. Direct CLI stoat exec and stoat cp -remain outside that policy. - ## Project tools and scope Project tools use the directory in which the server was started. They do not From 89d2d68dcea63b7db2fc8670c94560ff40e834dd Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sun, 6 Sep 2026 06:03:38 +0300 Subject: [PATCH 4/6] test(capabilities): verify CLI MCP parity Signed-off-by: NovusEdge --- internal/cli/capabilities_test.go | 100 +++++++++++++++++++++++++++ internal/mcpsrv/capabilities_test.go | 20 ++++++ 2 files changed, 120 insertions(+) diff --git a/internal/cli/capabilities_test.go b/internal/cli/capabilities_test.go index 50353674..ea632e25 100644 --- a/internal/cli/capabilities_test.go +++ b/internal/cli/capabilities_test.go @@ -2,14 +2,19 @@ package cli import ( "bytes" + "context" "encoding/json" "os" "path/filepath" + "reflect" "strconv" "strings" "testing" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/novusedge/stoat/internal/capabilities" "github.com/novusedge/stoat/internal/config" + "github.com/novusedge/stoat/internal/mcpsrv" ) func TestCapabilitiesCLIUsesHumanAndJSONForms(t *testing.T) { @@ -47,6 +52,101 @@ func TestCapabilitiesCLIUsesHumanAndJSONForms(t *testing.T) { } } +func callCapabilitiesMCP(t *testing.T, version string, args map[string]any) *mcp.CallToolResult { + t.Helper() + ctx := context.Background() + srv := mcpsrv.New(mcpsrv.Options{Version: version, Limits: mcpsrv.DefaultLimits()}) + ct, st := mcp.NewInMemoryTransports() + if _, err := srv.Connect(ctx, st, nil); err != nil { + t.Fatal(err) + } + cs, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "0"}, nil).Connect(ctx, ct, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := cs.Close(); err != nil { + t.Error(err) + } + }) + res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "capabilities", Arguments: args}) + if err != nil { + t.Fatal(err) + } + return res +} + +func decodeCapabilitiesEnvelope(t *testing.T, output []byte) capabilities.Report { + t.Helper() + var envelope struct { + Cmd string `json:"cmd"` + OK bool `json:"ok"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(bytes.TrimSpace(output), &envelope); err != nil { + t.Fatalf("capabilities output is not one JSON envelope: %v; output=%q", err, output) + } + if envelope.Cmd != "capabilities" || !envelope.OK { + t.Fatalf("capabilities envelope = %+v, want cmd=capabilities and ok=true", envelope) + } + var report capabilities.Report + if err := json.Unmarshal(envelope.Data, &report); err != nil { + t.Fatalf("capabilities data is not a report: %v; data=%s", err, envelope.Data) + } + return report +} + +func TestCapabilitiesCLIAndMCPReportsHaveSemanticParity(t *testing.T) { + projectRoot(t, twoVMs) + if err := (&config.VM{Name: "myrepo-dev", Mode: "cloud", AgentAccess: "observe"}).Save(); err != nil { + t.Fatal(err) + } + + var cliOut, cliErr bytes.Buffer + if code := Main([]string{"--json", "capabilities", "myrepo-dev"}, "v-test", strings.NewReader(""), &cliOut, &cliErr); code != ExitOK { + t.Fatalf("CLI capabilities exit = %d, want ExitOK; stdout=%q stderr=%q", code, cliOut.String(), cliErr.String()) + } + cliReport := decodeCapabilitiesEnvelope(t, cliOut.Bytes()) + res := callCapabilitiesMCP(t, "v-test", map[string]any{"vm": "myrepo-dev"}) + if res.IsError { + t.Fatalf("MCP capabilities failed: %+v", res.Content) + } + mcpRaw, err := json.Marshal(res.StructuredContent) + if err != nil { + t.Fatal(err) + } + var mcpReport capabilities.Report + if err := json.Unmarshal(mcpRaw, &mcpReport); err != nil { + t.Fatalf("MCP capabilities data is not a report: %v; output=%s", err, mcpRaw) + } + if !reflect.DeepEqual(cliReport, mcpReport) { + t.Fatalf("CLI and MCP capability reports differ:\nCLI: %+v\nMCP: %+v", cliReport, mcpReport) + } + if cliReport.Target == nil || cliReport.Target.Name != "myrepo-dev" || cliReport.Host.ProjectState != "available" || cliReport.StoatVersion != "v-test" { + t.Errorf("shared report target/version/project = target=%+v version=%q project=%q", cliReport.Target, cliReport.StoatVersion, cliReport.Host.ProjectState) + } +} + +func TestCapabilitiesCLIMissingTargetUsesNotFound(t *testing.T) { + cliRoot(t) + var out, errOut bytes.Buffer + if code := Main([]string{"--json", "capabilities", "missing"}, "v-test", strings.NewReader(""), &out, &errOut); code != ExitFail { + t.Fatalf("missing target exit = %d, want ExitFail; stdout=%q stderr=%q", code, out.String(), errOut.String()) + } + var envelope struct { + OK bool `json:"ok"` + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(bytes.TrimSpace(out.Bytes()), &envelope); err != nil { + t.Fatalf("missing target output is not one JSON envelope: %v; output=%q", err, out.String()) + } + if envelope.OK || envelope.Error.Code != "not_found" { + t.Errorf("missing target envelope = %+v, want ok=false error.code=not_found", envelope) + } +} + func TestCapabilitiesGlobalVMInProjectPreservesStalePID(t *testing.T) { projectRoot(t, twoVMs) if err := (&config.VM{Name: "outside", Mode: "cloud"}).Save(); err != nil { diff --git a/internal/mcpsrv/capabilities_test.go b/internal/mcpsrv/capabilities_test.go index c7f114a4..80b33851 100644 --- a/internal/mcpsrv/capabilities_test.go +++ b/internal/mcpsrv/capabilities_test.go @@ -3,7 +3,10 @@ package mcpsrv import ( "encoding/json" "slices" + "strings" "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" ) func TestCapabilitiesMCPToolIsOptionalVMReadOnly(t *testing.T) { @@ -80,3 +83,20 @@ func TestCapabilitiesMCPToolIsOptionalVMReadOnly(t *testing.T) { t.Errorf("capabilities schema = %d, want 1; output=%s", out.Schema, raw) } } + +func TestCapabilitiesMCPMissingTargetIsNotFound(t *testing.T) { + t.Setenv("STOAT_HOME", t.TempDir()) + res := callTool(t, "capabilities", map[string]any{"vm": "missing"}) + if !res.IsError { + t.Fatal("capabilities accepted a missing target") + } + var message strings.Builder + for _, content := range res.Content { + if text, ok := content.(*mcp.TextContent); ok { + message.WriteString(text.Text) + } + } + if !strings.Contains(message.String(), "missing") || !strings.Contains(strings.ToLower(message.String()), "not found") { + t.Errorf("missing target MCP error = %q, want existing not-found message", message.String()) + } +} From 8292f254e0120064b9ccbc37ddb08117a660fa0b Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sun, 6 Sep 2026 06:04:35 +0300 Subject: [PATCH 5/6] docs(json): clarify capability discovery Signed-off-by: NovusEdge --- docs/reference/json.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/reference/json.md b/docs/reference/json.md index a70e5100..1ad439d3 100644 --- a/docs/reference/json.md +++ b/docs/reference/json.md @@ -618,7 +618,8 @@ contract stays 3. stoat capabilities [VM] --json returns a report with schema 1 in the usual result envelope. The command reads host checks and one VM's stored metadata. It does not start, connect to, or mutate a VM. Omit VM for host and project -scope; a target adds its stored name, mode, and normalized agent_access values. +scope; a target adds its directory name, stored mode, and normalized +agent_access values. The report has these fields: @@ -635,8 +636,10 @@ The report has these fields: Each profile or capability has name, status, scope, requirements, limits, optional reason, and evidence. Status is supported, limited, unsupported, or -unknown. limited carries a limit code; unsupported and unknown carry a reason -code. Every list is [] when empty, never null. +unknown. supported means the implementation is available and its required +observations are available. Discovery does not establish VM readiness. limited +carries a limit code; unsupported and unknown carry a reason code. Every list +is [] when empty, never null. The stable reason codes are not_implemented, host_probe_unavailable, agent_access_unknown, target_mode_unknown, and project_state_unknown. The From 8e222e8c702ea7d28711c5e08509d628a7ca9112 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Sun, 6 Sep 2026 06:16:57 +0300 Subject: [PATCH 6/6] docs(json): align example fence style Signed-off-by: NovusEdge --- docs/reference/json.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/json.md b/docs/reference/json.md index 1ad439d3..ee0c69d2 100644 --- a/docs/reference/json.md +++ b/docs/reference/json.md @@ -649,7 +649,7 @@ agent_access; direct CLI exec and cp do not. A live target limits vm.snapshot with disk_required. runtime.fork and runtime.continuation always appear under unavailable with unsupported/not_implemented. -~~~json +```json { "schema": 1, "stoat_version": "dev", @@ -666,7 +666,7 @@ unavailable with unsupported/not_implemented. {"name": "runtime.continuation", "status": "unsupported", "scope": "runtime", "requirements": [], "limits": [], "reason": {"code": "not_implemented"}, "evidence": [{"kind": "implementation", "source": "runtime.continuation", "result": "not_implemented"}]} ] } -~~~ +``` The example abbreviates profiles and capabilities; a real report always contains the implemented entries. The proposal evidence identifies the report