Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
a887e55
chore(mcp): add the go-sdk dependency
NovusEdge Sep 5, 2026
6ec8358
test(mcp): pin contract version 3 and record its reason
NovusEdge Sep 5, 2026
17ae26f
test(mcp): pin the guards ported from Python
NovusEdge Sep 5, 2026
d1d4578
test(mcp): pin index, param and guest path guards
NovusEdge Sep 5, 2026
5fc7d1d
test(mcp): pin per-tool and shared rate limits
NovusEdge Sep 5, 2026
eb84e9b
test(mcp): pin the tool table and server registration
NovusEdge Sep 5, 2026
9a632a1
test(mcp): pin the rate limit middleware over a real tool call
NovusEdge Sep 5, 2026
f66a77c
feat(mcp): port the guards to Go
NovusEdge Sep 5, 2026
4606173
feat(mcp): add the server and read-only tools
NovusEdge Sep 5, 2026
038923c
feat(mcp): charge the rate limits in middleware
NovusEdge Sep 5, 2026
1e2ad86
test(mcp): probe the shared bucket with fresh tools
NovusEdge Sep 5, 2026
c6691d3
feat(mcp): add per-tool and shared rate limits
NovusEdge Sep 5, 2026
a8b23c4
test(mcp): pin host-side VM tools
NovusEdge Sep 5, 2026
ae0de99
test(mcp): pin agent access levels
NovusEdge Sep 5, 2026
11bf466
test(sshx): pin Run and the fake ssh harness
NovusEdge Sep 5, 2026
d50b041
test(mcp): pin in-VM read tools
NovusEdge Sep 5, 2026
4ef1518
test(mcp): pin in-VM manage tools
NovusEdge Sep 5, 2026
b109c98
test(mcp): pin exec and background job tools
NovusEdge Sep 5, 2026
4c92e10
refactor(sshx): add Run and one argv quoter
NovusEdge Sep 5, 2026
0729727
feat(mcp): replace allow_exec with agent_access levels
NovusEdge Sep 5, 2026
cc9b790
feat(mcp): add host-side VM tools
NovusEdge Sep 5, 2026
c918828
feat(mcp): add in-VM read tools
NovusEdge Sep 5, 2026
ba505e7
feat(mcp): add in-VM manage tools
NovusEdge Sep 5, 2026
5ddf60e
feat(mcp): add exec and background job tools
NovusEdge Sep 5, 2026
e6ef5c9
fix(config): map legacy allow_exec to agent_access on load
NovusEdge Sep 5, 2026
96af98d
fix(testutil): use POSIX idiom for fake ssh's last arg
NovusEdge Sep 5, 2026
e831ba9
fix(guest): set alpine's log_path for tail_log's fallback
NovusEdge Sep 5, 2026
709ba46
feat(wire): add agent_access to the VM DTO
NovusEdge Sep 5, 2026
d90f3b8
feat(cli): add --agent-access to create and update
NovusEdge Sep 5, 2026
f87bd10
fix(mcpsrv): route list_dir and ps through wire.NonNil
NovusEdge Sep 5, 2026
95ef3b9
test(sshx): read alpine's escalate from the guest file
NovusEdge Sep 5, 2026
6200e86
test(mcp): give the fixture VM a real guest os
NovusEdge Sep 5, 2026
5f5f499
test(mcp): drive access checks through callTool
NovusEdge Sep 5, 2026
deb1089
test(sshx): pin exact argv for non-escalating root
NovusEdge Sep 5, 2026
1d8700c
test(mcp): pin exec env names and stopped-VM refusals
NovusEdge Sep 5, 2026
54d5642
fix(cli): make --allow-exec alias --agent-access again
NovusEdge Sep 5, 2026
a14702e
fix(mcpsrv): report not_running for write_file and exec_bg
NovusEdge Sep 5, 2026
4d3af51
fix(mcpsrv): guard env var names, not svc names
NovusEdge Sep 5, 2026
bdf97f5
fix(mcpsrv): error on a guest with no svc verb
NovusEdge Sep 5, 2026
434fb28
fix(config): stop re-reading vm.toml for the legacy check
NovusEdge Sep 5, 2026
c3d0114
test(mcp): pin guest and recipe schema tool behaviour
NovusEdge Sep 5, 2026
b031e01
test(mcp): pin install and doctor behaviour
NovusEdge Sep 5, 2026
9e6f769
test(mcp): pin recipe index tool boundary
NovusEdge Sep 5, 2026
a68cb7d
test(mcp): pin exec's not-running refusal and the allow-exec alias
NovusEdge Sep 5, 2026
30b53af
test(mcp): pin secret redaction over wire values
NovusEdge Sep 5, 2026
e94bf60
test(cli): pin stoat mcp grammar, loopback guard, and doctor JSON
NovusEdge Sep 5, 2026
9bb6000
feat(mcp): add install and doctor subcommands
NovusEdge Sep 5, 2026
9584c31
feat(mcp): add recipe index tools
NovusEdge Sep 5, 2026
cc43a1e
feat(mcp): redact secrets in sending middleware
NovusEdge Sep 5, 2026
881a42d
feat(cli): add stoat mcp and the two transports
NovusEdge Sep 5, 2026
bdf6e7c
feat(mcp): add guest and recipe schema tools
NovusEdge Sep 5, 2026
b281781
merge: task 2x
NovusEdge Sep 5, 2026
7a762c0
merge: task 13
NovusEdge Sep 5, 2026
e04fd26
merge: task 14
NovusEdge Sep 5, 2026
c80dc59
merge: task 15
NovusEdge Sep 5, 2026
b8791f2
merge: task 16
NovusEdge Sep 5, 2026
8be8629
merge: task 17
NovusEdge Sep 5, 2026
d06c0ad
feat(mcp): add search_recipes and finish task 14
NovusEdge Sep 5, 2026
3fbab5e
fix(mcp): keep mcpsrv off qemu and recipes imports
NovusEdge Sep 5, 2026
e71a18f
fix(mcp): mask secrets nested inside a list
NovusEdge Sep 5, 2026
99961b1
fix(mcp): drop the dead secrets clear in update
NovusEdge Sep 5, 2026
c0021a5
test(mcp): cover mcp doctor and install --print in JSON
NovusEdge Sep 5, 2026
6d77148
chore(mcp): delete the ported Python server
NovusEdge Sep 5, 2026
27e2c7f
ci: drop the mcp python test job
NovusEdge Sep 5, 2026
13b1d4b
docs(mcp): rewrite design doc for the Go server
NovusEdge Sep 5, 2026
1b86408
fix(mcp): stop tail_log escalating a caller's path
NovusEdge Sep 5, 2026
75fecfe
fix(mcp): split list_dir names on newline
NovusEdge Sep 5, 2026
c451b8f
fix(mcp): accept a slash in add_recipe's git ref
NovusEdge Sep 5, 2026
4068ba9
test(mcp): finish the port table for recipe tools
NovusEdge Sep 5, 2026
2071e3a
fix(mcp): read a client config with mixed top keys
NovusEdge Sep 5, 2026
4046281
docs(mcp): match the doc to the shipped server
NovusEdge Sep 5, 2026
91aac22
docs(json): document the mcp command results
NovusEdge Sep 5, 2026
c51f34a
merge: origin/main into feat/mcp-go
NovusEdge Sep 5, 2026
756bb38
fix(mcp): make the job dir escalated and run under nohup
NovusEdge Sep 5, 2026
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
24 changes: 0 additions & 24 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,27 +58,3 @@ jobs:
# bundled installer is not worth a red PR.
- name: shellcheck
run: shellcheck -S warning $(git ls-files 'internal/recipes/bundled/*.sh' 'internal/recipes/bundled/*/*.sh' 'scripts/*.sh' '.githooks/*')

