Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 95ae900a58 | |||
| be066a4b04 |
@@ -8,6 +8,7 @@
|
||||
/mavcaldav
|
||||
/mavwaked
|
||||
/mavmaild
|
||||
/mavupdate
|
||||
|
||||
# Certs (private keys, don't commit)
|
||||
certs/
|
||||
|
||||
@@ -20,7 +20,7 @@ PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
||||
|
||||
all: build
|
||||
|
||||
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav build-mail
|
||||
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav build-mail build-update
|
||||
|
||||
build-stt:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
@@ -53,6 +53,12 @@ build-caldav:
|
||||
build-mail:
|
||||
$(GO) build $(GOFLAGS) -o mavmaild ./cmd/mavmaild/
|
||||
|
||||
# mavupdate is an operator CLI, not a daemon: nothing runs it but a human on the
|
||||
# box. It is built with the rest so a broken update path is caught by `make
|
||||
# build` rather than the first time it is needed.
|
||||
build-update:
|
||||
$(GO) build $(GOFLAGS) -o mavupdate ./cmd/mavupdate/
|
||||
|
||||
run-web: build-web
|
||||
./mavweb -addr :9200 -voice 127.0.0.1:9100
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
// Command mavupdate deploys a new build of Maven to the box she runs on, with
|
||||
// an automatic rollback when the new build does not come up (Vikunja #249).
|
||||
//
|
||||
// It is a CLI on purpose, and it is the ONLY trigger for the update path.
|
||||
//
|
||||
// The obvious design — an IPC method plus a button on the web UI behind the
|
||||
// step-up passkey gate, the way /tools works — was considered and refused. A
|
||||
// step-up gate protects against the wrong person clicking; it does not change
|
||||
// the fact that anything reachable over the network becomes, in the event of a
|
||||
// mavweb bug, a remote arbitrary-code path with a build system attached. An
|
||||
// update needs shell access on the host, which is a strictly higher bar than
|
||||
// the gate that guards the tool allowlist. That is deliberate and it is the
|
||||
// reason there is no MethodApplyUpdate anywhere in internal/ipc.
|
||||
//
|
||||
// Consequently: mavend does not import internal/update, nothing runs on a timer,
|
||||
// nothing checks a release server, and no act, intent, tool or LLM output can
|
||||
// reach any of this. She cannot update herself. She can be updated, by him.
|
||||
//
|
||||
// mavupdate -config deploy/mavend.json list # snapshots available to roll back to
|
||||
// mavupdate -config deploy/mavend.json verify # make build + make test, deploys nothing
|
||||
// mavupdate -config deploy/mavend.json apply -yes # the whole thing
|
||||
// mavupdate -config deploy/mavend.json rollback [id] # restore + restart (default: newest)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/update"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfgPath := flag.String("config", "deploy/mavend.json", "path to mavend.json (the update block is read from it)")
|
||||
yes := flag.Bool("yes", false, "required by `apply` and `rollback`: yes, restart the daemon")
|
||||
flag.Usage = usage
|
||||
flag.Parse()
|
||||
|
||||
// The stdlib flag package stops parsing at the first non-flag argument, so a
|
||||
// `-yes` written after the subcommand (which is how anyone would type it, and
|
||||
// how the usage text shows it) lands in Args instead of the flag. Pick it out
|
||||
// by hand rather than silently treating "apply -yes" as an unconfirmed apply.
|
||||
var args []string
|
||||
for _, a := range flag.Args() {
|
||||
if a == "-yes" || a == "--yes" {
|
||||
*yes = true
|
||||
continue
|
||||
}
|
||||
args = append(args, a)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
die("config: %v", err)
|
||||
}
|
||||
if cfg.Update == nil {
|
||||
die("no `update` block in %s — the update capability is off unless configured.\nSee the package comment in internal/update for what it does and does not do.", *cfgPath)
|
||||
}
|
||||
|
||||
logf := func(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, "%s %s\n", time.Now().Format("15:04:05"), fmt.Sprintf(format, a...))
|
||||
}
|
||||
u, err := update.New(*cfg.Update, update.WithLogger(logf))
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
|
||||
// Ctrl-C cancels the build or the health wait. It cannot cancel a rollback
|
||||
// midway into leaving the box in an unknown state, because the rollback runs
|
||||
// on its own context — see cmdApply.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
switch args[0] {
|
||||
case "list":
|
||||
cmdList(u)
|
||||
case "verify":
|
||||
cmdVerify(ctx, u)
|
||||
case "apply":
|
||||
if !*yes {
|
||||
die("apply restarts mavend and can roll her back. Re-run with -yes if that is what you want.")
|
||||
}
|
||||
cmdApply(ctx, u)
|
||||
case "rollback":
|
||||
if !*yes {
|
||||
die("rollback restores the previous artifacts and restarts mavend. Re-run with -yes.")
|
||||
}
|
||||
id := ""
|
||||
if len(args) > 1 {
|
||||
id = args[1]
|
||||
}
|
||||
cmdRollback(ctx, u, id)
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdList(u *update.Updater) {
|
||||
snaps, err := u.Snapshots()
|
||||
if err != nil {
|
||||
die("snapshots: %v", err)
|
||||
}
|
||||
if len(snaps) == 0 {
|
||||
fmt.Println("no snapshots yet — the first `apply` takes one before it builds anything")
|
||||
return
|
||||
}
|
||||
fmt.Printf("%-18s %-12s %s\n", "SNAPSHOT", "COMMIT", "FILES")
|
||||
for _, s := range snaps {
|
||||
commit := s.Commit
|
||||
if len(commit) > 12 {
|
||||
commit = commit[:12]
|
||||
}
|
||||
if commit == "" {
|
||||
commit = "-"
|
||||
}
|
||||
fmt.Printf("%-18s %-12s %d\n", s.ID, commit, len(s.Files))
|
||||
}
|
||||
fmt.Printf("\nrollback to the newest with: mavupdate rollback -yes\n")
|
||||
}
|
||||
|
||||
func cmdVerify(ctx context.Context, u *update.Updater) {
|
||||
steps, err := u.Verify(ctx)
|
||||
report(steps)
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
fmt.Println("verified: the tree builds and passes its own tests. Nothing was deployed — run `apply -yes` for that.")
|
||||
}
|
||||
|
||||
func cmdApply(ctx context.Context, u *update.Updater) {
|
||||
res, err := u.Apply(ctx)
|
||||
report(res.Steps)
|
||||
summarize(res)
|
||||
switch {
|
||||
case err == nil:
|
||||
fmt.Println("\nupdate committed: she answers on the new build.")
|
||||
case errors.Is(err, update.ErrRollbackFailed):
|
||||
die("\n%v\n\nSHE IS PROBABLY DOWN. The previous artifacts are in the snapshot dir; copy them\nover the install dir and restart by hand.", err)
|
||||
case errors.Is(err, update.ErrRolledBack):
|
||||
die("\n%v\n\nShe is answering again on the previous build. Nothing was lost; fix the change and retry.", err)
|
||||
default:
|
||||
die("\n%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdRollback(ctx context.Context, u *update.Updater, id string) {
|
||||
res, err := u.Rollback(ctx, id)
|
||||
report(res.Steps)
|
||||
summarize(res)
|
||||
if err != nil && !errors.Is(err, update.ErrRolledBack) {
|
||||
die("\n%v", err)
|
||||
}
|
||||
fmt.Printf("\nrolled back to %s; she answers on it.\n", res.SnapshotID)
|
||||
}
|
||||
|
||||
func report(steps []update.Step) {
|
||||
for _, s := range steps {
|
||||
status := "ok"
|
||||
if s.Err != nil {
|
||||
status = "FAILED: " + s.Err.Error()
|
||||
}
|
||||
fmt.Printf(" %-8s %-8s %s\n", s.Name, s.Took.Round(time.Second), status)
|
||||
if s.Output != "" {
|
||||
fmt.Printf("---- %s output ----\n%s\n-------------------\n", s.Name, s.Output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func summarize(res update.Result) {
|
||||
fmt.Printf("\nverified=%v snapshot=%s installed=%d restarted=%v healthy=%v rolled_back=%v rollback_healthy=%v took=%s\n",
|
||||
res.Verified, res.SnapshotID, len(res.Installed), res.Restarted, res.Healthy, res.RolledBack, res.RollbackHealthy, res.Took.Round(time.Second))
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, `mavupdate — deploy a new build of Maven, with rollback.
|
||||
|
||||
mavupdate [-config path] list
|
||||
mavupdate [-config path] verify
|
||||
mavupdate [-config path] apply -yes
|
||||
mavupdate [-config path] rollback [snapshot-id] -yes
|
||||
|
||||
apply is: health-check the running daemon, snapshot the deployed artifacts,
|
||||
make build, make test, install, restart, health-check — and restore the
|
||||
snapshot if any of that fails. It never fetches code and never runs by itself.
|
||||
|
||||
`)
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
func die(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -114,3 +114,49 @@ build on the target host, most likely in one of these:
|
||||
work fine over the core socket.
|
||||
- **netdata** — `mavpoll` reaches it via `host.docker.internal`; adjust if
|
||||
netdata runs elsewhere.
|
||||
|
||||
## Updating her (`mavupdate`, Vikunja #249)
|
||||
|
||||
Off unless configured, and there is deliberately no button for it. There is no
|
||||
IPC method, no web route, no timer and no act that starts an update — the trigger
|
||||
is a human running `mavupdate` on the host, which needs shell access, a strictly
|
||||
higher bar than the step-up passkey gate that guards `/tools`. She cannot update
|
||||
herself; she can be updated. Nothing here ever fetches code: the new version is
|
||||
whatever you pulled into the working tree yourself.
|
||||
|
||||
Add an `update` block to `mavend.json` (mavend ignores it — only the CLI reads
|
||||
it), with paths as they exist **on the host**, not inside a container:
|
||||
|
||||
```json
|
||||
"update": {
|
||||
"source_dir": "/home/kami/apps/Maven",
|
||||
"install_dir": "/home/kami/apps/Maven",
|
||||
"snapshot_dir": "/var/lib/maven-snapshots",
|
||||
"binaries": ["mavend", "mavweb", "mavsttd", "mavttsd", "mavwaked",
|
||||
"mavenclient", "mavpoll", "mavcaldav", "mavmaild"],
|
||||
"config_files": ["deploy/mavend.json"],
|
||||
"restart_cmd": ["docker", "compose", "up", "-d", "--build"],
|
||||
"health_socket": "/var/lib/docker/volumes/maven_sockets/_data/mavend.sock",
|
||||
"health_timeout_sec": 120
|
||||
}
|
||||
```
|
||||
|
||||
`snapshot_dir` must be outside `install_dir` (a restore must not read from what
|
||||
the install writes) and `health_socket` is required: an update that cannot check
|
||||
its own result cannot roll itself back, so the config is refused without one.
|
||||
|
||||
Then:
|
||||
|
||||
```sh
|
||||
mavupdate -config deploy/mavend.json verify # make build + make test, deploys nothing
|
||||
mavupdate -config deploy/mavend.json apply -yes # snapshot, verify, install, restart, health-check
|
||||
mavupdate -config deploy/mavend.json list # what you can roll back to
|
||||
mavupdate -config deploy/mavend.json rollback -yes # restore the previous artifacts and restart
|
||||
```
|
||||
|
||||
`apply` refuses to start if she is not already answering — otherwise a failed
|
||||
update and a box that was already broken are indistinguishable afterwards. On any
|
||||
failure after the install it restores the snapshot, restarts, and checks again;
|
||||
if that also fails it says so loudly and names the directory to copy back by hand.
|
||||
The database is never snapshotted or rolled back (see the package comment in
|
||||
`internal/update`); schema compatibility is `store.Migrate`'s job.
|
||||
|
||||
@@ -22,7 +22,9 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/delivery/ntfysink"
|
||||
"github.com/kami/maven/internal/delivery/telegramsink"
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/update"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
@@ -108,6 +110,16 @@ type Config struct {
|
||||
// calls its /v1/chat/completions endpoint to phrase nudges and reminders.
|
||||
Phraser *PhraserConfig `json:"phraser,omitempty"`
|
||||
|
||||
// Update — how THIS box deploys a new build of Maven (Vikunja #249). nil ⇒
|
||||
// the update capability does not exist, which is the state to leave it in
|
||||
// unless the operator has read internal/update's package comment.
|
||||
//
|
||||
// mavend never reads this block: the daemon does not import internal/update
|
||||
// and cannot update itself. It lives here because cmd/mavupdate — a CLI the
|
||||
// owner runs on the host, the only trigger there is — reads the same config
|
||||
// file to find the socket it health-checks.
|
||||
Update *update.Config `json:"update,omitempty"`
|
||||
|
||||
// Voice — the client↔core surface + the stt/tts modules the daemon
|
||||
// wires. nil ⇒ the daemon doesn't wire voice: the TCP listener stays
|
||||
// down, the dispatcher's Voice slot stays nil (the routing table's
|
||||
@@ -180,6 +192,119 @@ type Config struct {
|
||||
// discovers and executes capabilities through Hexis for ecosystem actions.
|
||||
// nil ⇒ no capability-aware routing.
|
||||
Hexis *HexisConfig `json:"hexis,omitempty"`
|
||||
|
||||
// MCP — Model Context Protocol servers Maven connects OUT to (Vikunja
|
||||
// #251). nil / absent / no enabled server ⇒ no connection is made and no
|
||||
// tool is discovered, like every other capability that reaches outside the
|
||||
// box. She is a client here, never a server: nothing exposes her own
|
||||
// capabilities to an outside caller. See MCPConfig.
|
||||
MCP *MCPConfig `json:"mcp,omitempty"`
|
||||
}
|
||||
|
||||
// MCPConfig — the MCP client block. Servers are dark until one has
|
||||
// `"enabled": true`, and a discovered tool is only ever PROPOSED: Kami enables
|
||||
// it on /tools, on the authed surface, exactly as he would a shell tool. The
|
||||
// voice path can never grant a capability to itself.
|
||||
type MCPConfig struct {
|
||||
// Servers — the configured servers. Each needs exactly one of command
|
||||
// (a subprocess on this box) or url (a streamable-HTTP endpoint).
|
||||
Servers []MCPServerConfig `json:"servers,omitempty"`
|
||||
|
||||
// Timeout — per-call budget for every server that does not set its own.
|
||||
// 0 ⇒ mcp.DefaultTimeout (15s). A tool slower than this is not usable in a
|
||||
// spoken turn.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// AllowHosts / DenyHosts — the host lists for the shared webfetch door that
|
||||
// url servers go through. Deny wins. Private addresses are refused
|
||||
// unconditionally unless the individual server sets allow_private.
|
||||
AllowHosts []string `json:"allow_hosts,omitempty"`
|
||||
DenyHosts []string `json:"deny_hosts,omitempty"`
|
||||
|
||||
// MaxBytes — cap on one JSON-RPC response. 0 ⇒ webfetch.DefaultMaxBytes.
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// MCPServerConfig — one MCP server.
|
||||
type MCPServerConfig struct {
|
||||
// Name — the local handle. It prefixes every tool this server contributes
|
||||
// ("vikunja" + "list_tasks" ⇒ the allowlist row "vikunja_list_tasks") and
|
||||
// becomes the store scope "mcp:<name>", so its provenance is readable on
|
||||
// /tools without opening the config.
|
||||
Name string `json:"name"`
|
||||
|
||||
// Command / Args / Env / Dir — a stdio server: a child process of mavend,
|
||||
// on this box, under this user. argv, never a shell string.
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
Dir string `json:"dir,omitempty"`
|
||||
|
||||
// URL — a streamable-HTTP endpoint. It is fetched through
|
||||
// internal/webfetch, so the SSRF guard, the redirect cap, the size cap and
|
||||
// the one-request-per-host-per-second limit all apply.
|
||||
URL string `json:"url,omitempty"`
|
||||
|
||||
// AllowPrivate — let THIS server be a loopback or LAN address. The Vikunja
|
||||
// server on homesrv is "http://localhost:9100/mcp", which is refused
|
||||
// without this flag. Understand what it means before setting it: a local
|
||||
// server is a DIFFERENT trust level from a public one. It is inside the
|
||||
// network, it usually needs no credential, and it can change things that
|
||||
// matter — so an argument the router got wrong lands somewhere real. Set it
|
||||
// only for a server you run yourself, and prefer allow_tools with it.
|
||||
AllowPrivate bool `json:"allow_private,omitempty"`
|
||||
|
||||
// AllowTools — when set, the ONLY remote tool names taken from this server.
|
||||
// This is the knob that keeps the catalogue deliberate: the resident model
|
||||
// is a 1.7B with a 4096-token context, and a tool name it half-remembers is
|
||||
// a wrong act, so fewer and better-chosen beats complete.
|
||||
AllowTools []string `json:"allow_tools,omitempty"`
|
||||
|
||||
// MaxTools — cap on this server's contribution. 0 ⇒ mcp.DefaultMaxTools (12).
|
||||
MaxTools int `json:"max_tools,omitempty"`
|
||||
|
||||
// Timeout — per-call budget for this server. 0 ⇒ MCPConfig.Timeout.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// Enabled — false (the default) keeps a configured server described but
|
||||
// dark, so a block can be written and reviewed before it is switched on.
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// MCPServers maps the config blocks onto the mcp package's own type. It lives
|
||||
// here so config validation and daemon wiring cannot drift on the mapping.
|
||||
// Returns nil when nothing is configured or nothing is enabled.
|
||||
func (c *Config) MCPServers() []mcp.ServerConfig {
|
||||
if c.MCP == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]mcp.ServerConfig, 0, len(c.MCP.Servers))
|
||||
for _, s := range c.MCP.Servers {
|
||||
if !s.Enabled {
|
||||
continue
|
||||
}
|
||||
timeout := time.Duration(s.Timeout)
|
||||
if timeout <= 0 {
|
||||
timeout = time.Duration(c.MCP.Timeout)
|
||||
}
|
||||
out = append(out, mcp.ServerConfig{
|
||||
Name: s.Name,
|
||||
Command: s.Command,
|
||||
Args: s.Args,
|
||||
Env: s.Env,
|
||||
Dir: s.Dir,
|
||||
URL: s.URL,
|
||||
AllowPrivate: s.AllowPrivate,
|
||||
AllowTools: s.AllowTools,
|
||||
MaxTools: s.MaxTools,
|
||||
Timeout: timeout,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PraxisConfig — maven's connection to the Praxis attention service.
|
||||
@@ -772,6 +897,12 @@ func (c *Config) applyDefaults() {
|
||||
c.Feeds = nil
|
||||
}
|
||||
|
||||
// Same rule for MCP: a block with no server, or none enabled, is the same
|
||||
// as no block at all. Normalising it to nil keeps "off" in one place.
|
||||
if c.MCP != nil && len(c.MCPServers()) == 0 {
|
||||
c.MCP = nil
|
||||
}
|
||||
|
||||
// Same rule for the crawler: a block that neither answers on demand nor
|
||||
// watches anything has nothing to do, so it is normalised to "off".
|
||||
if c.Crawl != nil && !c.Crawl.OnDemand && len(c.Crawl.Watches) == 0 {
|
||||
@@ -844,6 +975,14 @@ func (c *Config) validate() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The update block is validated here even though mavend never acts on it: a
|
||||
// half-written update config that is only noticed by cmd/mavupdate is noticed
|
||||
// at the worst possible moment, halfway through deploying a new build.
|
||||
if c.Update != nil {
|
||||
if err := c.Update.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.Voice != nil && c.Voice.Enabled {
|
||||
if c.Voice.Bind == "" {
|
||||
return errors.New("voice.enabled set but voice.bind is empty — refusing to start a voice surface with no bind address")
|
||||
@@ -868,6 +1007,12 @@ func (c *Config) validate() error {
|
||||
return fmt.Errorf("routine %q: bad cron %q: %w", r.Name, r.Cron, err)
|
||||
}
|
||||
}
|
||||
// An MCP block with a typo (no name, both command and url, a bare hostname
|
||||
// as the url) fails here, at startup, rather than at the first turn that
|
||||
// needed the tool.
|
||||
if err := mcp.Validate(c.MCPServers()); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(c.MorningRoutines) > 0 {
|
||||
if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil {
|
||||
return err
|
||||
|
||||
@@ -325,3 +325,44 @@ func TestSwapModelsParsedAndMustBeAbsolute(t *testing.T) {
|
||||
t.Error("Load accepted a relative swap_models entry; want a startup failure")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateBlockAbsentMeansOff — mavend never updates itself; the block only
|
||||
// exists so cmd/mavupdate can find the deployment it is asked to update
|
||||
// (Vikunja #249). Absent is the normal state.
|
||||
func TestUpdateBlockAbsentMeansOff(t *testing.T) {
|
||||
c, err := Load(writeConfig(t, `{}`))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if c.Update != nil {
|
||||
t.Errorf("update = %+v; want nil when unconfigured", c.Update)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateBlockValidatedAtStartup(t *testing.T) {
|
||||
good := `{"update": {
|
||||
"source_dir": "/srv/maven",
|
||||
"install_dir": "/srv/maven",
|
||||
"snapshot_dir": "/var/lib/maven/snapshots",
|
||||
"binaries": ["mavend", "mavweb"],
|
||||
"restart_cmd": ["docker", "compose", "up", "-d", "--build", "mavend"],
|
||||
"health_socket": "/run/maven/mavend.sock"
|
||||
}}`
|
||||
c, err := Load(writeConfig(t, good))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if c.Update == nil || len(c.Update.Binaries) != 2 {
|
||||
t.Fatalf("update block = %+v; want it parsed", c.Update)
|
||||
}
|
||||
// A block with no health check cannot detect its own failure, so it cannot
|
||||
// roll back — refused at load, not halfway through a deploy.
|
||||
noHealth := `{"update": {
|
||||
"source_dir": "/srv/maven", "install_dir": "/srv/maven",
|
||||
"snapshot_dir": "/var/lib/maven/snapshots",
|
||||
"binaries": ["mavend"], "restart_cmd": ["true"]
|
||||
}}`
|
||||
if _, err := Load(writeConfig(t, noHealth)); err == nil {
|
||||
t.Error("Load accepted an update block with no health_socket")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMCPAbsentIsOff(t *testing.T) {
|
||||
c, err := Load(writeConfig(t, `{}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.MCP != nil {
|
||||
t.Error("no mcp block ⇒ nil")
|
||||
}
|
||||
if got := c.MCPServers(); got != nil {
|
||||
t.Errorf("MCPServers() = %+v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A described-but-not-enabled server must not be wired. This is how a block can
|
||||
// sit in the config file, reviewed, before it is switched on.
|
||||
func TestMCPDisabledServerIsOff(t *testing.T) {
|
||||
c, err := Load(writeConfig(t, `{"mcp":{"servers":[
|
||||
{"name":"vikunja","url":"http://localhost:9100/mcp","allow_private":true}]}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.MCP != nil {
|
||||
t.Errorf("a block with nothing enabled must normalise to nil, got %+v", c.MCP)
|
||||
}
|
||||
if got := c.MCPServers(); len(got) != 0 {
|
||||
t.Errorf("MCPServers() = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPEnabledServerMapping(t *testing.T) {
|
||||
c, err := Load(writeConfig(t, `{"mcp":{
|
||||
"timeout":"5s",
|
||||
"servers":[
|
||||
{"name":"vikunja","url":"http://localhost:9100/mcp","allow_private":true,
|
||||
"allow_tools":["list_tasks"],"max_tools":3,"enabled":true},
|
||||
{"name":"files","command":"mcp-server-fs","args":["/srv"],"timeout":"1s","enabled":true},
|
||||
{"name":"off","command":"nope"}
|
||||
]}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := c.MCPServers()
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("servers = %+v", got)
|
||||
}
|
||||
if got[0].Name != "vikunja" || !got[0].AllowPrivate || got[0].MaxTools != 3 ||
|
||||
len(got[0].AllowTools) != 1 || got[0].Timeout != 5*time.Second {
|
||||
t.Errorf("vikunja mapped wrong: %+v", got[0])
|
||||
}
|
||||
if got[1].Command != "mcp-server-fs" || len(got[1].Args) != 1 || got[1].Timeout != time.Second {
|
||||
t.Errorf("files mapped wrong: %+v", got[1])
|
||||
}
|
||||
// allow_private is per server and must not leak to the other one.
|
||||
if got[1].AllowPrivate {
|
||||
t.Error("allow_private leaked between servers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPBadServerFailsAtStartup(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"no name": `{"mcp":{"servers":[{"command":"x","enabled":true}]}}`,
|
||||
"both": `{"mcp":{"servers":[{"name":"a","command":"x","url":"http://a.test","enabled":true}]}}`,
|
||||
"neither": `{"mcp":{"servers":[{"name":"a","enabled":true}]}}`,
|
||||
"bad scheme": `{"mcp":{"servers":[{"name":"a","url":"unix:///run/x.sock","enabled":true}]}}`,
|
||||
"duplicate": `{"mcp":{"servers":[{"name":"a","command":"x","enabled":true},{"name":"a","command":"y","enabled":true}]}}`,
|
||||
"spacey name": `{"mcp":{"servers":[{"name":"a b","command":"x","enabled":true}]}}`,
|
||||
}
|
||||
for name, body := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := Load(writeConfig(t, body)); err == nil {
|
||||
t.Fatal("want a startup error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CmdPrefix is the reserved first argv element that marks an allowlist row as
|
||||
// an MCP call rather than a process. An MCP tool row looks like
|
||||
//
|
||||
// name: "vikunja_list_tasks" cmd: ["mcp", "vikunja", "list_tasks"]
|
||||
//
|
||||
// which is why there is no new column and no migration: the store, the /tools
|
||||
// page, ProposeTool, EnableTool, DisableTool, the act matcher and the confirm
|
||||
// turn all keep working unchanged. The executor is the only place that has to
|
||||
// know the difference, and it is one branch on Cmd[0].
|
||||
//
|
||||
// The rest of the allowlist discipline is inherited whole: a row that is not
|
||||
// status='enabled' does not run, and a row marked destructive does not run on
|
||||
// first hearing. Nothing here can enable itself — discovery only proposes.
|
||||
const CmdPrefix = "mcp"
|
||||
|
||||
// Cmd builds the argv encoding for a discovered tool.
|
||||
func Cmd(server, tool string) []string { return []string{CmdPrefix, server, tool} }
|
||||
|
||||
// ParseCmd recognises an MCP allowlist row. ok=false for an ordinary process
|
||||
// tool, which is what almost every row is.
|
||||
func ParseCmd(cmd []string) (server, tool string, ok bool) {
|
||||
if len(cmd) != 3 || cmd[0] != CmdPrefix {
|
||||
return "", "", false
|
||||
}
|
||||
if cmd[1] == "" || cmd[2] == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return cmd[1], cmd[2], true
|
||||
}
|
||||
|
||||
var notName = regexp.MustCompile(`[^a-z0-9_]+`)
|
||||
|
||||
// LocalName is the allowlist name for a discovered tool: the server handle, an
|
||||
// underscore, the remote name, lowercased and stripped of anything that is not
|
||||
// a word character. Namespacing by server is what keeps two servers that both
|
||||
// offer "search" from colliding, and what makes the provenance of a row on the
|
||||
// /tools page obvious without opening the diff.
|
||||
func LocalName(server, tool string) string {
|
||||
clean := func(s string) string {
|
||||
return strings.Trim(notName.ReplaceAllString(strings.ToLower(strings.TrimSpace(s)), "_"), "_")
|
||||
}
|
||||
s, t := clean(server), clean(tool)
|
||||
switch {
|
||||
case s == "":
|
||||
return t
|
||||
case t == "":
|
||||
return s
|
||||
}
|
||||
return s + "_" + t
|
||||
}
|
||||
|
||||
// Scope is the store scope for a server's rows, so the /tools page can group
|
||||
// them and a human can tell at a glance where a capability came from.
|
||||
func Scope(server string) string { return "mcp:" + server }
|
||||
@@ -0,0 +1,267 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Errors callers distinguish.
|
||||
var (
|
||||
// ErrClosed — the transport is gone (subprocess died, client closed).
|
||||
ErrClosed = errors.New("mcp: connection is closed")
|
||||
// ErrNotInitialized — a call was made before the initialize handshake.
|
||||
ErrNotInitialized = errors.New("mcp: not initialized")
|
||||
// ErrToolFailed — the server ran the tool and reported an error result.
|
||||
ErrToolFailed = errors.New("mcp: tool reported an error")
|
||||
)
|
||||
|
||||
// Tool is one tool a server offers, in the form Maven cares about.
|
||||
//
|
||||
// ReadOnly comes from the server's own readOnlyHint annotation and decides
|
||||
// whether the allowlist row is marked destructive: no hint, or a false one,
|
||||
// means "assume it mutates", which routes the call through the confirm turn.
|
||||
// Guessing wrong in that direction only costs a question.
|
||||
type Tool struct {
|
||||
Server string
|
||||
Name string
|
||||
Description string
|
||||
InputSchema json.RawMessage
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
// Resource is one resource a server offers. Contents are fetched separately —
|
||||
// listing is cheap, reading is not.
|
||||
type Resource struct {
|
||||
Server string
|
||||
URI string
|
||||
Name string
|
||||
MIMEType string
|
||||
}
|
||||
|
||||
// ServerInfo is what came back from the handshake.
|
||||
type ServerInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
ProtocolVersion string `json:"-"`
|
||||
}
|
||||
|
||||
// Client is one connected MCP server. Safe for concurrent use.
|
||||
type Client struct {
|
||||
name string
|
||||
tr transport
|
||||
next atomic.Int64
|
||||
|
||||
mu sync.Mutex
|
||||
info ServerInfo
|
||||
ready bool
|
||||
}
|
||||
|
||||
// newClient wraps a transport. Callers use Dial* in manager.go.
|
||||
func newClient(name string, tr transport) *Client {
|
||||
return &Client{name: name, tr: tr}
|
||||
}
|
||||
|
||||
// Name — the local name of this server (the config key, not the server's own).
|
||||
func (c *Client) Name() string { return c.name }
|
||||
|
||||
// Info — what the server said about itself during the handshake.
|
||||
func (c *Client) Info() ServerInfo {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.info
|
||||
}
|
||||
|
||||
// Initialize performs the MCP handshake and sends notifications/initialized.
|
||||
// Capabilities we declare are empty on purpose: Maven consumes, she does not
|
||||
// offer sampling or roots back to the server.
|
||||
func (c *Client) Initialize(ctx context.Context) error {
|
||||
var out struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
ServerInfo ServerInfo `json:"serverInfo"`
|
||||
}
|
||||
err := c.call(ctx, "initialize", map[string]any{
|
||||
"protocolVersion": ProtocolVersion,
|
||||
"capabilities": map[string]any{},
|
||||
"clientInfo": map[string]any{"name": "maven", "version": "1.0"},
|
||||
}, &out)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(out.ProtocolVersion) == "" {
|
||||
return fmt.Errorf("mcp: %s: handshake returned no protocol version", c.name)
|
||||
}
|
||||
out.ServerInfo.ProtocolVersion = out.ProtocolVersion
|
||||
c.mu.Lock()
|
||||
c.info, c.ready = out.ServerInfo, true
|
||||
c.mu.Unlock()
|
||||
// Best effort: a stateless HTTP server may not care, and a failure here is
|
||||
// not worth dropping a working connection over.
|
||||
_ = c.tr.Notify(ctx, "notifications/initialized", map[string]any{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTools discovers the server's tools.
|
||||
func (c *Client) ListTools(ctx context.Context) ([]Tool, error) {
|
||||
if !c.initialized() {
|
||||
return nil, ErrNotInitialized
|
||||
}
|
||||
var out struct {
|
||||
Tools []struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
InputSchema json.RawMessage `json:"inputSchema"`
|
||||
Annotations *struct {
|
||||
ReadOnlyHint bool `json:"readOnlyHint"`
|
||||
} `json:"annotations"`
|
||||
} `json:"tools"`
|
||||
}
|
||||
if err := c.call(ctx, "tools/list", map[string]any{}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tools := make([]Tool, 0, len(out.Tools))
|
||||
for _, t := range out.Tools {
|
||||
if strings.TrimSpace(t.Name) == "" {
|
||||
continue
|
||||
}
|
||||
tools = append(tools, Tool{
|
||||
Server: c.name,
|
||||
Name: t.Name,
|
||||
Description: strings.TrimSpace(t.Description),
|
||||
InputSchema: t.InputSchema,
|
||||
ReadOnly: t.Annotations != nil && t.Annotations.ReadOnlyHint,
|
||||
})
|
||||
}
|
||||
return tools, nil
|
||||
}
|
||||
|
||||
// CallTool runs one tool and returns its text content, joined by newlines.
|
||||
// Non-text content (images, blobs) is dropped: everything downstream of here
|
||||
// is a spoken or written sentence.
|
||||
//
|
||||
// args is exactly what the router produced. Nothing else — no history, no
|
||||
// notes, no persona — is in scope here, by construction.
|
||||
func (c *Client) CallTool(ctx context.Context, name string, args map[string]any) (string, error) {
|
||||
if !c.initialized() {
|
||||
return "", ErrNotInitialized
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
var out struct {
|
||||
IsError bool `json:"isError"`
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
} `json:"content"`
|
||||
}
|
||||
if err := c.call(ctx, "tools/call", map[string]any{"name": name, "arguments": args}, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var parts []string
|
||||
for _, ct := range out.Content {
|
||||
if ct.Type == "text" && strings.TrimSpace(ct.Text) != "" {
|
||||
parts = append(parts, strings.TrimSpace(ct.Text))
|
||||
}
|
||||
}
|
||||
text := strings.Join(parts, "\n")
|
||||
if out.IsError {
|
||||
return text, fmt.Errorf("%w: %s/%s: %s", ErrToolFailed, c.name, name, text)
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
// ListResources discovers the server's resources. A server without the
|
||||
// resources capability answers with an error; that is not fatal, the caller
|
||||
// gets an empty list.
|
||||
func (c *Client) ListResources(ctx context.Context) ([]Resource, error) {
|
||||
if !c.initialized() {
|
||||
return nil, ErrNotInitialized
|
||||
}
|
||||
var out struct {
|
||||
Resources []struct {
|
||||
URI string `json:"uri"`
|
||||
Name string `json:"name"`
|
||||
MIMEType string `json:"mimeType"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := c.call(ctx, "resources/list", map[string]any{}, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := make([]Resource, 0, len(out.Resources))
|
||||
for _, r := range out.Resources {
|
||||
if strings.TrimSpace(r.URI) == "" {
|
||||
continue
|
||||
}
|
||||
res = append(res, Resource{Server: c.name, URI: r.URI, Name: r.Name, MIMEType: r.MIMEType})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ReadResource returns a resource's text contents, joined by newlines. This is
|
||||
// the RAG-hint path: the text can be pasted into a router or phraser prompt.
|
||||
func (c *Client) ReadResource(ctx context.Context, uri string) (string, error) {
|
||||
if !c.initialized() {
|
||||
return "", ErrNotInitialized
|
||||
}
|
||||
var out struct {
|
||||
Contents []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"contents"`
|
||||
}
|
||||
if err := c.call(ctx, "resources/read", map[string]any{"uri": uri}, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var parts []string
|
||||
for _, ct := range out.Contents {
|
||||
if strings.TrimSpace(ct.Text) != "" {
|
||||
parts = append(parts, strings.TrimSpace(ct.Text))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n"), nil
|
||||
}
|
||||
|
||||
// Close drops the connection.
|
||||
func (c *Client) Close() error {
|
||||
c.mu.Lock()
|
||||
c.ready = false
|
||||
c.mu.Unlock()
|
||||
return c.tr.Close()
|
||||
}
|
||||
|
||||
func (c *Client) initialized() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.ready
|
||||
}
|
||||
|
||||
// alive reports whether the underlying transport can still carry a call. HTTP
|
||||
// is stateless, so it is always alive; a dead subprocess is not.
|
||||
func (c *Client) alive() bool {
|
||||
if s, ok := c.tr.(*stdioTransport); ok {
|
||||
return s.alive()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Client) call(ctx context.Context, method string, params any, out any) error {
|
||||
req := &rpcRequest{JSONRPC: "2.0", ID: c.next.Add(1), Method: method, Params: params}
|
||||
resp, err := c.tr.Call(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mcp: %s: %s: %w", c.name, method, err)
|
||||
}
|
||||
if resp.Error != nil {
|
||||
return fmt.Errorf("mcp: %s: %s: %w", c.name, method, resp.Error)
|
||||
}
|
||||
if out == nil || len(resp.Result) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(resp.Result, out); err != nil {
|
||||
return fmt.Errorf("mcp: %s: %s: decode result: %w", c.name, method, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Poster is the HTTP seam: internal/webfetch.Fetcher satisfies it. The
|
||||
// transport takes it as an interface so a test can serve a fake without a
|
||||
// listener, and so that the ONLY implementation wired in production is the
|
||||
// guarded fetcher — an MCP endpoint cannot get a bare http.Client this way.
|
||||
type Poster interface {
|
||||
Post(ctx context.Context, rawURL, contentType string, body []byte, hdr map[string]string) (*PostResponse, error)
|
||||
}
|
||||
|
||||
// PostResponse is the shape webfetch returns, restated here so this package
|
||||
// does not depend on it structurally.
|
||||
type PostResponse struct {
|
||||
Status int
|
||||
ContentType string
|
||||
Body []byte
|
||||
Header map[string]string
|
||||
}
|
||||
|
||||
// httpTransport speaks streamable HTTP: every request is a POST to one
|
||||
// endpoint, and the reply is either a JSON object or a text/event-stream frame
|
||||
// carrying one. Both are accepted — servers pick per response, and the two the
|
||||
// LAN runs disagree about which.
|
||||
type httpTransport struct {
|
||||
poster Poster
|
||||
url string
|
||||
|
||||
mu sync.Mutex
|
||||
session string // Mcp-Session-Id, echoed back when the server issues one
|
||||
}
|
||||
|
||||
func newHTTPTransport(post Poster, endpoint string) *httpTransport {
|
||||
return &httpTransport{poster: post, url: endpoint}
|
||||
}
|
||||
|
||||
func (t *httpTransport) Call(ctx context.Context, req *rpcRequest) (*rpcResponse, error) {
|
||||
body, err := t.send(ctx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
frame, err := decodeFrame(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp rpcResponse
|
||||
if err := json.Unmarshal(frame, &resp); err != nil {
|
||||
return nil, fmt.Errorf("mcp: decode response: %w", err)
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (t *httpTransport) Notify(ctx context.Context, method string, params any) error {
|
||||
_, err := t.send(ctx, &rpcRequest{JSONRPC: "2.0", Method: method, Params: params})
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *httpTransport) send(ctx context.Context, req *rpcRequest) ([]byte, error) {
|
||||
req.JSONRPC = "2.0"
|
||||
raw, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hdr := map[string]string{"Accept": "application/json, text/event-stream"}
|
||||
t.mu.Lock()
|
||||
if t.session != "" {
|
||||
hdr["Mcp-Session-Id"] = t.session
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
resp, err := t.poster.Post(ctx, t.url, "application/json", raw, hdr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sid := headerGet(resp.Header, "Mcp-Session-Id"); sid != "" {
|
||||
t.mu.Lock()
|
||||
t.session = sid
|
||||
t.mu.Unlock()
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func (t *httpTransport) Close() error {
|
||||
t.mu.Lock()
|
||||
t.session = ""
|
||||
t.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func headerGet(h map[string]string, key string) string {
|
||||
if h == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := h[key]; ok {
|
||||
return v
|
||||
}
|
||||
lower := strings.ToLower(key)
|
||||
for k, v := range h {
|
||||
if strings.ToLower(k) == lower {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// decodeFrame pulls the JSON object out of a body that is either raw JSON or
|
||||
// SSE. For SSE we take the LAST data: payload that parses, which is the
|
||||
// response — earlier frames on the stream are progress notifications.
|
||||
func decodeFrame(body []byte) ([]byte, error) {
|
||||
trimmed := bytes.TrimSpace(body)
|
||||
if len(trimmed) == 0 {
|
||||
return nil, errors.New("mcp: empty response body")
|
||||
}
|
||||
if trimmed[0] == '{' || trimmed[0] == '[' {
|
||||
return trimmed, nil
|
||||
}
|
||||
var last []byte
|
||||
sc := bufio.NewScanner(bytes.NewReader(trimmed))
|
||||
sc.Buffer(make([]byte, 0, 64<<10), maxLine)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if !strings.HasPrefix(line, "data:") {
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
|
||||
if payload == "" {
|
||||
continue
|
||||
}
|
||||
var probe map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(payload), &probe) != nil {
|
||||
continue
|
||||
}
|
||||
if _, isResp := probe["id"]; isResp {
|
||||
last = []byte(payload)
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, fmt.Errorf("mcp: read event stream: %w", err)
|
||||
}
|
||||
if last == nil {
|
||||
return nil, errors.New("mcp: no JSON-RPC response in event stream")
|
||||
}
|
||||
return last, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Package mcp is Maven's Model Context Protocol CLIENT. She is a host: she
|
||||
// connects OUT to MCP servers, discovers the tools and resources they offer,
|
||||
// and hands them to the parts of her that already exist for this — the tool
|
||||
// allowlist in the store, the confirm turn for anything that mutates, the
|
||||
// stage-3 gate that makes an uncertain act ask instead of run.
|
||||
//
|
||||
// She is not an MCP server. Nothing here exposes her own capabilities to an
|
||||
// outside caller; docs/plans/06-mcp-support.md asks for the host direction only.
|
||||
//
|
||||
// Boundaries, in code rather than in prose:
|
||||
//
|
||||
// - OFF unless configured. No mcp_servers block ⇒ no manager, no goroutine,
|
||||
// no socket.
|
||||
// - A remote server is reached through internal/webfetch, so the SSRF guard,
|
||||
// the size cap, the redirect cap and the per-host rate limit all apply to
|
||||
// an MCP endpoint exactly as they do to a news feed. Reaching a loopback
|
||||
// or LAN server means explicitly setting allow_private on THAT server —
|
||||
// a different trust level, spelled out per server rather than globally.
|
||||
// - Only the tool name and the arguments the router produced are sent. This
|
||||
// package never sees his notes, facts, history or the persona block, and
|
||||
// has no API through which a caller could pass them.
|
||||
// - Discovery proposes, it does not enable. A discovered tool lands as a
|
||||
// 'proposed' row; a human enables it on the authed surface.
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ProtocolVersion — the spec revision we ask for in the initialize handshake.
|
||||
// A server that answers with a different one is accepted (the spec says the
|
||||
// client may proceed if it can support what came back); we only refuse when it
|
||||
// answers with no version at all, which means it is not an MCP server.
|
||||
const ProtocolVersion = "2025-06-18"
|
||||
|
||||
// rpcRequest / rpcResponse — JSON-RPC 2.0. Deliberately hand-rolled: the wire
|
||||
// format is four fields, and the repo vendors its dependencies, so pulling a
|
||||
// library in for this would cost more than it saves.
|
||||
type rpcRequest struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID int64 `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
type rpcResponse struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID *int64 `json:"id"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *rpcError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type rpcError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *rpcError) Error() string { return fmt.Sprintf("mcp: rpc error %d: %s", e.Code, e.Message) }
|
||||
|
||||
// transport carries one JSON-RPC conversation. Implementations: stdioTransport
|
||||
// (a subprocess on this box) and httpTransport (streamable HTTP, guarded by
|
||||
// webfetch). Both must be safe for concurrent use by the Client.
|
||||
type transport interface {
|
||||
// Call sends a request and returns the matching response.
|
||||
Call(ctx context.Context, req *rpcRequest) (*rpcResponse, error)
|
||||
// Notify sends a notification (no id, no reply expected).
|
||||
Notify(ctx context.Context, method string, params any) error
|
||||
// Close releases the transport (kills the subprocess, drops the session).
|
||||
Close() error
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Defaults for a server block. Small numbers on purpose — see MaxTools.
|
||||
const (
|
||||
// DefaultTimeout bounds one JSON-RPC call. A tool that takes longer than
|
||||
// this is not usable in a spoken turn anyway.
|
||||
DefaultTimeout = 15 * time.Second
|
||||
// DefaultMaxTools caps how many tools ONE server may contribute. The
|
||||
// resident model is a 1.7B with a 4096-token context: a catalogue of forty
|
||||
// tool names does not fit in its head, and a name it half-remembers is a
|
||||
// wrong act. Twelve per server is already generous.
|
||||
DefaultMaxTools = 12
|
||||
// DefaultReconnectEvery is how long the manager waits before re-dialing a
|
||||
// server whose connection died.
|
||||
DefaultReconnectEvery = 30 * time.Second
|
||||
)
|
||||
|
||||
// ErrNoServer — the named server is not configured or not connected.
|
||||
var ErrNoServer = errors.New("mcp: no such server")
|
||||
|
||||
// ServerConfig is one configured MCP server. Off unless present.
|
||||
//
|
||||
// Exactly one of Command (a subprocess on this box) or URL (a remote or
|
||||
// loopback HTTP endpoint) must be set.
|
||||
type ServerConfig struct {
|
||||
// Name is the local handle. It prefixes every tool this server
|
||||
// contributes, so it must be short and a valid identifier-ish word.
|
||||
Name string `json:"name"`
|
||||
// Command + Args + Env + Dir describe a stdio server: a child process of
|
||||
// mavend, on this box, under this user. argv, never a shell string.
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env []string `json:"env,omitempty"`
|
||||
Dir string `json:"dir,omitempty"`
|
||||
// URL is a streamable-HTTP endpoint. It goes through internal/webfetch, so
|
||||
// it inherits the SSRF guard, the size cap and the per-host rate limit.
|
||||
URL string `json:"url,omitempty"`
|
||||
// AllowPrivate lets THIS server be a loopback or LAN address
|
||||
// (http://localhost:9100/mcp is the Vikunja server on homesrv). It is a
|
||||
// per-server hole in the private-address guard and it is not the same trust
|
||||
// level as a public endpoint: whatever is behind it is inside the network,
|
||||
// so an argument the router got wrong reaches something that matters. Set
|
||||
// it only for a server you run.
|
||||
AllowPrivate bool `json:"allow_private,omitempty"`
|
||||
// AllowTools, when non-empty, is the ONLY set of remote tool names taken
|
||||
// from this server. This is the knob for keeping the catalogue small and
|
||||
// deliberate rather than "whatever the server grew this week".
|
||||
AllowTools []string `json:"allow_tools,omitempty"`
|
||||
// MaxTools caps the contribution (0 ⇒ DefaultMaxTools).
|
||||
MaxTools int `json:"max_tools,omitempty"`
|
||||
// Timeout bounds one call (0 ⇒ DefaultTimeout).
|
||||
Timeout time.Duration `json:"-"`
|
||||
// Enabled=false keeps a configured server described but dark.
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// PosterFactory builds the HTTP door for one server. It is a factory rather
|
||||
// than a single shared Poster because allow_private is per server: the fetcher
|
||||
// that may reach http://localhost:9100/mcp must NOT be the same fetcher another
|
||||
// server's public URL goes through, or one loopback exemption would quietly
|
||||
// unlock the LAN for all of them.
|
||||
type PosterFactory func(cfg ServerConfig) (Poster, error)
|
||||
|
||||
// Manager owns the connections. Nothing here starts unless at least one server
|
||||
// is configured and enabled.
|
||||
type Manager struct {
|
||||
newPoster PosterFactory
|
||||
mu sync.Mutex
|
||||
conns map[string]*conn
|
||||
order []string
|
||||
}
|
||||
|
||||
type conn struct {
|
||||
cfg ServerConfig
|
||||
client *Client
|
||||
tools []Tool
|
||||
lastErr error
|
||||
lastTry time.Time
|
||||
dialedAt time.Time
|
||||
}
|
||||
|
||||
// NewManager builds a manager for the enabled servers in cfgs. newPoster is
|
||||
// the guarded HTTP door factory for url servers; pass nil only when no url
|
||||
// server is configured (a nil factory with a url server is reported per server
|
||||
// at dial time rather than fatally, so one bad block never stops the daemon).
|
||||
//
|
||||
// Dialing is lazy: NewManager validates and records, Connect dials.
|
||||
func NewManager(newPoster PosterFactory, cfgs []ServerConfig) (*Manager, error) {
|
||||
m := &Manager{newPoster: newPoster, conns: map[string]*conn{}}
|
||||
for _, c := range cfgs {
|
||||
if !c.Enabled {
|
||||
continue
|
||||
}
|
||||
if err := validate(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, dup := m.conns[c.Name]; dup {
|
||||
return nil, fmt.Errorf("mcp: duplicate server name %q", c.Name)
|
||||
}
|
||||
if c.Timeout <= 0 {
|
||||
c.Timeout = DefaultTimeout
|
||||
}
|
||||
if c.MaxTools <= 0 {
|
||||
c.MaxTools = DefaultMaxTools
|
||||
}
|
||||
m.conns[c.Name] = &conn{cfg: c}
|
||||
m.order = append(m.order, c.Name)
|
||||
}
|
||||
sort.Strings(m.order)
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Validate checks a set of server blocks without dialling anything, so a typo
|
||||
// fails at startup rather than at the first turn that needed the tool.
|
||||
func Validate(cfgs []ServerConfig) error {
|
||||
seen := map[string]bool{}
|
||||
for _, c := range cfgs {
|
||||
if err := validate(c); err != nil {
|
||||
return err
|
||||
}
|
||||
if seen[c.Name] {
|
||||
return fmt.Errorf("mcp: duplicate server name %q", c.Name)
|
||||
}
|
||||
seen[c.Name] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validate(c ServerConfig) error {
|
||||
if strings.TrimSpace(c.Name) == "" {
|
||||
return errors.New("mcp: server needs a name")
|
||||
}
|
||||
if strings.ContainsAny(c.Name, " \t/:") {
|
||||
return fmt.Errorf("mcp: server name %q must be one word without spaces, slashes or colons", c.Name)
|
||||
}
|
||||
hasCmd, hasURL := c.Command != "", c.URL != ""
|
||||
if hasCmd == hasURL {
|
||||
return fmt.Errorf("mcp: server %q needs exactly one of command or url", c.Name)
|
||||
}
|
||||
if hasURL && !strings.HasPrefix(c.URL, "http://") && !strings.HasPrefix(c.URL, "https://") {
|
||||
return fmt.Errorf("mcp: server %q url must be http or https", c.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Servers — the configured, enabled server names, sorted.
|
||||
func (m *Manager) Servers() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return append([]string(nil), m.order...)
|
||||
}
|
||||
|
||||
// Empty reports whether nothing is configured. The daemon uses it to skip
|
||||
// wiring entirely.
|
||||
func (m *Manager) Empty() bool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return len(m.conns) == 0
|
||||
}
|
||||
|
||||
// Connect dials every configured server, handshakes, and discovers tools.
|
||||
// A server that fails is recorded and retried later by Refresh — one bad
|
||||
// server never blocks the others, and never blocks boot.
|
||||
func (m *Manager) Connect(ctx context.Context) {
|
||||
for _, name := range m.Servers() {
|
||||
if err := m.dial(ctx, name); err != nil {
|
||||
log.Printf("mcp: %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) dial(ctx context.Context, name string) error {
|
||||
m.mu.Lock()
|
||||
c, ok := m.conns[name]
|
||||
if !ok {
|
||||
m.mu.Unlock()
|
||||
return ErrNoServer
|
||||
}
|
||||
cfg := c.cfg
|
||||
c.lastTry = time.Now()
|
||||
m.mu.Unlock()
|
||||
|
||||
var tr transport
|
||||
var err error
|
||||
if cfg.Command != "" {
|
||||
tr, err = newStdioTransport(ctx, append([]string{cfg.Command}, cfg.Args...), cfg.Env, cfg.Dir)
|
||||
} else if m.newPoster == nil {
|
||||
err = fmt.Errorf("server %q has a url but no http door was wired", name)
|
||||
} else {
|
||||
var poster Poster
|
||||
if poster, err = m.newPoster(cfg); err == nil {
|
||||
tr = newHTTPTransport(poster, cfg.URL)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
m.fail(name, err)
|
||||
return err
|
||||
}
|
||||
|
||||
cl := newClient(name, tr)
|
||||
ictx, cancel := context.WithTimeout(ctx, cfg.Timeout)
|
||||
defer cancel()
|
||||
if err := cl.Initialize(ictx); err != nil {
|
||||
_ = cl.Close()
|
||||
m.fail(name, err)
|
||||
return err
|
||||
}
|
||||
tools, err := cl.ListTools(ictx)
|
||||
if err != nil {
|
||||
// A server with no tools capability is still a usable resource server.
|
||||
log.Printf("mcp: %s: list tools: %v", name, err)
|
||||
tools = nil
|
||||
}
|
||||
tools = filterTools(cfg, tools)
|
||||
|
||||
m.mu.Lock()
|
||||
if old := m.conns[name].client; old != nil {
|
||||
_ = old.Close()
|
||||
}
|
||||
m.conns[name].client = cl
|
||||
m.conns[name].tools = tools
|
||||
m.conns[name].lastErr = nil
|
||||
m.conns[name].dialedAt = time.Now()
|
||||
m.mu.Unlock()
|
||||
log.Printf("mcp: %s connected (%s %s), %d tool(s)", name, cl.Info().Name, cl.Info().Version, len(tools))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) fail(name string, err error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if c := m.conns[name]; c != nil {
|
||||
c.lastErr = err
|
||||
c.client = nil
|
||||
c.tools = nil
|
||||
}
|
||||
}
|
||||
|
||||
// filterTools applies AllowTools and MaxTools, and drops nameless entries.
|
||||
// Sorted first, so the cap is deterministic rather than "whatever order the
|
||||
// server felt like".
|
||||
func filterTools(cfg ServerConfig, in []Tool) []Tool {
|
||||
sort.Slice(in, func(i, j int) bool { return in[i].Name < in[j].Name })
|
||||
out := make([]Tool, 0, len(in))
|
||||
for _, t := range in {
|
||||
if len(cfg.AllowTools) > 0 && !contains(cfg.AllowTools, t.Name) {
|
||||
continue
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
if cfg.MaxTools > 0 && len(out) > cfg.MaxTools {
|
||||
log.Printf("mcp: %s offers %d tools, taking the first %d (raise max_tools or set allow_tools)",
|
||||
cfg.Name, len(out), cfg.MaxTools)
|
||||
out = out[:cfg.MaxTools]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(hay []string, needle string) bool {
|
||||
for _, h := range hay {
|
||||
if h == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Refresh re-dials any server that is down, if enough time has passed since the
|
||||
// last attempt. Call it from the daemon's periodic tick — it is cheap when
|
||||
// everything is up.
|
||||
func (m *Manager) Refresh(ctx context.Context) {
|
||||
now := time.Now()
|
||||
var stale []string
|
||||
m.mu.Lock()
|
||||
for _, name := range m.order {
|
||||
c := m.conns[name]
|
||||
down := c.client == nil || !c.client.alive()
|
||||
if down && now.Sub(c.lastTry) >= DefaultReconnectEvery {
|
||||
stale = append(stale, name)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
for _, name := range stale {
|
||||
if err := m.dial(ctx, name); err != nil {
|
||||
log.Printf("mcp: %s: reconnect: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tools — every discovered tool across connected servers, sorted by
|
||||
// server then name.
|
||||
func (m *Manager) Tools() []Tool {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []Tool
|
||||
for _, name := range m.order {
|
||||
out = append(out, m.conns[name].tools...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Status is one server's health, for the web surface.
|
||||
type Status struct {
|
||||
Name string
|
||||
Transport string // "stdio" or "http"
|
||||
Target string // command or url
|
||||
Connected bool
|
||||
Server string // the server's own name+version
|
||||
Tools int
|
||||
Err string
|
||||
}
|
||||
|
||||
// Status reports every configured server.
|
||||
func (m *Manager) Status() []Status {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]Status, 0, len(m.order))
|
||||
for _, name := range m.order {
|
||||
c := m.conns[name]
|
||||
s := Status{Name: name, Tools: len(c.tools)}
|
||||
if c.cfg.Command != "" {
|
||||
s.Transport, s.Target = "stdio", strings.Join(append([]string{c.cfg.Command}, c.cfg.Args...), " ")
|
||||
} else {
|
||||
s.Transport, s.Target = "http", c.cfg.URL
|
||||
}
|
||||
if c.client != nil {
|
||||
s.Connected = true
|
||||
s.Server = strings.TrimSpace(c.client.Info().Name + " " + c.client.Info().Version)
|
||||
}
|
||||
if c.lastErr != nil {
|
||||
s.Err = c.lastErr.Error()
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Call runs server's tool with args. Args come from the router and nothing
|
||||
// else; there is no path here through which a note or a fact could travel.
|
||||
func (m *Manager) Call(ctx context.Context, server, tool string, args map[string]any) (string, error) {
|
||||
m.mu.Lock()
|
||||
c := m.conns[server]
|
||||
m.mu.Unlock()
|
||||
if c == nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrNoServer, server)
|
||||
}
|
||||
m.mu.Lock()
|
||||
cl, timeout, known := c.client, c.cfg.Timeout, false
|
||||
for _, t := range c.tools {
|
||||
if t.Name == tool {
|
||||
known = true
|
||||
break
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if cl == nil {
|
||||
return "", fmt.Errorf("mcp: %s is not connected", server)
|
||||
}
|
||||
// The discovered-and-filtered set is the second allowlist: even an enabled
|
||||
// store row cannot reach a tool the server stopped offering, or one
|
||||
// allow_tools excludes.
|
||||
if !known {
|
||||
return "", fmt.Errorf("mcp: %s offers no tool %q", server, tool)
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
return cl.CallTool(cctx, tool, args)
|
||||
}
|
||||
|
||||
// Resources lists resources across connected servers.
|
||||
func (m *Manager) Resources(ctx context.Context) []Resource {
|
||||
m.mu.Lock()
|
||||
clients := make([]*Client, 0, len(m.order))
|
||||
for _, name := range m.order {
|
||||
if cl := m.conns[name].client; cl != nil {
|
||||
clients = append(clients, cl)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
var out []Resource
|
||||
for _, cl := range clients {
|
||||
rs, err := cl.ListResources(ctx)
|
||||
if err != nil {
|
||||
continue // no resources capability; not an error worth logging per tick
|
||||
}
|
||||
out = append(out, rs...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ReadResource reads one resource from one server.
|
||||
func (m *Manager) ReadResource(ctx context.Context, server, uri string) (string, error) {
|
||||
m.mu.Lock()
|
||||
c := m.conns[server]
|
||||
var cl *Client
|
||||
var timeout time.Duration
|
||||
if c != nil {
|
||||
cl, timeout = c.client, c.cfg.Timeout
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if cl == nil {
|
||||
return "", fmt.Errorf("%w: %s", ErrNoServer, server)
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
return cl.ReadResource(cctx, uri)
|
||||
}
|
||||
|
||||
// Close shuts every connection down.
|
||||
func (m *Manager) Close() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, name := range m.order {
|
||||
if cl := m.conns[name].client; cl != nil {
|
||||
_ = cl.Close()
|
||||
m.conns[name].client = nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakePoster answers POSTs from a canned handler, in either JSON or SSE form.
|
||||
type fakePoster struct {
|
||||
mu sync.Mutex
|
||||
handler func(method string, params json.RawMessage) (any, *rpcError)
|
||||
sse bool
|
||||
session string
|
||||
seen []map[string]string // headers of each request, for the session test
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakePoster) Post(_ context.Context, _, _ string, body []byte, hdr map[string]string) (*PostResponse, error) {
|
||||
var req struct {
|
||||
ID *int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.seen = append(f.seen, hdr)
|
||||
f.calls = append(f.calls, req.Method)
|
||||
f.mu.Unlock()
|
||||
|
||||
if req.ID == nil { // notification
|
||||
return &PostResponse{Status: 202, Body: []byte(`{}`)}, nil
|
||||
}
|
||||
result, rerr := f.handler(req.Method, req.Params)
|
||||
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID}
|
||||
if rerr != nil {
|
||||
resp["error"] = map[string]any{"code": rerr.Code, "message": rerr.Message}
|
||||
} else {
|
||||
resp["result"] = result
|
||||
}
|
||||
raw, _ := json.Marshal(resp)
|
||||
out := &PostResponse{Status: 200, Body: raw, ContentType: "application/json", Header: map[string]string{}}
|
||||
if f.sse {
|
||||
out.ContentType = "text/event-stream"
|
||||
out.Body = []byte("event: message\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/progress\"}\n\nevent: message\ndata: " + string(raw) + "\n\n")
|
||||
}
|
||||
if f.session != "" {
|
||||
out.Header["Mcp-Session-Id"] = f.session
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// echoServer is a handler with two tools, one read-only and one not.
|
||||
func echoServer() func(string, json.RawMessage) (any, *rpcError) {
|
||||
return func(method string, params json.RawMessage) (any, *rpcError) {
|
||||
switch method {
|
||||
case "initialize":
|
||||
return map[string]any{
|
||||
"protocolVersion": ProtocolVersion,
|
||||
"serverInfo": map[string]any{"name": "fake", "version": "0.1"},
|
||||
}, nil
|
||||
case "tools/list":
|
||||
return map[string]any{"tools": []any{
|
||||
map[string]any{
|
||||
"name": "read_thing", "description": "reads",
|
||||
"inputSchema": map[string]any{"type": "object"},
|
||||
"annotations": map[string]any{"readOnlyHint": true},
|
||||
},
|
||||
map[string]any{"name": "break_thing", "description": "mutates"},
|
||||
}}, nil
|
||||
case "tools/call":
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
Args map[string]any `json:"arguments"`
|
||||
}
|
||||
_ = json.Unmarshal(params, &p)
|
||||
if p.Name == "break_thing" {
|
||||
return map[string]any{"isError": true, "content": []any{
|
||||
map[string]any{"type": "text", "text": "не вышло"}}}, nil
|
||||
}
|
||||
return map[string]any{"content": []any{
|
||||
map[string]any{"type": "text", "text": fmt.Sprintf("%s:%v", p.Name, p.Args["q"])},
|
||||
map[string]any{"type": "image", "text": "ignored"},
|
||||
}}, nil
|
||||
case "resources/list":
|
||||
return map[string]any{"resources": []any{
|
||||
map[string]any{"uri": "note://one", "name": "one", "mimeType": "text/plain"},
|
||||
map[string]any{"uri": "", "name": "nameless"},
|
||||
}}, nil
|
||||
case "resources/read":
|
||||
return map[string]any{"contents": []any{map[string]any{"text": "тело ресурса"}}}, nil
|
||||
}
|
||||
return nil, &rpcError{Code: -32601, Message: "method not found"}
|
||||
}
|
||||
}
|
||||
|
||||
func dialFake(t *testing.T, p *fakePoster) *Client {
|
||||
t.Helper()
|
||||
c := newClient("fake", newHTTPTransport(p, "http://example.test/mcp"))
|
||||
if err := c.Initialize(context.Background()); err != nil {
|
||||
t.Fatalf("initialize: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestHandshakeAndDiscovery(t *testing.T) {
|
||||
for _, sse := range []bool{false, true} {
|
||||
name := "json"
|
||||
if sse {
|
||||
name = "sse"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
p := &fakePoster{handler: echoServer(), sse: sse}
|
||||
c := dialFake(t, p)
|
||||
if got := c.Info().Name; got != "fake" {
|
||||
t.Fatalf("server name = %q", got)
|
||||
}
|
||||
if got := c.Info().ProtocolVersion; got != ProtocolVersion {
|
||||
t.Fatalf("protocol = %q", got)
|
||||
}
|
||||
tools, err := c.ListTools(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list tools: %v", err)
|
||||
}
|
||||
if len(tools) != 2 {
|
||||
t.Fatalf("tools = %+v", tools)
|
||||
}
|
||||
byName := map[string]Tool{}
|
||||
for _, tl := range tools {
|
||||
byName[tl.Name] = tl
|
||||
}
|
||||
if !byName["read_thing"].ReadOnly {
|
||||
t.Error("read_thing should be read-only (readOnlyHint true)")
|
||||
}
|
||||
// The important direction: no annotation ⇒ assume it mutates.
|
||||
if byName["break_thing"].ReadOnly {
|
||||
t.Error("break_thing has no readOnlyHint, must NOT be treated as read-only")
|
||||
}
|
||||
if byName["read_thing"].Server != "fake" {
|
||||
t.Error("tool should carry its server handle")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallToolTextOnly(t *testing.T) {
|
||||
c := dialFake(t, &fakePoster{handler: echoServer()})
|
||||
out, err := c.CallTool(context.Background(), "read_thing", map[string]any{"q": "привет"})
|
||||
if err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if out != "read_thing:привет" {
|
||||
t.Fatalf("out = %q (non-text content must be dropped)", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallToolErrorResult(t *testing.T) {
|
||||
c := dialFake(t, &fakePoster{handler: echoServer()})
|
||||
out, err := c.CallTool(context.Background(), "break_thing", nil)
|
||||
if err == nil {
|
||||
t.Fatal("isError result must surface as an error")
|
||||
}
|
||||
if out != "не вышло" {
|
||||
t.Fatalf("text should still come back, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResources(t *testing.T) {
|
||||
c := dialFake(t, &fakePoster{handler: echoServer()})
|
||||
rs, err := c.ListResources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list resources: %v", err)
|
||||
}
|
||||
if len(rs) != 1 || rs[0].URI != "note://one" {
|
||||
t.Fatalf("resources = %+v (a uri-less entry must be dropped)", rs)
|
||||
}
|
||||
body, err := c.ReadResource(context.Background(), "note://one")
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if body != "тело ресурса" {
|
||||
t.Fatalf("body = %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallBeforeInitializeRefused(t *testing.T) {
|
||||
c := newClient("fake", newHTTPTransport(&fakePoster{handler: echoServer()}, "http://example.test/mcp"))
|
||||
if _, err := c.CallTool(context.Background(), "read_thing", nil); err != ErrNotInitialized {
|
||||
t.Fatalf("err = %v, want ErrNotInitialized", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionIDEchoed(t *testing.T) {
|
||||
p := &fakePoster{handler: echoServer(), session: "sess-1"}
|
||||
c := dialFake(t, p)
|
||||
if _, err := c.ListTools(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
last := p.seen[len(p.seen)-1]
|
||||
if last["Mcp-Session-Id"] != "sess-1" {
|
||||
t.Fatalf("session header not echoed: %+v", last)
|
||||
}
|
||||
if !strings.Contains(last["Accept"], "text/event-stream") {
|
||||
t.Fatalf("Accept must offer both forms: %q", last["Accept"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandshakeWithoutProtocolVersionRefused(t *testing.T) {
|
||||
p := &fakePoster{handler: func(m string, _ json.RawMessage) (any, *rpcError) {
|
||||
return map[string]any{"serverInfo": map[string]any{"name": "not-mcp"}}, nil
|
||||
}}
|
||||
c := newClient("x", newHTTPTransport(p, "http://example.test/mcp"))
|
||||
if err := c.Initialize(context.Background()); err == nil {
|
||||
t.Fatal("a reply with no protocolVersion is not an MCP server")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRPCErrorSurfaces(t *testing.T) {
|
||||
c := dialFake(t, &fakePoster{handler: echoServer()})
|
||||
if _, err := c.callRaw(context.Background(), "nope/nope"); err == nil {
|
||||
t.Fatal("want an rpc error")
|
||||
} else if !strings.Contains(err.Error(), "method not found") {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// callRaw is a test-only shim so the rpc-error path can be exercised without a
|
||||
// typed wrapper for a method the server does not implement.
|
||||
func (c *Client) callRaw(ctx context.Context, method string) (any, error) {
|
||||
var out any
|
||||
err := c.call(ctx, method, map[string]any{}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func TestDecodeFrame(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, in, want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "plain json", in: `{"id":1,"result":{}}`, want: `{"id":1,"result":{}}`},
|
||||
{name: "sse single", in: "event: message\ndata: {\"id\":1,\"result\":1}\n\n", want: `{"id":1,"result":1}`},
|
||||
{
|
||||
name: "sse picks the response not the notification",
|
||||
in: "data: {\"method\":\"notifications/progress\"}\n\ndata: {\"id\":2,\"result\":2}\n\n",
|
||||
want: `{"id":2,"result":2}`,
|
||||
},
|
||||
{name: "empty", in: " ", wantErr: true},
|
||||
{name: "sse with no response", in: "data: {\"method\":\"x\"}\n\n", wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := decodeFrame([]byte(tc.in))
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("want error, got %q", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != tc.want {
|
||||
t.Fatalf("got %q want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg ServerConfig
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "stdio ok", cfg: ServerConfig{Name: "a", Command: "echo"}},
|
||||
{name: "http ok", cfg: ServerConfig{Name: "a", URL: "http://x.test/mcp"}},
|
||||
{name: "no name", cfg: ServerConfig{Command: "echo"}, wantErr: true},
|
||||
{name: "spacey name", cfg: ServerConfig{Name: "a b", Command: "echo"}, wantErr: true},
|
||||
{name: "neither", cfg: ServerConfig{Name: "a"}, wantErr: true},
|
||||
{name: "both", cfg: ServerConfig{Name: "a", Command: "echo", URL: "http://x.test"}, wantErr: true},
|
||||
{name: "bad scheme", cfg: ServerConfig{Name: "a", URL: "file:///etc/passwd"}, wantErr: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := Validate([]ServerConfig{tc.cfg})
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("err = %v, wantErr = %v", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := Validate([]ServerConfig{{Name: "a", Command: "x"}, {Name: "a", Command: "y"}}); err == nil {
|
||||
t.Error("duplicate names must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerOffWhenNothingEnabled(t *testing.T) {
|
||||
m, err := NewManager(nil, []ServerConfig{{Name: "a", Command: "echo"}}) // Enabled=false
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.Empty() {
|
||||
t.Fatal("a server that is not enabled must not be wired")
|
||||
}
|
||||
m.Connect(context.Background())
|
||||
if got := m.Tools(); len(got) != 0 {
|
||||
t.Fatalf("tools = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerDiscoversAndCalls(t *testing.T) {
|
||||
p := &fakePoster{handler: echoServer()}
|
||||
m, err := NewManager(func(ServerConfig) (Poster, error) { return p, nil },
|
||||
[]ServerConfig{{Name: "fake", URL: "http://example.test/mcp", Enabled: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Connect(context.Background())
|
||||
defer m.Close()
|
||||
|
||||
tools := m.Tools()
|
||||
if len(tools) != 2 {
|
||||
t.Fatalf("tools = %+v", tools)
|
||||
}
|
||||
out, err := m.Call(context.Background(), "fake", "read_thing", map[string]any{"q": "да"})
|
||||
if err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if out != "read_thing:да" {
|
||||
t.Fatalf("out = %q", out)
|
||||
}
|
||||
// The discovered set is a second allowlist.
|
||||
if _, err := m.Call(context.Background(), "fake", "not_offered", nil); err == nil {
|
||||
t.Error("a tool the server does not offer must be refused")
|
||||
}
|
||||
if _, err := m.Call(context.Background(), "other", "read_thing", nil); err == nil {
|
||||
t.Error("an unconfigured server must be refused")
|
||||
}
|
||||
st := m.Status()
|
||||
if len(st) != 1 || !st[0].Connected || st[0].Transport != "http" || st[0].Tools != 2 {
|
||||
t.Fatalf("status = %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerAllowToolsAndMaxTools(t *testing.T) {
|
||||
p := &fakePoster{handler: echoServer()}
|
||||
mk := func(cfg ServerConfig) *Manager {
|
||||
cfg.Name, cfg.URL, cfg.Enabled = "fake", "http://example.test/mcp", true
|
||||
m, err := NewManager(func(ServerConfig) (Poster, error) { return p, nil }, []ServerConfig{cfg})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Connect(context.Background())
|
||||
return m
|
||||
}
|
||||
m := mk(ServerConfig{AllowTools: []string{"read_thing"}})
|
||||
defer m.Close()
|
||||
if got := m.Tools(); len(got) != 1 || got[0].Name != "read_thing" {
|
||||
t.Fatalf("allow_tools ignored: %+v", got)
|
||||
}
|
||||
if _, err := m.Call(context.Background(), "fake", "break_thing", nil); err == nil {
|
||||
t.Error("a tool excluded by allow_tools must be unreachable")
|
||||
}
|
||||
m2 := mk(ServerConfig{MaxTools: 1})
|
||||
defer m2.Close()
|
||||
if got := m2.Tools(); len(got) != 1 || got[0].Name != "break_thing" {
|
||||
t.Fatalf("max_tools should keep the first name-sorted tool: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerURLServerWithoutHTTPDoor(t *testing.T) {
|
||||
m, err := NewManager(nil, []ServerConfig{{Name: "fake", URL: "http://example.test/mcp", Enabled: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Connect(context.Background())
|
||||
st := m.Status()
|
||||
if len(st) != 1 || st[0].Connected || st[0].Err == "" {
|
||||
t.Fatalf("a url server with no poster must be recorded as failed: %+v", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerReconnectAfterFailure(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
fail := true
|
||||
m, err := NewManager(func(ServerConfig) (Poster, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if fail {
|
||||
return nil, fmt.Errorf("down")
|
||||
}
|
||||
return &fakePoster{handler: echoServer()}, nil
|
||||
}, []ServerConfig{{Name: "fake", URL: "http://example.test/mcp", Enabled: true}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer m.Close()
|
||||
m.Connect(context.Background())
|
||||
if m.Status()[0].Connected {
|
||||
t.Fatal("should be down")
|
||||
}
|
||||
mu.Lock()
|
||||
fail = false
|
||||
mu.Unlock()
|
||||
// Refresh honours the backoff, so pretend the last attempt was long ago.
|
||||
m.mu.Lock()
|
||||
m.conns["fake"].lastTry = time.Now().Add(-2 * DefaultReconnectEvery)
|
||||
m.mu.Unlock()
|
||||
m.Refresh(context.Background())
|
||||
if !m.Status()[0].Connected {
|
||||
t.Fatalf("should have reconnected: %+v", m.Status())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalNameAndCmd(t *testing.T) {
|
||||
cases := [][3]string{
|
||||
{"vikunja", "list_tasks", "vikunja_list_tasks"},
|
||||
{"Vikunja", "Get Task Details", "vikunja_get_task_details"},
|
||||
{"fs", "read-file", "fs_read_file"},
|
||||
{"", "search", "search"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := LocalName(c[0], c[1]); got != c[2] {
|
||||
t.Errorf("LocalName(%q,%q) = %q want %q", c[0], c[1], got, c[2])
|
||||
}
|
||||
}
|
||||
server, tool, ok := ParseCmd(Cmd("vikunja", "list_tasks"))
|
||||
if !ok || server != "vikunja" || tool != "list_tasks" {
|
||||
t.Fatalf("ParseCmd round-trip: %q %q %v", server, tool, ok)
|
||||
}
|
||||
for _, bad := range [][]string{nil, {"systemctl", "restart", "nginx"}, {"mcp", "vikunja"}, {"mcp", "", "x"}} {
|
||||
if _, _, ok := ParseCmd(bad); ok {
|
||||
t.Errorf("ParseCmd(%v) must not claim an ordinary tool row", bad)
|
||||
}
|
||||
}
|
||||
if Scope("vikunja") != "mcp:vikunja" {
|
||||
t.Error("scope")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// maxLine bounds one JSON-RPC frame from a subprocess. A tool result bigger
|
||||
// than this is a misbehaving server, not something to buffer.
|
||||
const maxLine = 1 << 20 // 1 MiB
|
||||
|
||||
// stdioTransport speaks newline-delimited JSON-RPC to a child process. This is
|
||||
// the local transport: the server runs on this box, under this user, and gets
|
||||
// no network guard because it never touches the network on our behalf.
|
||||
//
|
||||
// Args are argv, never a shell string — the same discipline internal/tool
|
||||
// keeps, for the same reason.
|
||||
type stdioTransport struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
in io.WriteCloser
|
||||
out *bufio.Reader
|
||||
dead bool
|
||||
}
|
||||
|
||||
func newStdioTransport(ctx context.Context, argv []string, env []string, dir string) (*stdioTransport, error) {
|
||||
if len(argv) == 0 {
|
||||
return nil, errors.New("mcp: stdio server needs a command")
|
||||
}
|
||||
cmd := exec.Command(argv[0], argv[1:]...)
|
||||
cmd.Dir = dir
|
||||
if len(env) > 0 {
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
}
|
||||
cmd.Stderr = os.Stderr
|
||||
in, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mcp: stdin pipe: %w", err)
|
||||
}
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mcp: stdout pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("mcp: start %q: %w", argv[0], err)
|
||||
}
|
||||
return &stdioTransport{cmd: cmd, in: in, out: bufio.NewReaderSize(out, 64<<10)}, nil
|
||||
}
|
||||
|
||||
func (t *stdioTransport) Call(ctx context.Context, req *rpcRequest) (*rpcResponse, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.dead {
|
||||
return nil, ErrClosed
|
||||
}
|
||||
if err := t.write(req); err != nil {
|
||||
t.dead = true
|
||||
return nil, err
|
||||
}
|
||||
// Read until the frame with our id turns up; anything else on the pipe is
|
||||
// a notification or a server-initiated request we do not answer.
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line, err := t.readLine()
|
||||
if err != nil {
|
||||
t.dead = true
|
||||
return nil, err
|
||||
}
|
||||
var resp rpcResponse
|
||||
if err := json.Unmarshal(line, &resp); err != nil {
|
||||
continue // not a response frame; ignore rather than break the turn
|
||||
}
|
||||
if resp.ID == nil || *resp.ID != req.ID {
|
||||
continue
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (t *stdioTransport) Notify(ctx context.Context, method string, params any) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.dead {
|
||||
return ErrClosed
|
||||
}
|
||||
return t.write(&rpcRequest{JSONRPC: "2.0", Method: method, Params: params})
|
||||
}
|
||||
|
||||
func (t *stdioTransport) write(req *rpcRequest) error {
|
||||
req.JSONRPC = "2.0"
|
||||
raw, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := t.in.Write(append(raw, '\n')); err != nil {
|
||||
return fmt.Errorf("mcp: write %s: %w", req.Method, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *stdioTransport) readLine() ([]byte, error) {
|
||||
for {
|
||||
line, err := t.out.ReadString('\n')
|
||||
if err != nil {
|
||||
if len(strings.TrimSpace(line)) == 0 {
|
||||
return nil, fmt.Errorf("mcp: read: %w", err)
|
||||
}
|
||||
return []byte(line), nil
|
||||
}
|
||||
if len(line) > maxLine {
|
||||
return nil, fmt.Errorf("mcp: frame exceeds %d bytes", maxLine)
|
||||
}
|
||||
if s := strings.TrimSpace(line); s != "" {
|
||||
return []byte(s), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *stdioTransport) Close() error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.dead = true
|
||||
if t.in != nil {
|
||||
_ = t.in.Close()
|
||||
}
|
||||
if t.cmd.Process != nil {
|
||||
_ = t.cmd.Process.Kill()
|
||||
_ = t.cmd.Wait()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// alive reports whether the transport can still carry a call. The manager uses
|
||||
// it to decide on a reconnect instead of retrying into a dead pipe.
|
||||
func (t *stdioTransport) alive() bool {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return !t.dead
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The stdio transport is tested against a real subprocess — this test binary,
|
||||
// re-executed with MAVEN_MCP_FAKE set, acting as a minimal MCP server. No
|
||||
// python, no fixture file, no network.
|
||||
func TestMain(m *testing.M) {
|
||||
if os.Getenv("MAVEN_MCP_FAKE") != "" {
|
||||
fakeStdioServer()
|
||||
return
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func fakeStdioServer() {
|
||||
h := echoServer()
|
||||
sc := bufio.NewScanner(os.Stdin)
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
defer out.Flush()
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var req struct {
|
||||
ID *int64 `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params json.RawMessage `json:"params"`
|
||||
}
|
||||
if json.Unmarshal([]byte(line), &req) != nil {
|
||||
continue
|
||||
}
|
||||
if req.ID == nil {
|
||||
// A notification gets no reply, but we emit an unrelated
|
||||
// notification so the client's frame-skipping is exercised.
|
||||
_, _ = out.WriteString("{\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\"}\n")
|
||||
_ = out.Flush()
|
||||
continue
|
||||
}
|
||||
result, rerr := h(req.Method, req.Params)
|
||||
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID}
|
||||
if rerr != nil {
|
||||
resp["error"] = map[string]any{"code": rerr.Code, "message": rerr.Message}
|
||||
} else {
|
||||
resp["result"] = result
|
||||
}
|
||||
raw, _ := json.Marshal(resp)
|
||||
_, _ = out.Write(append(raw, '\n'))
|
||||
_ = out.Flush()
|
||||
if os.Getenv("MAVEN_MCP_FAKE") == "die" && req.Method == "tools/list" {
|
||||
return // hang up, so the reconnect path has something to see
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stdioManager(t *testing.T, mode string) *Manager {
|
||||
t.Helper()
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Skipf("no executable path: %v", err)
|
||||
}
|
||||
if _, err := exec.LookPath(self); err != nil && !strings.Contains(self, "/") {
|
||||
t.Skip("test binary not executable")
|
||||
}
|
||||
m, err := NewManager(nil, []ServerConfig{{
|
||||
Name: "fake",
|
||||
Command: self,
|
||||
Env: []string{"MAVEN_MCP_FAKE=" + mode},
|
||||
Enabled: true,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Connect(context.Background())
|
||||
return m
|
||||
}
|
||||
|
||||
func TestStdioTransportEndToEnd(t *testing.T) {
|
||||
m := stdioManager(t, "1")
|
||||
defer m.Close()
|
||||
st := m.Status()
|
||||
if len(st) != 1 || !st[0].Connected {
|
||||
t.Fatalf("status = %+v", st)
|
||||
}
|
||||
if st[0].Transport != "stdio" {
|
||||
t.Fatalf("transport = %q", st[0].Transport)
|
||||
}
|
||||
if got := len(m.Tools()); got != 2 {
|
||||
t.Fatalf("tools = %d", got)
|
||||
}
|
||||
out, err := m.Call(context.Background(), "fake", "read_thing", map[string]any{"q": "стдио"})
|
||||
if err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if out != "read_thing:стдио" {
|
||||
t.Fatalf("out = %q", out)
|
||||
}
|
||||
res := m.Resources(context.Background())
|
||||
if len(res) != 1 || res[0].URI != "note://one" {
|
||||
t.Fatalf("resources = %+v", res)
|
||||
}
|
||||
body, err := m.ReadResource(context.Background(), "fake", "note://one")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body != "тело ресурса" {
|
||||
t.Fatalf("body = %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioServerThatDiesIsNotUsable(t *testing.T) {
|
||||
m := stdioManager(t, "die")
|
||||
defer m.Close()
|
||||
// The server hung up after tools/list; the next call must fail cleanly
|
||||
// rather than hang or panic.
|
||||
if _, err := m.Call(context.Background(), "fake", "read_thing", nil); err == nil {
|
||||
t.Fatal("a call into a dead server must error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStdioMissingCommand(t *testing.T) {
|
||||
m, err := NewManager(nil, []ServerConfig{{
|
||||
Name: "nope", Command: "/nonexistent/mcp-server-that-is-not-there", Enabled: true,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Connect(context.Background())
|
||||
st := m.Status()
|
||||
if st[0].Connected || st[0].Err == "" {
|
||||
t.Fatalf("a missing binary must be recorded, not fatal: %+v", st)
|
||||
}
|
||||
if got := len(m.Tools()); got != 0 {
|
||||
t.Fatalf("tools = %d", got)
|
||||
}
|
||||
if !strings.Contains(fmt.Sprint(st[0].Err), "start") {
|
||||
t.Logf("err = %q", st[0].Err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
// WebfetchDoor builds the PosterFactory used in production: one guarded
|
||||
// webfetch.Fetcher per url server, with that server's allow_private and the
|
||||
// shared host lists and limits.
|
||||
//
|
||||
// One fetcher PER server is the point. allow_private is a hole in the
|
||||
// private-address guard, and a hole punched for the Vikunja server on loopback
|
||||
// must not become a hole for some public endpoint that happens to redirect at
|
||||
// the LAN. Rate limiting is per fetcher too, which is the right shape here:
|
||||
// separate servers are separate hosts.
|
||||
func WebfetchDoor(limits webfetch.Config) PosterFactory {
|
||||
return func(cfg ServerConfig) (Poster, error) {
|
||||
c := limits
|
||||
c.AllowPrivate = cfg.AllowPrivate
|
||||
if c.Timeout <= 0 && cfg.Timeout > 0 {
|
||||
c.Timeout = cfg.Timeout
|
||||
}
|
||||
return fetcherPoster{webfetch.New(c)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// fetcherPoster adapts webfetch.Fetcher to Poster. It exists so this package
|
||||
// does not have to know webfetch's Response type, and so a test can substitute
|
||||
// a fake without a listener.
|
||||
type fetcherPoster struct{ f *webfetch.Fetcher }
|
||||
|
||||
func (p fetcherPoster) Post(ctx context.Context, rawURL, contentType string, body []byte, hdr map[string]string) (*PostResponse, error) {
|
||||
resp, err := p.f.Post(ctx, rawURL, contentType, body, hdr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mcp: post %s: %w", rawURL, err)
|
||||
}
|
||||
return &PostResponse{
|
||||
Status: resp.Status,
|
||||
ContentType: resp.ContentType,
|
||||
Body: resp.Body,
|
||||
Header: resp.Header,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Result — the full account of one Apply. Every field is filled in on the
|
||||
// failure paths too, because "what state is my box in" is the only question that
|
||||
// matters after a failed update.
|
||||
type Result struct {
|
||||
Verified bool
|
||||
SnapshotID string // the rollback target; named even when the rollback failed
|
||||
Installed []string
|
||||
Restarted bool
|
||||
Healthy bool
|
||||
RolledBack bool
|
||||
// RollbackHealthy — whether she answered again after the restore. False with
|
||||
// RolledBack true is the manual-recovery case.
|
||||
RollbackHealthy bool
|
||||
Steps []Step
|
||||
Took time.Duration
|
||||
}
|
||||
|
||||
// Apply is the whole update, in the only order that is safe.
|
||||
//
|
||||
// It is called by a human running cmd/mavupdate on the box. Nothing else calls
|
||||
// it: no timer, no IPC method, no web route, no act. See the package comment.
|
||||
func (u *Updater) Apply(ctx context.Context) (Result, error) {
|
||||
start := u.now()
|
||||
res := Result{}
|
||||
defer func() { res.Took = u.now().Sub(start) }()
|
||||
|
||||
// 0. She has to be answering before we start. Otherwise a failed update and
|
||||
// a box that was already broken look identical afterwards, and the rollback
|
||||
// has no baseline to prove itself against.
|
||||
u.log("preflight: checking the running daemon")
|
||||
if err := u.health(ctx, u.cfg.HealthSocket); err != nil {
|
||||
return res, fmt.Errorf("%w: %v", ErrUnhealthyBefore, err)
|
||||
}
|
||||
|
||||
// 1. Snapshot what is deployed now, BEFORE the build.
|
||||
//
|
||||
// The order matters and it is not the obvious one. `make build` writes its
|
||||
// binaries into the working tree, and on the docker deployment the working
|
||||
// tree IS the install dir — so snapshotting after the build would snapshot
|
||||
// the new artifacts and leave nothing to roll back to. The snapshot is the
|
||||
// only thing standing between a bad build and a box that needs a screwdriver,
|
||||
// so it is taken first, while the deployed bytes are still the old ones.
|
||||
names := append(append([]string{}, u.cfg.Binaries...), u.cfg.ConfigFiles...)
|
||||
snap, err := u.store.Save(u.cfg.InstallDir, names, u.gitHead(ctx), "pre-update")
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.SnapshotID = snap.ID
|
||||
u.log("snapshot: %s (%d files) in %s", snap.ID, len(snap.Files), snap.Dir())
|
||||
|
||||
// 2. Build and test before anything is deployed. A broken tree costs time
|
||||
// and nothing else — but `make build` has already overwritten the binaries in
|
||||
// the tree, so restore them: otherwise a later restart by hand would deploy
|
||||
// code that failed its own tests. Nothing has been restarted, so this is a
|
||||
// file restore with no restart and no health check.
|
||||
steps, err := u.Verify(ctx)
|
||||
res.Steps = append(res.Steps, steps...)
|
||||
if err != nil {
|
||||
if rerr := snap.Restore(u.cfg.InstallDir); rerr != nil {
|
||||
u.log("verify failed and the artifacts could not be put back: %v — the previous ones are in %s", rerr, snap.Dir())
|
||||
} else {
|
||||
res.RolledBack = true
|
||||
u.log("verify failed; the previously deployed artifacts are back in place, she was never restarted")
|
||||
}
|
||||
return res, err
|
||||
}
|
||||
res.Verified = true
|
||||
|
||||
// 3. Install. Per-file temp+rename, so an interruption leaves whole files.
|
||||
// Config is snapshotted but never overwritten — an update does not get to
|
||||
// replace the operator's config.
|
||||
installed, err := u.install()
|
||||
res.Installed = installed
|
||||
if err != nil {
|
||||
// Files may be half-swapped across the set, so restore before returning
|
||||
// even though nothing has been restarted yet.
|
||||
u.log("install failed: %v — restoring", err)
|
||||
return u.rollback(ctx, snap, res, err)
|
||||
}
|
||||
u.log("install: %d artifact(s) into %s", len(installed), u.cfg.InstallDir)
|
||||
|
||||
// 4. Restart, then 5. prove she answers.
|
||||
if err := u.restart(ctx, &res); err != nil {
|
||||
return u.rollback(ctx, snap, res, err)
|
||||
}
|
||||
u.log("restart: ok, waiting for her to answer (up to %s)", u.cfg.healthTimeout())
|
||||
if err := u.waitHealthy(ctx, u.cfg.healthTimeout()); err != nil {
|
||||
return u.rollback(ctx, snap, res, err)
|
||||
}
|
||||
res.Healthy = true
|
||||
u.log("health: she answers on %s — update committed", u.cfg.HealthSocket)
|
||||
|
||||
if err := u.store.Prune(u.cfg.KeepSnapshots); err != nil {
|
||||
u.log("prune: %v (harmless)", err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Rollback restores a snapshot by id (empty = the newest) and restarts. Exposed
|
||||
// separately so the operator can undo an update that verified, restarted and
|
||||
// answered a Presence call but is wrong in a way no health check can see.
|
||||
func (u *Updater) Rollback(ctx context.Context, id string) (Result, error) {
|
||||
var snap Snapshot
|
||||
var err error
|
||||
if id == "" {
|
||||
snaps, lerr := u.store.List()
|
||||
if lerr != nil {
|
||||
return Result{}, lerr
|
||||
}
|
||||
if len(snaps) == 0 {
|
||||
return Result{}, errors.New("update: no snapshots to roll back to")
|
||||
}
|
||||
snap = snaps[0]
|
||||
} else if snap, err = u.store.Load(id); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
res := Result{SnapshotID: snap.ID}
|
||||
return u.rollback(ctx, snap, res, errors.New("operator asked for a rollback"))
|
||||
}
|
||||
|
||||
// rollback restores the snapshot and restarts, then reports whether that worked.
|
||||
// It depends on nothing that the update changed: file copies out of the snapshot
|
||||
// dir and the same restart command. No build, no migration, no cooperation from
|
||||
// the code being replaced.
|
||||
func (u *Updater) rollback(ctx context.Context, snap Snapshot, res Result, cause error) (Result, error) {
|
||||
// A rollback interrupted halfway is the one outcome worse than the failure
|
||||
// that triggered it, so it does not inherit the caller's cancellation: a
|
||||
// Ctrl-C during the health wait must not abandon the restore mid-restart.
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
res.RolledBack = true
|
||||
u.log("rollback: restoring snapshot %s over %s", snap.ID, u.cfg.InstallDir)
|
||||
if err := snap.Restore(u.cfg.InstallDir); err != nil {
|
||||
u.log("rollback: RESTORE FAILED: %v", err)
|
||||
return res, fmt.Errorf("%w: %v (after %v); the previous artifacts are in %s — copy them back by hand", ErrRollbackFailed, err, cause, snap.Dir())
|
||||
}
|
||||
// A restore with no restart leaves the failed process running, so a failed
|
||||
// restart here is still the manual-recovery case.
|
||||
if err := u.restart(ctx, &res); err != nil {
|
||||
u.log("rollback: RESTART FAILED: %v", err)
|
||||
return res, fmt.Errorf("%w: restored %s but the restart failed: %v (after %v)", ErrRollbackFailed, snap.ID, err, cause)
|
||||
}
|
||||
if err := u.waitHealthy(ctx, u.cfg.healthTimeout()); err != nil {
|
||||
u.log("rollback: she still does not answer: %v", err)
|
||||
return res, fmt.Errorf("%w: restored %s and restarted but she does not answer: %v (after %v)", ErrRollbackFailed, snap.ID, err, cause)
|
||||
}
|
||||
res.RollbackHealthy = true
|
||||
u.log("rollback: she answers again on the previous build (%s)", snap.ID)
|
||||
return res, fmt.Errorf("%w to %s: %v", ErrRolledBack, snap.ID, cause)
|
||||
}
|
||||
|
||||
// install copies the freshly built binaries from SourceDir into InstallDir.
|
||||
//
|
||||
// When the two are the same directory — the docker deployment builds the image
|
||||
// from the working tree — this is a no-op by design rather than by accident: the
|
||||
// artifacts are already where they belong and the restart command rebuilds the
|
||||
// image from them.
|
||||
func (u *Updater) install() ([]string, error) {
|
||||
if filepath.Clean(u.cfg.SourceDir) == filepath.Clean(u.cfg.InstallDir) {
|
||||
return u.cfg.Binaries, nil
|
||||
}
|
||||
var done []string
|
||||
for _, name := range u.cfg.Binaries {
|
||||
src := filepath.Join(u.cfg.SourceDir, name)
|
||||
fi, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return done, fmt.Errorf("update: install %s: %w (did `make build` produce it?)", name, err)
|
||||
}
|
||||
if _, err := copyFile(src, filepath.Join(u.cfg.InstallDir, name), fi.Mode().Perm()); err != nil {
|
||||
return done, fmt.Errorf("update: install %s: %w", name, err)
|
||||
}
|
||||
done = append(done, name)
|
||||
}
|
||||
return done, nil
|
||||
}
|
||||
|
||||
func (u *Updater) restart(ctx context.Context, res *Result) error {
|
||||
u.log("restart: %v", u.cfg.RestartCmd)
|
||||
out, err := u.run(ctx, u.cfg.SourceDir, u.cfg.RestartCmd)
|
||||
if err != nil {
|
||||
res.Steps = append(res.Steps, Step{Name: "restart", Argv: u.cfg.RestartCmd, Err: err, Output: tail(out, 4000)})
|
||||
return fmt.Errorf("update: restart %v: %w", u.cfg.RestartCmd, err)
|
||||
}
|
||||
res.Restarted = true
|
||||
res.Steps = append(res.Steps, Step{Name: "restart", Argv: u.cfg.RestartCmd})
|
||||
return nil
|
||||
}
|
||||
|
||||
// gitHead records which commit produced a snapshot, for the operator's benefit.
|
||||
// Best-effort: a tree without git is not a reason to refuse to snapshot.
|
||||
func (u *Updater) gitHead(ctx context.Context) string {
|
||||
out, err := u.run(ctx, u.cfg.SourceDir, []string{"git", "rev-parse", "HEAD"})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// The health check is the whole basis for rolling back, so it has to mean
|
||||
// something. "The process is running" does not: mavend can be up with a dead
|
||||
// store, a socket it never bound, or a config it failed to parse. What is
|
||||
// checked instead is that she answers a real read over the real IPC socket —
|
||||
// which exercises the socket, the dispatch table and the store in one call.
|
||||
//
|
||||
// Presence is the method used because it is read-only (safe to retry), needs no
|
||||
// arguments, and touches the store. It cannot write anything, so a health check
|
||||
// never leaves a trace in her memory.
|
||||
|
||||
// DialHealth connects to the mavend socket and performs one read.
|
||||
func DialHealth(ctx context.Context, socket string) error {
|
||||
c, err := ipc.Dial(socket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update: health dial: %w", err)
|
||||
}
|
||||
defer c.Close()
|
||||
if _, err := c.Presence(ctx); err != nil {
|
||||
return fmt.Errorf("update: health read: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitHealthy retries the health check until it passes or the timeout elapses.
|
||||
// A restart is not instantaneous — she loads a 1.7B on boot — so the first few
|
||||
// failures are expected and are not a reason to roll back.
|
||||
func (u *Updater) waitHealthy(ctx context.Context, timeout time.Duration) error {
|
||||
deadline := u.now().Add(timeout)
|
||||
delay := 500 * time.Millisecond
|
||||
var last error
|
||||
for {
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
err := u.health(attemptCtx, u.cfg.HealthSocket)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
last = err
|
||||
if u.now().After(deadline) {
|
||||
return fmt.Errorf("update: not healthy after %s: %w", timeout, last)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(delay):
|
||||
}
|
||||
if delay < 5*time.Second {
|
||||
delay *= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A snapshot is a byte-for-byte copy of the deployed artifacts plus a manifest
|
||||
// of their sha256 sums, taken before an install.
|
||||
//
|
||||
// It is copies, not hardlinks and not a git stash, for one reason: the restore
|
||||
// path must work when everything else is broken. A hardlink into the install dir
|
||||
// would be clobbered by the very install it exists to undo, and a git-based
|
||||
// undo needs a toolchain, a clean tree, and a rebuild — three things a failed
|
||||
// update is likely to have taken away. Copying two dozen megabytes of Go
|
||||
// binaries costs a second and needs nothing but the filesystem.
|
||||
//
|
||||
// The sums are what make a restore verifiable rather than hopeful: Restore
|
||||
// re-hashes every file it writes, so "the old bytes are back" is checked, not
|
||||
// assumed.
|
||||
|
||||
// FileRec — one file in a snapshot.
|
||||
type FileRec struct {
|
||||
Name string `json:"name"` // relative name inside the install dir
|
||||
SHA256 string `json:"sha256"` // of the snapshotted bytes
|
||||
Mode os.FileMode `json:"mode"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// Snapshot — the manifest. Written last, so a directory without a readable
|
||||
// manifest.json is an aborted snapshot and is never offered as a rollback target.
|
||||
type Snapshot struct {
|
||||
ID string `json:"id"` // sortable timestamp, also the directory name
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Commit string `json:"commit,omitempty"` // git HEAD of the tree that produced it, when known
|
||||
Note string `json:"note,omitempty"`
|
||||
Files []FileRec `json:"files"`
|
||||
|
||||
dir string // absolute path, filled in by List/Load
|
||||
}
|
||||
|
||||
// Dir — where this snapshot's file copies live.
|
||||
func (s Snapshot) Dir() string { return s.dir }
|
||||
|
||||
const manifestName = "manifest.json"
|
||||
|
||||
// Store is a directory of snapshots.
|
||||
type Store struct {
|
||||
Dir string
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func (st *Store) clock() time.Time {
|
||||
if st.now != nil {
|
||||
return st.now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// Save copies names (relative to srcDir) into a new snapshot and writes the
|
||||
// manifest. A name that does not exist is skipped rather than fatal: the first
|
||||
// ever run happens on a box where some artifact may legitimately be missing, and
|
||||
// refusing to snapshot then would mean refusing to update.
|
||||
func (st *Store) Save(srcDir string, names []string, commit, note string) (Snapshot, error) {
|
||||
ts := st.clock().UTC()
|
||||
snap := Snapshot{
|
||||
ID: ts.Format("20060102-150405"),
|
||||
CreatedAt: ts,
|
||||
Commit: commit,
|
||||
Note: note,
|
||||
}
|
||||
snap.dir = filepath.Join(st.Dir, snap.ID)
|
||||
if err := os.MkdirAll(snap.dir, 0o700); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("update: snapshot dir: %w", err)
|
||||
}
|
||||
for _, name := range names {
|
||||
src := filepath.Join(srcDir, name)
|
||||
fi, err := os.Stat(src)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return Snapshot{}, fmt.Errorf("update: snapshot %s: %w", name, err)
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return Snapshot{}, fmt.Errorf("update: snapshot %s: is a directory (only files are deployable artifacts)", name)
|
||||
}
|
||||
dst := filepath.Join(snap.dir, name)
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
sum, err := copyFile(src, dst, fi.Mode().Perm())
|
||||
if err != nil {
|
||||
return Snapshot{}, fmt.Errorf("update: snapshot %s: %w", name, err)
|
||||
}
|
||||
snap.Files = append(snap.Files, FileRec{Name: name, SHA256: sum, Mode: fi.Mode().Perm(), Size: fi.Size()})
|
||||
}
|
||||
if len(snap.Files) == 0 {
|
||||
os.RemoveAll(snap.dir)
|
||||
return Snapshot{}, fmt.Errorf("update: snapshot of %s is empty — none of the listed artifacts exist", srcDir)
|
||||
}
|
||||
// Manifest last: its presence is what makes the snapshot usable.
|
||||
blob, err := json.MarshalIndent(snap, "", " ")
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(snap.dir, manifestName), blob, 0o600); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("update: snapshot manifest: %w", err)
|
||||
}
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// List returns the complete snapshots, newest first.
|
||||
func (st *Store) List() ([]Snapshot, error) {
|
||||
ents, err := os.ReadDir(st.Dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var out []Snapshot
|
||||
for _, e := range ents {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
s, err := st.Load(e.Name())
|
||||
if err != nil {
|
||||
continue // aborted or hand-mangled: not a rollback target
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Load reads one snapshot's manifest.
|
||||
func (st *Store) Load(id string) (Snapshot, error) {
|
||||
dir := filepath.Join(st.Dir, id)
|
||||
blob, err := os.ReadFile(filepath.Join(dir, manifestName))
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
var s Snapshot
|
||||
if err := json.Unmarshal(blob, &s); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("update: manifest %s: %w", id, err)
|
||||
}
|
||||
s.dir = dir
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Restore copies a snapshot's files back over dstDir and verifies every write
|
||||
// against the manifest sum. Only the named files are touched; anything else in
|
||||
// dstDir is left alone.
|
||||
//
|
||||
// This is the function the whole package exists to be able to run. It uses the
|
||||
// filesystem and nothing else — no toolchain, no build, no cooperation from the
|
||||
// code being replaced.
|
||||
func (s Snapshot) Restore(dstDir string) error {
|
||||
if s.dir == "" {
|
||||
return errors.New("update: snapshot has no directory (load it through the store)")
|
||||
}
|
||||
for _, f := range s.Files {
|
||||
src := filepath.Join(s.dir, f.Name)
|
||||
sum, err := hashFile(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update: restore %s: %w", f.Name, err)
|
||||
}
|
||||
if sum != f.SHA256 {
|
||||
return fmt.Errorf("update: restore %s: snapshot is corrupt (sha256 %s, manifest says %s)", f.Name, sum, f.SHA256)
|
||||
}
|
||||
dst := filepath.Join(dstDir, f.Name)
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
got, err := copyFile(src, dst, f.Mode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update: restore %s: %w", f.Name, err)
|
||||
}
|
||||
if got != f.SHA256 {
|
||||
return fmt.Errorf("update: restore %s: wrote the wrong bytes (sha256 %s)", f.Name, got)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prune keeps the newest keep snapshots and removes the rest. The newest is
|
||||
// never pruned regardless of keep — it is the rollback target.
|
||||
func (st *Store) Prune(keep int) error {
|
||||
if keep < 1 {
|
||||
keep = 1
|
||||
}
|
||||
snaps, err := st.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range snaps[min(keep, len(snaps)):] {
|
||||
if err := os.RemoveAll(s.dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFile writes src to dst atomically (temp + rename, so a reader never sees a
|
||||
// half file and an interrupted copy leaves the old one intact) and returns the
|
||||
// sha256 of what was written.
|
||||
func copyFile(src, dst string, mode os.FileMode) (string, error) {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer in.Close()
|
||||
if mode == 0 {
|
||||
mode = 0o644
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(dst), ".update-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName) // no-op once the rename succeeds
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(io.MultiWriter(tmp, h), in); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
// fsync before the rename: a binary that is renamed into place but whose
|
||||
// bytes are still in the page cache is exactly the file a power cut turns
|
||||
// into an unbootable daemon.
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Chmod(mode); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmpName, dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func hashFile(p string) (string, error) {
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// Package update applies a new build of Maven to the box she runs on, with a
|
||||
// verified-before-committed install and an automatic rollback (Vikunja #249).
|
||||
//
|
||||
// # What this package refuses to be
|
||||
//
|
||||
// This is the highest-risk capability in the backlog — code that changes the
|
||||
// running system — so the refusals are as much of the design as the features,
|
||||
// and they are enforced here rather than described in a doc:
|
||||
//
|
||||
// - It is never automatic and never on a timer. There is no checker, no
|
||||
// channel, no "check for updates" call and nothing that fires from the tick
|
||||
// loop. Apply runs exactly when a human runs cmd/mavupdate on the box.
|
||||
// - The daemon cannot update itself. mavend does not import this package and
|
||||
// there is no IPC method and no web route that reaches it, so no act, no
|
||||
// intent, no tool and no LLM output can start an update. The trigger needs
|
||||
// shell access to the host, which is a strictly higher bar than the step-up
|
||||
// passkey gate that guards /tools — an update is not a thing to expose to
|
||||
// anything reachable over the network.
|
||||
// - It does not fetch code. Nothing here talks to a release server, a
|
||||
// registry, or GitHub. The new version is whatever is in the working tree
|
||||
// the operator points it at, which he pulled himself. Downloading and
|
||||
// running code on the strength of a checksum in the same download is not a
|
||||
// property we can verify on one box.
|
||||
// - It does not supervise its own death. The plan asked for an in-process
|
||||
// crash-loop detector; a process cannot reliably notice that it keeps
|
||||
// dying, and one that thinks it can is worse than nothing. Restart-on-crash
|
||||
// belongs to whatever starts mavend (compose `restart: unless-stopped`,
|
||||
// systemd `Restart=`). What this package guarantees instead is narrower and
|
||||
// real: within one Apply, the new build is proven to answer before the old
|
||||
// one is considered replaced, and if it does not answer the old bytes go
|
||||
// back and are proven to answer again.
|
||||
//
|
||||
// # The order of operations, and why
|
||||
//
|
||||
// Apply is: health-check the CURRENT daemon → build → test → snapshot → install
|
||||
// → restart → health-check → rollback on any failure.
|
||||
//
|
||||
// The first health check is not ceremony. If she is already not answering, a
|
||||
// failed update and a broken box are indistinguishable afterwards, and the
|
||||
// rollback has nothing to prove itself against — so Apply refuses to start.
|
||||
//
|
||||
// Build and test run BEFORE anything is written to the install dir, so a broken
|
||||
// tree costs nothing but time. Install is per-file write-temp-then-rename, so a
|
||||
// crash mid-install leaves whole files, not half ones.
|
||||
//
|
||||
// The rollback path deliberately depends on nothing that just changed: it copies
|
||||
// byte-for-byte from a snapshot taken before the install and re-runs the same
|
||||
// restart command. It does not ask the new binary to do anything, does not run
|
||||
// a migration, and does not need the update to have gotten far enough to leave
|
||||
// a working anything behind.
|
||||
//
|
||||
// # What is out of scope on purpose
|
||||
//
|
||||
// The database is not snapshotted or rolled back. It is encrypted, live, and
|
||||
// often larger than the disk headroom; a store rolled back under a schema that
|
||||
// already migrated forward loses writes silently, which is worse than a failed
|
||||
// update. Schema compatibility is store.Migrate's job. A snapshot here is the
|
||||
// deployable artifacts only: binaries and config.
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNotConfigured — no update block in the config. The capability does not
|
||||
// exist unless the operator described his own deployment.
|
||||
ErrNotConfigured = errors.New("update: not configured")
|
||||
|
||||
// ErrUnhealthyBefore — the daemon was already not answering when Apply
|
||||
// started. Refused: see the package comment.
|
||||
ErrUnhealthyBefore = errors.New("update: the running daemon is not healthy — refusing to update on top of a broken box")
|
||||
|
||||
// ErrVerifyFailed — build or test failed. Nothing was installed.
|
||||
ErrVerifyFailed = errors.New("update: verification failed")
|
||||
|
||||
// ErrRolledBack — the new build was installed and did not come up healthy,
|
||||
// so the previous snapshot was restored. Wraps the underlying failure.
|
||||
ErrRolledBack = errors.New("update: rolled back")
|
||||
|
||||
// ErrRollbackFailed — the worst case: the new build failed AND the restore
|
||||
// did not bring her back. The operator has to fix the box by hand; the
|
||||
// snapshot directory is named in the result so he knows what to copy.
|
||||
ErrRollbackFailed = errors.New("update: ROLLBACK FAILED — manual recovery required")
|
||||
)
|
||||
|
||||
// Config — the operator's description of his own deployment. Every path is
|
||||
// absolute and validated; nothing is guessed, because guessing wrong here means
|
||||
// overwriting the wrong file.
|
||||
type Config struct {
|
||||
// SourceDir — the git working tree to build. The operator pulls it himself;
|
||||
// this package never fetches.
|
||||
SourceDir string `json:"source_dir"`
|
||||
|
||||
// InstallDir — where the built binaries are copied to. On the docker
|
||||
// deployment this is the tree the image is built from, so it is usually the
|
||||
// same as SourceDir and Install is a no-op copy; on a bare-metal deployment
|
||||
// it is /opt/maven/bin.
|
||||
InstallDir string `json:"install_dir"`
|
||||
|
||||
// SnapshotDir — where the pre-install copies live. Must not be inside
|
||||
// InstallDir: a restore reading from a directory the install is writing to
|
||||
// is not a restore.
|
||||
SnapshotDir string `json:"snapshot_dir"`
|
||||
|
||||
// Binaries — the artifact names to snapshot and install, relative to
|
||||
// SourceDir (built) and InstallDir (deployed). Listed explicitly rather than
|
||||
// globbed so a stray file in the tree never gets deployed.
|
||||
Binaries []string `json:"binaries"`
|
||||
|
||||
// ConfigFiles — extra files to snapshot alongside the binaries, relative to
|
||||
// InstallDir. Snapshotted, never overwritten by an install: the operator's
|
||||
// config is not something an update gets to replace.
|
||||
ConfigFiles []string `json:"config_files,omitempty"`
|
||||
|
||||
// RestartCmd — how this deployment restarts mavend, e.g.
|
||||
// ["docker","compose","up","-d","--build","mavend"] or
|
||||
// ["systemctl","restart","mavend"]. Run in SourceDir. Required: there is no
|
||||
// portable default and picking one would mean restarting the wrong thing.
|
||||
RestartCmd []string `json:"restart_cmd"`
|
||||
|
||||
// HealthSocket — mavend's IPC socket, used to prove she answers after a
|
||||
// restart. Required: without a health check there is no signal to roll back
|
||||
// on, and an update that cannot detect its own failure is not what this
|
||||
// package is for.
|
||||
HealthSocket string `json:"health_socket"`
|
||||
|
||||
// HealthTimeoutSec — how long to wait for the restarted daemon to answer.
|
||||
// Default 90s; she loads a 1.7B on boot, so this is not a couple of seconds.
|
||||
HealthTimeoutSec int `json:"health_timeout_sec,omitempty"`
|
||||
|
||||
// VerifyTimeoutMin — cap on `make build` + `make test`. Default 20m.
|
||||
VerifyTimeoutMin int `json:"verify_timeout_min,omitempty"`
|
||||
|
||||
// KeepSnapshots — how many snapshots to retain. Default 5, minimum 1: the
|
||||
// most recent one is the rollback target and is never pruned.
|
||||
KeepSnapshots int `json:"keep_snapshots,omitempty"`
|
||||
}
|
||||
|
||||
// Validate — fail at startup, not halfway through an install.
|
||||
func (c Config) Validate() error {
|
||||
if c.SourceDir == "" || c.InstallDir == "" || c.SnapshotDir == "" {
|
||||
return errors.New("update: source_dir, install_dir and snapshot_dir are all required")
|
||||
}
|
||||
for _, p := range []string{c.SourceDir, c.InstallDir, c.SnapshotDir} {
|
||||
if !filepath.IsAbs(p) {
|
||||
return fmt.Errorf("update: %q must be an absolute path", p)
|
||||
}
|
||||
}
|
||||
if within(c.SnapshotDir, c.InstallDir) {
|
||||
return fmt.Errorf("update: snapshot_dir %q is inside install_dir %q — a restore must not read from what the install writes", c.SnapshotDir, c.InstallDir)
|
||||
}
|
||||
if len(c.Binaries) == 0 {
|
||||
return errors.New("update: binaries is empty — nothing to install")
|
||||
}
|
||||
for _, b := range append(append([]string{}, c.Binaries...), c.ConfigFiles...) {
|
||||
if filepath.IsAbs(b) || strings.Contains(b, "..") {
|
||||
return fmt.Errorf("update: %q must be a plain relative name", b)
|
||||
}
|
||||
}
|
||||
if len(c.RestartCmd) == 0 {
|
||||
return errors.New("update: restart_cmd is required — there is no safe default for restarting someone else's deployment")
|
||||
}
|
||||
if c.HealthSocket == "" {
|
||||
return errors.New("update: health_socket is required — an update that cannot check its own result cannot roll back on failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Config) withDefaults() Config {
|
||||
if c.HealthTimeoutSec <= 0 {
|
||||
c.HealthTimeoutSec = 90
|
||||
}
|
||||
if c.VerifyTimeoutMin <= 0 {
|
||||
c.VerifyTimeoutMin = 20
|
||||
}
|
||||
if c.KeepSnapshots < 1 {
|
||||
c.KeepSnapshots = 5
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c Config) healthTimeout() time.Duration {
|
||||
return time.Duration(c.HealthTimeoutSec) * time.Second
|
||||
}
|
||||
|
||||
func (c Config) verifyTimeout() time.Duration {
|
||||
return time.Duration(c.VerifyTimeoutMin) * time.Minute
|
||||
}
|
||||
|
||||
// within reports whether p is dir or lives under it.
|
||||
func within(p, dir string) bool {
|
||||
p, dir = filepath.Clean(p), filepath.Clean(dir)
|
||||
if p == dir {
|
||||
return true
|
||||
}
|
||||
rel, err := filepath.Rel(dir, p)
|
||||
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// Runner runs one command and returns its combined output. Injected so the
|
||||
// tests can drive build/test/restart failures without a toolchain, a container
|
||||
// or a real daemon to break.
|
||||
type Runner func(ctx context.Context, dir string, argv []string) (string, error)
|
||||
|
||||
// ExecRunner is the real one.
|
||||
func ExecRunner(ctx context.Context, dir string, argv []string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// HealthCheck proves the daemon at socket answers. Injected for the same reason
|
||||
// as Runner.
|
||||
type HealthCheck func(ctx context.Context, socket string) error
|
||||
|
||||
// Logger receives one line per step. The CLI prints these as they happen: an
|
||||
// update that goes quiet for four minutes during `make test` reads as a hang.
|
||||
type Logger func(format string, args ...any)
|
||||
|
||||
// Updater is the whole capability. Construct with New and call Apply or
|
||||
// Rollback; there is no background goroutine and nothing starts on its own.
|
||||
type Updater struct {
|
||||
cfg Config
|
||||
store *Store
|
||||
run Runner
|
||||
health HealthCheck
|
||||
log Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New builds an Updater. Every seam has a real default; the tests replace them.
|
||||
func New(cfg Config, opts ...Option) (*Updater, error) {
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := &Updater{
|
||||
cfg: cfg.withDefaults(),
|
||||
store: &Store{Dir: cfg.SnapshotDir},
|
||||
run: ExecRunner,
|
||||
health: DialHealth,
|
||||
log: func(string, ...any) {},
|
||||
now: time.Now,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(u)
|
||||
}
|
||||
u.store.now = u.now
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// Option — a constructor seam.
|
||||
type Option func(*Updater)
|
||||
|
||||
func WithRunner(r Runner) Option { return func(u *Updater) { u.run = r } }
|
||||
func WithHealth(h HealthCheck) Option { return func(u *Updater) { u.health = h } }
|
||||
func WithLogger(l Logger) Option { return func(u *Updater) { u.log = l } }
|
||||
func WithClock(f func() time.Time) Option {
|
||||
return func(u *Updater) { u.now = f }
|
||||
}
|
||||
|
||||
// Snapshots lists what is available to roll back to, newest first.
|
||||
func (u *Updater) Snapshots() ([]Snapshot, error) { return u.store.List() }
|
||||
@@ -0,0 +1,437 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The tests drive the whole orchestration against a fake box: a directory tree
|
||||
// standing in for the install dir, an injected Runner standing in for
|
||||
// make/git/docker, and an injected HealthCheck standing in for mavend. That is
|
||||
// what makes the failure paths — the ones that matter — testable at all: you
|
||||
// cannot ask a real deployment to fail its health check on demand, and the
|
||||
// rollback path is exactly the path nobody exercises by hand.
|
||||
|
||||
type fakeBox struct {
|
||||
t *testing.T
|
||||
root string
|
||||
|
||||
// what the fake `make build` writes into the source tree
|
||||
newBytes string
|
||||
// scripted failures
|
||||
buildErr error
|
||||
testErr error
|
||||
restartErr error
|
||||
|
||||
// health: fails until the Nth call, then follows healthy
|
||||
healthErrs int // remaining failures to serve
|
||||
healthy bool
|
||||
healthChecks int
|
||||
// deployedAtRestart records the installed bytes each time restart runs, so a
|
||||
// test can prove the rollback put the old bytes back BEFORE restarting.
|
||||
deployedAtRestart []string
|
||||
|
||||
ran []string
|
||||
}
|
||||
|
||||
func newFakeBox(t *testing.T) *fakeBox {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, d := range []string{"src", "install", "snapshots"} {
|
||||
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// The currently deployed build, and a config file next to it.
|
||||
write(t, filepath.Join(root, "install", "mavend"), "OLD-BUILD")
|
||||
write(t, filepath.Join(root, "install", "mavend.json"), `{"tick_interval":"60s"}`)
|
||||
// The source tree already contains a stale binary; `make build` overwrites it.
|
||||
write(t, filepath.Join(root, "src", "mavend"), "STALE")
|
||||
return &fakeBox{t: t, root: root, newBytes: "NEW-BUILD", healthy: true}
|
||||
}
|
||||
|
||||
func (b *fakeBox) cfg() Config {
|
||||
return Config{
|
||||
SourceDir: filepath.Join(b.root, "src"),
|
||||
InstallDir: filepath.Join(b.root, "install"),
|
||||
SnapshotDir: filepath.Join(b.root, "snapshots"),
|
||||
Binaries: []string{"mavend"},
|
||||
ConfigFiles: []string{"mavend.json"},
|
||||
RestartCmd: []string{"restart-the-thing"},
|
||||
HealthSocket: filepath.Join(b.root, "mavend.sock"),
|
||||
HealthTimeoutSec: 1,
|
||||
KeepSnapshots: 3,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *fakeBox) run(ctx context.Context, dir string, argv []string) (string, error) {
|
||||
b.ran = append(b.ran, strings.Join(argv, " "))
|
||||
switch strings.Join(argv, " ") {
|
||||
case "make build":
|
||||
if b.buildErr != nil {
|
||||
return "ld: undefined reference to everything", b.buildErr
|
||||
}
|
||||
// A real build writes its artifacts into the working tree — the behaviour
|
||||
// the snapshot-before-build ordering exists to survive.
|
||||
write(b.t, filepath.Join(b.root, "src", "mavend"), b.newBytes)
|
||||
return "built", nil
|
||||
case "make test":
|
||||
if b.testErr != nil {
|
||||
return "--- FAIL: TestSomething", b.testErr
|
||||
}
|
||||
return "ok", nil
|
||||
case "git rev-parse HEAD":
|
||||
return "cafebabecafebabecafebabecafebabecafebabe\n", nil
|
||||
case "restart-the-thing":
|
||||
b.deployedAtRestart = append(b.deployedAtRestart, read(b.t, filepath.Join(b.root, "install", "mavend")))
|
||||
if b.restartErr != nil {
|
||||
return "no such container", b.restartErr
|
||||
}
|
||||
return "restarted", nil
|
||||
}
|
||||
return "", errors.New("unexpected command: " + strings.Join(argv, " "))
|
||||
}
|
||||
|
||||
func (b *fakeBox) health(ctx context.Context, socket string) error {
|
||||
b.healthChecks++
|
||||
if b.healthErrs > 0 {
|
||||
b.healthErrs--
|
||||
return errors.New("connection refused")
|
||||
}
|
||||
if !b.healthy {
|
||||
return errors.New("she does not answer")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *fakeBox) updater(t *testing.T, extra ...Option) *Updater {
|
||||
t.Helper()
|
||||
opts := append([]Option{WithRunner(b.run), WithHealth(b.health)}, extra...)
|
||||
u, err := New(b.cfg(), opts...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (b *fakeBox) deployed() string { return read(b.t, filepath.Join(b.root, "install", "mavend")) }
|
||||
|
||||
func write(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func read(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestApply_HappyPath(t *testing.T) {
|
||||
b := newFakeBox(t)
|
||||
res, err := b.updater(t).Apply(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if !res.Verified || !res.Restarted || !res.Healthy || res.RolledBack {
|
||||
t.Fatalf("result = %+v; want verified+restarted+healthy and no rollback", res)
|
||||
}
|
||||
if got := b.deployed(); got != "NEW-BUILD" {
|
||||
t.Errorf("deployed binary = %q; want the new build", got)
|
||||
}
|
||||
// The order is the property: health, snapshot, build, test, install, restart.
|
||||
want := []string{"git rev-parse HEAD", "make build", "make test", "restart-the-thing"}
|
||||
if strings.Join(b.ran, "|") != strings.Join(want, "|") {
|
||||
t.Errorf("commands ran = %v; want %v", b.ran, want)
|
||||
}
|
||||
if res.SnapshotID == "" {
|
||||
t.Error("no snapshot was taken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_RefusesWhenSheIsAlreadyDown(t *testing.T) {
|
||||
// A box that is already broken has no baseline for the rollback to prove
|
||||
// itself against, so the update never starts.
|
||||
b := newFakeBox(t)
|
||||
b.healthy = false
|
||||
res, err := b.updater(t).Apply(context.Background())
|
||||
if !errors.Is(err, ErrUnhealthyBefore) {
|
||||
t.Fatalf("Apply on an unhealthy box = %v; want ErrUnhealthyBefore", err)
|
||||
}
|
||||
if len(b.ran) != 0 {
|
||||
t.Errorf("a refused update still ran %v", b.ran)
|
||||
}
|
||||
if res.SnapshotID != "" {
|
||||
t.Error("a refused update still took a snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_TestFailureDeploysNothingAndPutsTheTreeBack(t *testing.T) {
|
||||
b := newFakeBox(t)
|
||||
b.testErr = errors.New("exit status 1")
|
||||
res, err := b.updater(t).Apply(context.Background())
|
||||
if !errors.Is(err, ErrVerifyFailed) {
|
||||
t.Fatalf("Apply with failing tests = %v; want ErrVerifyFailed", err)
|
||||
}
|
||||
if res.Verified {
|
||||
t.Error("result claims verified after a failing test suite")
|
||||
}
|
||||
for _, c := range b.ran {
|
||||
if c == "restart-the-thing" {
|
||||
t.Fatal("a failed verification restarted the daemon")
|
||||
}
|
||||
}
|
||||
if got := b.deployed(); got != "OLD-BUILD" {
|
||||
t.Errorf("deployed binary = %q; want the old build untouched", got)
|
||||
}
|
||||
// The failing output is kept so the operator can see why.
|
||||
var found bool
|
||||
for _, s := range res.Steps {
|
||||
if s.Name == "test" && strings.Contains(s.Output, "FAIL") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("the failing test output was not retained")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_BuildFailureIsCaughtBeforeTheTests(t *testing.T) {
|
||||
b := newFakeBox(t)
|
||||
b.buildErr = errors.New("exit status 2")
|
||||
if _, err := b.updater(t).Apply(context.Background()); !errors.Is(err, ErrVerifyFailed) {
|
||||
t.Fatalf("Apply with a failing build = %v; want ErrVerifyFailed", err)
|
||||
}
|
||||
for _, c := range b.ran {
|
||||
if c == "make test" {
|
||||
t.Error("ran the test suite after the build failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_UnhealthyAfterRestartRollsBackToTheOldBytes(t *testing.T) {
|
||||
// The case the package exists for: everything verifies, the new build
|
||||
// installs, and then she does not come up.
|
||||
b := newFakeBox(t)
|
||||
b.healthErrs = 1 // the preflight check passes, then she stops answering
|
||||
b.healthy = false
|
||||
u := b.updater(t)
|
||||
// Once the rollback restores the old build, she answers again.
|
||||
restored := false
|
||||
u.health = func(ctx context.Context, socket string) error {
|
||||
b.healthChecks++
|
||||
if b.deployed() == "OLD-BUILD" && restored {
|
||||
return nil
|
||||
}
|
||||
if b.healthChecks == 1 {
|
||||
return nil // preflight: the old build is up
|
||||
}
|
||||
if b.deployed() == "OLD-BUILD" {
|
||||
restored = true
|
||||
return nil
|
||||
}
|
||||
return errors.New("she does not answer on the new build")
|
||||
}
|
||||
res, err := u.Apply(context.Background())
|
||||
if !errors.Is(err, ErrRolledBack) {
|
||||
t.Fatalf("Apply with a dead new build = %v; want ErrRolledBack", err)
|
||||
}
|
||||
if !res.RolledBack || !res.RollbackHealthy || res.Healthy {
|
||||
t.Fatalf("result = %+v; want rolled back and healthy again on the old build", res)
|
||||
}
|
||||
if got := b.deployed(); got != "OLD-BUILD" {
|
||||
t.Errorf("deployed binary after the rollback = %q; want OLD-BUILD", got)
|
||||
}
|
||||
// And the restore happened BEFORE the second restart, not after it.
|
||||
if len(b.deployedAtRestart) != 2 {
|
||||
t.Fatalf("restarts = %v; want two (the update and the rollback)", b.deployedAtRestart)
|
||||
}
|
||||
if b.deployedAtRestart[0] != "NEW-BUILD" || b.deployedAtRestart[1] != "OLD-BUILD" {
|
||||
t.Errorf("bytes in place at each restart = %v; want [NEW-BUILD OLD-BUILD]", b.deployedAtRestart)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_RestartFailureRollsBack(t *testing.T) {
|
||||
b := newFakeBox(t)
|
||||
b.restartErr = errors.New("exit status 1")
|
||||
res, err := b.updater(t).Apply(context.Background())
|
||||
// The rollback's own restart fails too, so this is the manual-recovery case —
|
||||
// and it says so instead of reporting a tidy rollback.
|
||||
if !errors.Is(err, ErrRollbackFailed) {
|
||||
t.Fatalf("Apply with a broken restart command = %v; want ErrRollbackFailed", err)
|
||||
}
|
||||
if !res.RolledBack || res.RollbackHealthy {
|
||||
t.Fatalf("result = %+v; want rolled back but not healthy", res)
|
||||
}
|
||||
if got := b.deployed(); got != "OLD-BUILD" {
|
||||
t.Errorf("deployed binary = %q; want the old bytes restored even so", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_RollbackNeedsNoBuildAndNoNewCode(t *testing.T) {
|
||||
// The rollback must not depend on the toolchain, the source tree, or the
|
||||
// code it is replacing. Prove it: delete the source tree's binary and make
|
||||
// every command except the restart fail, then roll back.
|
||||
b := newFakeBox(t)
|
||||
if _, err := b.updater(t).Apply(context.Background()); err != nil {
|
||||
t.Fatalf("setup Apply: %v", err)
|
||||
}
|
||||
if b.deployed() != "NEW-BUILD" {
|
||||
t.Fatal("setup did not deploy")
|
||||
}
|
||||
os.RemoveAll(filepath.Join(b.root, "src"))
|
||||
if err := os.MkdirAll(filepath.Join(b.root, "src"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.buildErr = errors.New("no toolchain here")
|
||||
b.testErr = errors.New("no toolchain here")
|
||||
b.ran = nil
|
||||
|
||||
res, err := b.updater(t).Rollback(context.Background(), "")
|
||||
if err != nil && !errors.Is(err, ErrRolledBack) {
|
||||
t.Fatalf("Rollback: %v", err)
|
||||
}
|
||||
if !res.RollbackHealthy {
|
||||
t.Fatalf("result = %+v; want a healthy rollback", res)
|
||||
}
|
||||
if got := b.deployed(); got != "OLD-BUILD" {
|
||||
t.Errorf("deployed binary = %q; want OLD-BUILD", got)
|
||||
}
|
||||
for _, c := range b.ran {
|
||||
if strings.HasPrefix(c, "make") {
|
||||
t.Errorf("the rollback ran %q — it must not need a build", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_ConfigIsSnapshottedButNeverOverwritten(t *testing.T) {
|
||||
b := newFakeBox(t)
|
||||
// A config in the source tree must not be deployed over the operator's.
|
||||
write(t, filepath.Join(b.root, "src", "mavend.json"), `{"tick_interval":"1s"}`)
|
||||
if _, err := b.updater(t).Apply(context.Background()); err != nil {
|
||||
t.Fatalf("Apply: %v", err)
|
||||
}
|
||||
if got := read(t, filepath.Join(b.root, "install", "mavend.json")); !strings.Contains(got, "60s") {
|
||||
t.Errorf("installed config = %q; an update must not replace his config", got)
|
||||
}
|
||||
snaps, err := b.updater(t).Snapshots()
|
||||
if err != nil || len(snaps) == 0 {
|
||||
t.Fatalf("Snapshots: %v %v", snaps, err)
|
||||
}
|
||||
var names []string
|
||||
for _, f := range snaps[0].Files {
|
||||
names = append(names, f.Name)
|
||||
}
|
||||
if len(names) != 2 {
|
||||
t.Errorf("snapshot files = %v; want the binary and the config", names)
|
||||
}
|
||||
if snaps[0].Commit == "" {
|
||||
t.Error("the snapshot did not record which commit produced it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollback_CorruptSnapshotIsRefusedNotRestored(t *testing.T) {
|
||||
b := newFakeBox(t)
|
||||
if _, err := b.updater(t).Apply(context.Background()); err != nil {
|
||||
t.Fatalf("setup Apply: %v", err)
|
||||
}
|
||||
snaps, _ := b.updater(t).Snapshots()
|
||||
// Something ate the snapshot. Restoring it would deploy garbage.
|
||||
write(t, filepath.Join(snaps[0].Dir(), "mavend"), "CORRUPT")
|
||||
_, err := b.updater(t).Rollback(context.Background(), snaps[0].ID)
|
||||
if !errors.Is(err, ErrRollbackFailed) || !strings.Contains(err.Error(), "corrupt") {
|
||||
t.Fatalf("Rollback of a corrupt snapshot = %v; want a refusal naming the corruption", err)
|
||||
}
|
||||
if got := b.deployed(); got != "NEW-BUILD" {
|
||||
t.Errorf("deployed binary = %q; a refused restore must change nothing", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollback_NoSnapshots(t *testing.T) {
|
||||
b := newFakeBox(t)
|
||||
if _, err := b.updater(t).Rollback(context.Background(), ""); err == nil {
|
||||
t.Error("Rollback with no snapshots succeeded; want an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrune_KeepsTheNewestAsTheRollbackTarget(t *testing.T) {
|
||||
b := newFakeBox(t)
|
||||
st := &Store{Dir: filepath.Join(b.root, "snapshots")}
|
||||
base := time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC)
|
||||
for i := 0; i < 4; i++ {
|
||||
i := i
|
||||
st.now = func() time.Time { return base.Add(time.Duration(i) * time.Minute) }
|
||||
if _, err := st.Save(filepath.Join(b.root, "install"), []string{"mavend"}, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := st.Prune(0); err != nil { // 0 is clamped to 1, never to zero
|
||||
t.Fatal(err)
|
||||
}
|
||||
snaps, err := st.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snaps) != 1 {
|
||||
t.Fatalf("kept %d snapshots; want 1", len(snaps))
|
||||
}
|
||||
if snaps[0].ID != "20260801-030300" {
|
||||
t.Errorf("kept %s; want the newest", snaps[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_IgnoresSnapshotsWithNoManifest(t *testing.T) {
|
||||
// An interrupted snapshot has files but no manifest. It must never be offered
|
||||
// as a rollback target — restoring a half-copied binary is the worst outcome
|
||||
// in the package.
|
||||
b := newFakeBox(t)
|
||||
dir := filepath.Join(b.root, "snapshots", "20260801-000000")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write(t, filepath.Join(dir, "mavend"), "HALF")
|
||||
snaps, err := (&Store{Dir: filepath.Join(b.root, "snapshots")}).List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(snaps) != 0 {
|
||||
t.Errorf("List returned %d snapshots; want none (no manifest)", len(snaps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidate(t *testing.T) {
|
||||
ok := (&fakeBox{root: t.TempDir()}).cfg()
|
||||
if err := ok.Validate(); err != nil {
|
||||
t.Fatalf("valid config rejected: %v", err)
|
||||
}
|
||||
bad := map[string]func(c Config) Config{
|
||||
"relative source": func(c Config) Config { c.SourceDir = "src"; return c },
|
||||
"no restart command": func(c Config) Config { c.RestartCmd = nil; return c },
|
||||
"no health socket": func(c Config) Config { c.HealthSocket = ""; return c },
|
||||
"no binaries": func(c Config) Config { c.Binaries = nil; return c },
|
||||
"escaping artifact name": func(c Config) Config { c.Binaries = []string{"../../etc/passwd"}; return c },
|
||||
"absolute artifact name": func(c Config) Config { c.Binaries = []string{"/usr/bin/mavend"}; return c },
|
||||
"snapshots inside install": func(c Config) Config { c.SnapshotDir = filepath.Join(c.InstallDir, "snaps"); return c },
|
||||
}
|
||||
for name, mutate := range bad {
|
||||
if err := mutate(ok).Validate(); err == nil {
|
||||
t.Errorf("%s was accepted; want a startup failure", name)
|
||||
}
|
||||
}
|
||||
// And New refuses an invalid config outright rather than half-configuring.
|
||||
if _, err := New(mutate(ok, "no health socket", bad)); err == nil {
|
||||
t.Error("New accepted a config with no health socket")
|
||||
}
|
||||
}
|
||||
|
||||
func mutate(c Config, key string, m map[string]func(Config) Config) Config { return m[key](c) }
|
||||
@@ -0,0 +1,65 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Verification is "does this tree build and does it pass its own tests", run
|
||||
// before a single byte is written to the install dir.
|
||||
//
|
||||
// It is `make build` and `make test`, not `go build`: the CGO daemons need the
|
||||
// vendored toolchain and the whisper/piper include and library paths wired
|
||||
// through the Makefile, and a bare `go build` on them fails in a way that has
|
||||
// nothing to do with the change being deployed. `make test` is the -race suite
|
||||
// with the CGO env set, and it is the only evidence available on a single box
|
||||
// that the new code does what the old code did.
|
||||
//
|
||||
// This is not a substitute for a second environment. A test suite that passes
|
||||
// says the code is self-consistent; it does not say the new build will start
|
||||
// against this machine's actual models, sockets and encrypted store. That is
|
||||
// what the post-restart health check is for, and it is why the install is
|
||||
// reversible rather than merely careful.
|
||||
|
||||
// Step — one verification or orchestration step and how it went. Kept so the CLI
|
||||
// can print a truthful account of what was done, including on the failure path.
|
||||
type Step struct {
|
||||
Name string
|
||||
Argv []string
|
||||
Took time.Duration
|
||||
Err error
|
||||
Output string // combined output, only retained for failures
|
||||
}
|
||||
|
||||
// Verify runs the build and the test suite in SourceDir.
|
||||
func (u *Updater) Verify(ctx context.Context) ([]Step, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, u.cfg.verifyTimeout())
|
||||
defer cancel()
|
||||
var steps []Step
|
||||
for _, argv := range [][]string{{"make", "build"}, {"make", "test"}} {
|
||||
u.log("verify: %v (this takes a while)", argv)
|
||||
start := u.now()
|
||||
out, err := u.run(ctx, u.cfg.SourceDir, argv)
|
||||
st := Step{Name: argv[len(argv)-1], Argv: argv, Took: u.now().Sub(start), Err: err}
|
||||
if err != nil {
|
||||
st.Output = tail(out, 4000)
|
||||
}
|
||||
steps = append(steps, st)
|
||||
if err != nil {
|
||||
u.log("verify: %v FAILED after %s", argv, st.Took.Round(time.Second))
|
||||
return steps, fmt.Errorf("%w: %v: %v", ErrVerifyFailed, argv, err)
|
||||
}
|
||||
u.log("verify: %v ok in %s", argv, st.Took.Round(time.Second))
|
||||
}
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// tail keeps the last n bytes — a failing `make test` prints far more than is
|
||||
// useful, and the failure is always at the end.
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
package webfetch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -92,6 +93,9 @@ type Response struct {
|
||||
Status int
|
||||
ContentType string
|
||||
Body []byte
|
||||
// Header — the response headers, one value each (the first). Populated for
|
||||
// every request; MCP needs Mcp-Session-Id, nothing else reads it.
|
||||
Header map[string]string
|
||||
}
|
||||
|
||||
// Fetcher performs guarded GETs. Safe for concurrent use; the per-host rate
|
||||
@@ -159,6 +163,37 @@ func New(cfg Config) *Fetcher {
|
||||
// Get fetches rawURL. The body is capped: a larger response is an error, not a
|
||||
// truncation, because half an XML document is worse than none.
|
||||
func (f *Fetcher) Get(ctx context.Context, rawURL string) (*Response, error) {
|
||||
return f.do(ctx, http.MethodGet, rawURL, nil, nil)
|
||||
}
|
||||
|
||||
// Post sends body to rawURL and returns the reply, under exactly the same
|
||||
// guards as Get: scheme rule, host lists, the dialer's private-address check on
|
||||
// every hop, the size cap and the per-host rate limit.
|
||||
//
|
||||
// It exists for JSON-RPC over HTTP (internal/mcp), which cannot be expressed as
|
||||
// a GET. That an outbound request now carries a body does not widen the
|
||||
// address policy one bit — a POST to the LAN is refused for the same reason a
|
||||
// GET is, unless AllowPrivate was set for that specific fetcher.
|
||||
//
|
||||
// hdr is merged over the defaults; a caller may not override User-Agent or
|
||||
// Accept-Encoding, because identity encoding and an honest UA are part of the
|
||||
// contract with whatever is on the other end.
|
||||
func (f *Fetcher) Post(ctx context.Context, rawURL, contentType string, body []byte, hdr map[string]string) (*Response, error) {
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
if hdr == nil {
|
||||
hdr = map[string]string{}
|
||||
}
|
||||
merged := make(map[string]string, len(hdr)+1)
|
||||
for k, v := range hdr {
|
||||
merged[k] = v
|
||||
}
|
||||
merged["Content-Type"] = contentType
|
||||
return f.do(ctx, http.MethodPost, rawURL, body, merged)
|
||||
}
|
||||
|
||||
func (f *Fetcher) do(ctx context.Context, method, rawURL string, body []byte, hdr map[string]string) (*Response, error) {
|
||||
u, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webfetch: bad url %q: %w", rawURL, err)
|
||||
@@ -170,10 +205,17 @@ func (f *Fetcher) Get(ctx context.Context, rawURL string) (*Response, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
rdr = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u.String(), rdr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", f.cfg.UserAgent)
|
||||
req.Header.Set("Accept-Encoding", "identity")
|
||||
|
||||
@@ -190,22 +232,27 @@ func (f *Fetcher) Get(ctx context.Context, rawURL string) (*Response, error) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, f.cfg.MaxBytes+1))
|
||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, f.cfg.MaxBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(body)) > f.cfg.MaxBytes {
|
||||
if int64(len(respBody)) > f.cfg.MaxBytes {
|
||||
return nil, fmt.Errorf("%w (%d bytes)", ErrTooLarge, f.cfg.MaxBytes)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return nil, fmt.Errorf("%w: %d", ErrStatus, resp.StatusCode)
|
||||
}
|
||||
return &Response{
|
||||
out := &Response{
|
||||
URL: resp.Request.URL.String(),
|
||||
Status: resp.StatusCode,
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
Body: body,
|
||||
}, nil
|
||||
Body: respBody,
|
||||
Header: map[string]string{},
|
||||
}
|
||||
for k := range resp.Header {
|
||||
out.Header[k] = resp.Header.Get(k)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// checkURL applies the scheme rule and the host lists. The address rule is the
|
||||
|
||||
@@ -3,6 +3,7 @@ package webfetch
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -225,3 +226,74 @@ func TestUserAgentIsSent(t *testing.T) {
|
||||
t.Fatalf("user-agent = %q", ua)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostSendsBodyAndHeaders(t *testing.T) {
|
||||
type seen struct {
|
||||
method, ctype, accept, ua, custom string
|
||||
body []byte
|
||||
}
|
||||
ch := make(chan seen, 1)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
ch <- seen{r.Method, r.Header.Get("Content-Type"), r.Header.Get("Accept"),
|
||||
r.Header.Get("User-Agent"), r.Header.Get("X-Thing"), b}
|
||||
w.Header().Set("Mcp-Session-Id", "sess-9")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
f := testFetcher(t, Config{UserAgent: "Maven/test"})
|
||||
resp, err := f.Post(context.Background(), srv.URL, "application/json",
|
||||
[]byte(`{"jsonrpc":"2.0"}`), map[string]string{"Accept": "text/event-stream", "X-Thing": "1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(resp.Body) != `{"ok":true}` {
|
||||
t.Fatalf("body = %q", resp.Body)
|
||||
}
|
||||
if resp.Header["Mcp-Session-Id"] != "sess-9" {
|
||||
t.Fatalf("response headers not surfaced: %+v", resp.Header)
|
||||
}
|
||||
s := <-ch
|
||||
if s.method != http.MethodPost {
|
||||
t.Fatalf("method = %s", s.method)
|
||||
}
|
||||
if string(s.body) != `{"jsonrpc":"2.0"}` {
|
||||
t.Fatalf("request body = %q", s.body)
|
||||
}
|
||||
if s.ctype != "application/json" {
|
||||
t.Fatalf("content-type = %q", s.ctype)
|
||||
}
|
||||
if s.accept != "text/event-stream" || s.custom != "1" {
|
||||
t.Fatalf("caller headers dropped: %+v", s)
|
||||
}
|
||||
if s.ua != "Maven/test" {
|
||||
t.Fatalf("user-agent = %q — a caller must not be able to override it", s.ua)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of routing MCP through webfetch: a POST is guarded exactly
|
||||
// like a GET. A body does not buy a caller a way onto the LAN.
|
||||
func TestPostRefusesPrivateAddress(t *testing.T) {
|
||||
f := New(Config{}) // no AllowPrivate
|
||||
_, err := f.Post(context.Background(), "http://127.0.0.1:9100/mcp", "application/json", []byte(`{}`), nil)
|
||||
if !errors.Is(err, ErrPrivate) {
|
||||
t.Fatalf("error = %v, want ErrPrivate", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostRefusesNonHTTPScheme(t *testing.T) {
|
||||
f := New(Config{})
|
||||
if _, err := f.Post(context.Background(), "file:///etc/passwd", "application/json", nil, nil); !errors.Is(err, ErrScheme) {
|
||||
t.Fatalf("error = %v, want ErrScheme", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostObeysDenylist(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer srv.Close()
|
||||
f := testFetcher(t, Config{DenyHosts: []string{"127.0.0.1"}})
|
||||
if _, err := f.Post(context.Background(), srv.URL, "application/json", []byte(`{}`), nil); !errors.Is(err, ErrBlocked) {
|
||||
t.Fatalf("error = %v, want ErrBlocked", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user