# A separate job because it needs Python rather than Go, and because a
# failure here should read as "the MCP server broke", not "the Go suite
# broke". Until this existed, mcp/'s tests ran only on a contributor's
# machine, so a regression in guards.py (the deterministic blocks that are
# the whole security boundary, since MCP guarantees nothing itself) would
# have merged green.
mcp:
name: mcp
runs-on: ubuntu-latest
defaults:
run:
working-directory: mcp
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: astral-sh/setup-uv@v5

- name: install
run: uv pip install --system -e '.[dev]'
- name: test
run: pytest -q
432 changes: 250 additions & 182 deletions docs/design/mcp-server.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions docs/reference/guest.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ capabilities = ["pkg"] # feeds recipe.toml's `requires`; the lo
aliases = ["bsd"] # extra keys a recipe's [scripts] map may use for this OS
filename_hints = ["FreeBSD-"] # recognise this OS in a BYO image filename
seed_packages = ["sudo"] # packages the cloud-init seed assumes but the image may lack
log_path = "/var/log/messages" # tail_log's fallback when a unit and a path are both omitted; optional

[pkg]
setup = "pkg update" # prelude's stoat_pkg_setup; empty means no refresh needed
Expand All @@ -41,8 +42,8 @@ skip_9p = true
## Field rules

- Required: every top-level scalar and list except `installer`, `aliases`,
`filename_hints`; every `[pkg]` and `[svc]` key except `scaffold_setup`. A
missing one is `guest.toml: <name>: missing <field>`.
`filename_hints`, `log_path`; every `[pkg]` and `[svc]` key except
`scaffold_setup`. A missing one is `guest.toml: <name>: missing <field>`.
- Unknown keys are an error: `<path>: unknown key "<key>"`.
- `schema` must be present and equal to 1.
- The loader appends `init` to `capabilities`. A file whose `capabilities`
Expand Down
36 changes: 31 additions & 5 deletions docs/reference/json.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
# JSON Output Reference

`--json` turns any subcommand into a machine interface. It exists because
stoat's MCP server is a separate Python process that reaches `internal/core`
only by running this binary and reading its output, so everything a caller
would otherwise regex, guess at, or reconstruct is defined here instead.
`--json` turns any subcommand into a machine interface, so everything a
caller would otherwise regex, guess at, or reconstruct is defined here
instead.

This document is the contract. The human-facing CLI is documented in
[cli.md](cli.md).

`stoat mcp` serves the same contract over MCP from inside the same binary.
Every tool's output type in `internal/mcpsrv` is a `wire` struct, the same Go
type the matching `--json` command emits, so the two cannot drift: the MCP
schema is generated from these types, not maintained separately. See
`internal/mcpsrv/table_test.go` for the tool table.

```
stoat --json ls
{"v":3,"type":"result","cmd":"ls","ok":true,"data":{"vms":[...]}}
Expand Down Expand Up @@ -190,7 +195,7 @@ VM {"name":"work","os":"alpine","mode":"cloud","backend":"cloudinit",
"share":"/home/u/src","recipes":["xfce"],
"ssh_port":2200,"ssh_user":"stoat","installed":false,
"forwards":[{"host_port":8080,"guest_port":80}],
"allow_exec":true,"display":"vnc",
"allow_exec":true,"agent_access":"manage","display":"vnc",
"error":"only on a broken VM"}

VMStatus {"name":"work",...VM fields...,"health":"ok","recipes_detail":[
Expand Down Expand Up @@ -252,8 +257,15 @@ Guest {"name":"fedora","init":"systemd","shell":"/bin/bash",
"svc":{"enable":"systemctl enable {name}", ...},
"cmd":{},"backend":{"cloudinit":{"skip_9p":false}},
"source":"bundled"}

MCPClient {"client":"cursor","path":"/home/u/.cursor/mcp.json",
"installed":true,"command":"/home/u/.local/bin/stoat",
"current":true}
```

`MCPClient.current` is false when the client's entry names a different
binary than the running one, which is the stale entry `mcp doctor` reports.

`RecipeEntry` has `name`, `description`, `scope`, `source`, `ref`, and
`commit`. `scope` is one of `bundled`, `local`, `global`, or `project`; only
`global` and `project` entries carry `source`, `ref`, and the seven-character
Expand All @@ -277,6 +289,12 @@ enforced one: `stoat exec`/`cp` do not check it, so a consumer that must
refuse exec on a VM with `allow_exec:false` (the MCP server) has to check it
itself before calling.

`agent_access` supersedes `allow_exec` with four levels (`none`, `observe`,
`manage`, `exec`) instead of a boolean; each level includes every tool the
ones below it allow. `allow_exec:true` loads as `exec`, `false` as `manage`,
so an old VM keeps its meaning under the new field. `stoat mcp`'s
`requireAccess` is what enforces it; `stoat exec`/`cp` still do not.

`Snapshot.size_display` and `created_display` are named that way because they
are qemu's own formatted table output. They are opaque. Do not parse them.

Expand Down Expand Up @@ -389,6 +407,8 @@ so a leak fails the build rather than shipping.
| `logs` (no VM) | `{"lines":[...]}` (stoat's own log) |
| `logs <vm>` | `{"vm":"work","which":"console","lines":[...]}` |
| `doctor` | `{"healthy":false,"checks":[Check,...]}` |
| `mcp doctor` | `{"contract":3,"version":"1.2.3","transport":"stdio","binary":"/home/u/.local/bin/stoat","clients":[MCPClient,...]}` |
| `mcp install` | `{"client":"cursor","path":"/home/u/.cursor/mcp.json","json":"{...}"}` |
| `version` | `{"version":"1.2.3","contract":3}` |
| `help` | `{"usage":"..."}` |
| `ssh` | **refused**, see below |
Expand Down Expand Up @@ -526,3 +546,9 @@ contents of any `*_display` field, or the absence of fields it does not know.
a list of `{path, scope}` in search order, and `recipes` became a list of
`RecipeEntry` objects rather than names. A consumer that read
`data.recipes[]` as strings reads `data.recipes[].name` instead.

The same version also adds `agent_access` to `VM`, additive alongside
`allow_exec`, and moves the MCP server from a separate Python process into
`stoat mcp` in this binary. Neither change removes or repurposes a field, so
neither bumped the version on its own; they are noted here only because they
landed in the same branch as the `recipe list` change.
1 change: 1 addition & 0 deletions docs/reference/samples/guest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ capabilities = ["apk"] # string[]; default []; guest author writes
aliases = [] # string[]; default []; guest author writes alternate script keys.
filename_hints = ["alpine"] # string[]; default []; guest author writes BYO-image filename hints.
seed_packages = ["sudo"] # string[]; default []; guest author writes cloud-init seed packages.
log_path = "/var/log/messages" # string; default empty; tail_log's fallback when a systemd unit has no journal.

[pkg]
setup = "apk update" # string; default empty; guest author writes the package-index prelude.
Expand Down
7 changes: 7 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (
github.com/alecthomas/kong v1.16.1
github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/x/term v0.2.2
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/pelletier/go-toml/v2 v2.4.3
golang.org/x/sys v0.47.0
gopkg.in/yaml.v3 v3.0.1
Expand All @@ -31,14 +32,20 @@ require (
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-runewidth v0.0.27 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sahilm/fuzzy v0.1.3 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/time v0.15.0 // indirect
)
18 changes: 18 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
Expand All @@ -74,6 +78,8 @@ github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhV
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
Expand All @@ -84,16 +90,28 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/sahilm/fuzzy v0.1.3 h1:juByESSS32nVD81vr6tHmKmA/8zde7gE+x5CLxrzXPU=
github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
12 changes: 12 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/novusedge/stoat/internal/guest"
"github.com/novusedge/stoat/internal/keys"
"github.com/novusedge/stoat/internal/logx"
"github.com/novusedge/stoat/internal/mcpsrv"
"github.com/novusedge/stoat/internal/recipes"
)

Expand Down Expand Up @@ -136,6 +137,15 @@ type Args struct {
Patch core.Patch
Changed []string
Params []ParamEdit

// HTTP, Limits, Client, Project and Print belong to "mcp". HTTP is the
// loopback address for "mcp serve --http"; empty means stdio. Client,
// Project and Print belong to "mcp install".
HTTP string
Limits mcpsrv.Limits
Client string
Project bool
Print bool
}

// ParamEdit is one recipe parameter edit parsed from create or update flags.
Expand Down Expand Up @@ -491,6 +501,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 "mcp":
return runMCP(a, version, stdout, stderr)
default:
// Unreachable: Parse already rejected anything not handled above.
fmt.Fprintln(stderr, "stoat: unknown subcommand", a.Cmd)
Expand Down
48 changes: 42 additions & 6 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,23 +128,43 @@ func TestParse(t *testing.T) {
}
}

// TestParseCreateAllowExecDefaultsTrue pins that an omitted --allow-exec
// still produces Spec.AllowExec pointing at true. A nil pointer would hide
// a kong misconfiguration that made the flag look unset. false would be
// Go's bool zero value, the regression AllowExec's default guards against.
// TestParseCreateAllowExecDefaultsTrue pins that a bare --allow-exec, with
// no value, still produces Spec.AllowExec pointing at true: AllowExec is a
// *bool with no kong default tag, so a bare flag relies on kong's ordinary
// bool parsing, not a default value. It also maps to the exec agent_access
// level, --allow-exec's alias contract.
func TestParseCreateAllowExecDefaultsTrue(t *testing.T) {
got, err := Parse([]string{"create", "work", "--image", "alpine", "--allow-exec"})
if err != nil {
t.Fatal(err)
}
if got.Spec.AllowExec == nil || !*got.Spec.AllowExec {
t.Errorf("create --allow-exec: Spec.AllowExec = %v, want a pointer to true", got.Spec.AllowExec)
}
if got.Spec.AgentAccess != "exec" {
t.Errorf("create --allow-exec: Spec.AgentAccess = %q, want %q", got.Spec.AgentAccess, "exec")
}
}

// TestParseCreateAllowExecOmittedDefaultsToManage pins that omitting both
// --allow-exec and --agent-access still gives Spec.AllowExec a pointer to
// true (see Spec.AllowExec's doc comment), while Spec.AgentAccess defaults
// to manage: only a given --allow-exec maps to exec.
func TestParseCreateAllowExecOmittedDefaultsToManage(t *testing.T) {
got, err := Parse([]string{"create", "work", "--image", "alpine"})
if err != nil {
t.Fatal(err)
}
if got.Spec.AllowExec == nil || !*got.Spec.AllowExec {
t.Errorf("create with no --allow-exec: Spec.AllowExec = %v, want a pointer to true", got.Spec.AllowExec)
}
if got.Spec.AgentAccess != "manage" {
t.Errorf("create with no --allow-exec: Spec.AgentAccess = %q, want %q", got.Spec.AgentAccess, "manage")
}
}

// TestParseCreateAllowExecFalse pins that --allow-exec=false is how a
// caller turns it off; kong's bool default:"true" makes a bare --allow-exec
// (no value) mean true, matching every other bool flag.
// caller turns it off, mapping to the manage agent_access level.
func TestParseCreateAllowExecFalse(t *testing.T) {
got, err := Parse([]string{"create", "work", "--image", "alpine", "--allow-exec=false"})
if err != nil {
Expand All @@ -153,6 +173,22 @@ func TestParseCreateAllowExecFalse(t *testing.T) {
if got.Spec.AllowExec == nil || *got.Spec.AllowExec {
t.Errorf("create --allow-exec=false: Spec.AllowExec = %v, want a pointer to false", got.Spec.AllowExec)
}
if got.Spec.AgentAccess != "manage" {
t.Errorf("create --allow-exec=false: Spec.AgentAccess = %q, want %q", got.Spec.AgentAccess, "manage")
}
}

// TestParseCreateAgentAccessWinsOverAllowExec pins that an explicit
// --agent-access overrides the hidden --allow-exec alias, so a caller who
// passes both is not silently downgraded by the legacy flag.
func TestParseCreateAgentAccessWinsOverAllowExec(t *testing.T) {
got, err := Parse([]string{"create", "work", "--image", "alpine", "--allow-exec", "--agent-access", "observe"})
if err != nil {
t.Fatal(err)
}
if got.Spec.AgentAccess != "observe" {
t.Errorf("create --allow-exec --agent-access observe: Spec.AgentAccess = %q, want %q", got.Spec.AgentAccess, "observe")
}
}

// TestParsePure guards against Parse doing anything beyond interpreting
Expand Down
Loading
Loading