Version, authenticate and fully trace ecosystem calls #84

Merged
claude merged 135 commits from overnight/eco-versioned-traces into master 2026-08-01 14:50:26 +02:00
7 changed files with 479 additions and 29 deletions
Showing only changes of commit da62a2f25e - Show all commits
+125 -20
View File
@@ -13,8 +13,11 @@ import (
"github.com/kami/maven/internal/webfetch"
)
// mcpRefreshInterval — how often the manager re-dials a server that is down.
// The manager applies its own backoff on top, so this being short is cheap.
// mcpRefreshInterval — how often the manager is asked to re-dial servers that
// are down. It is a tick, not a retry rate: mcp.Manager holds a per-server
// backoff that starts at DefaultReconnectEvery and doubles to
// MaxReconnectEvery, so a permanently misconfigured stdio server is not
// re-exec'd once a minute forever.
const mcpRefreshInterval = time.Minute
// mcpWiring — the MCP client, when the `mcp` block configures at least one
@@ -29,9 +32,16 @@ type mcpWiring struct {
st *store.Store
}
// wireMCP builds the manager, connects, and proposes what it found. It never
// fails the daemon: a server that is unreachable at boot is logged and retried,
// because Maven starting is not contingent on someone else's process.
// wireMCP builds the manager. It does NOT dial: run does that, on its own
// goroutine, which is what makes "Maven starting is not contingent on someone
// else's process" true rather than merely intended.
//
// Dialing here used to be synchronous with a 30s budget, from wireVoice, from
// run. Connect dials serially and each HTTP dial is three requests against
// that server's timeout, so one black-holed endpoint cost 15s of boot and two
// cost the whole budget. On the passkey path wireVoice runs inside the unlock
// handler, so it delayed the answer to an unlock as well. Not failing and not
// blocking are different properties and only the first one held.
func wireMCP(cfg *config.Config, st *store.Store) *mcpWiring {
servers := cfg.MCPServers()
if len(servers) == 0 {
@@ -43,6 +53,7 @@ func wireMCP(cfg *config.Config, st *store.Store) *mcpWiring {
limits.DenyHosts = cfg.MCP.DenyHosts
limits.MaxBytes = cfg.MCP.MaxBytes
limits.Timeout = time.Duration(cfg.MCP.Timeout)
limits.HostInterval = time.Duration(cfg.MCP.HostInterval)
}
mgr, err := mcp.NewManager(mcp.WebfetchDoor(limits), servers)
if err != nil {
@@ -52,30 +63,58 @@ func wireMCP(cfg *config.Config, st *store.Store) *mcpWiring {
log.Printf("mcp: not wired: %v", err)
return nil
}
w := &mcpWiring{mgr: mgr, st: st}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
mgr.Connect(ctx)
w.propose(ctx)
return w
return &mcpWiring{mgr: mgr, st: st}
}
// propose writes a 'proposed' allowlist row for every discovered tool. It does
// NOT enable anything: a configured server is a place Maven may look, not a
// capability she has. Kami enables what he wants on /tools, behind step-up,
// which is the same gate a shell tool goes through.
// connect dials every server and reconciles what came back. Called from run,
// under the daemon's context, so a shutdown during a slow dial is observed.
func (w *mcpWiring) connect(ctx context.Context) {
if w == nil {
return
}
w.mgr.Connect(ctx)
w.propose(ctx)
}
// propose writes a 'proposed' allowlist row for every discovered tool, and
// reconciles the rows that already exist against what the server offers today.
// It does NOT enable anything: a configured server is a place Maven may look,
// not a capability she has. Kami enables what he wants on /tools, behind
// step-up, which is the same gate a shell tool goes through.
//
// Re-running on every boot is idempotent — ProposeMCPTool never touches an
// existing row, so a tool he disabled stays disabled and one he enabled keeps
// the cmd he enabled it with.
// Three things happen per discovered tool.
//
// A name not in the store becomes a proposal, carrying the tool's fingerprint.
//
// A name already in the store is reconciled against that fingerprint. A tool
// whose description, schema or readOnlyHint changed since it was approved drops
// back to 'proposed' and, if it stopped claiming read-only, to destructive=1.
// Insert-or-skip was not enough on its own: the cmd is a late-bound reference
// to a name the far end owns, so the server can redefine list_tasks into
// something that writes without the row changing at all.
//
// A row whose server is connected and no longer offers the tool is withdrawn.
func (w *mcpWiring) propose(ctx context.Context) {
if w == nil {
return
}
now := time.Now()
fresh := 0
fresh, changed := 0, 0
seen := map[string]string{} // local name → "server/tool", for collisions
for _, t := range w.mgr.Tools() {
name := mcp.LocalName(t.Server, t.Name)
remote := t.Server + "/" + t.Name
// Two different tools can flatten to one local name: server "vik" with
// tool "list_tasks" and server "vik_list" with tool "tasks" both give
// "vik_list_tasks". The store keys rows by name, so the second would
// land on the first one's row. Config-controlled and therefore rare,
// but silently reusing a row is the wrong way to lose that race.
if prev, dup := seen[name]; dup {
log.Printf("mcp: %s and %s both map to the allowlist name %q — skipping the second, rename a server",
prev, remote, name)
continue
}
seen[name] = remote
// No readOnlyHint ⇒ assume it mutates ⇒ the confirm turn. Being wrong
// in this direction only costs a question.
destructive := !t.ReadOnly
@@ -83,19 +122,82 @@ func (w *mcpWiring) propose(ctx context.Context) {
if t.Description != "" {
provenance += ": " + t.Description
}
fp := mcp.Fingerprint(t)
ok, err := w.st.ProposeMCPTool(ctx, name, mcp.Scope(t.Server),
mcp.Cmd(t.Server, t.Name), destructive, provenance, now)
mcp.Cmd(t.Server, t.Name), destructive, provenance, fp, now)
if err != nil {
log.Printf("mcp: propose %s: %v", name, err)
continue
}
if ok {
fresh++
continue
}
// The row already existed. Its provenance is whatever the server said
// the first time; reconciling rewrites it, so what /tools shows is what
// the server says now.
ch, err := w.st.ReconcileMCPTool(ctx, name, fp, destructive, provenance, now)
if err != nil {
log.Printf("mcp: reconcile %s: %v", name, err)
continue
}
if !ch.Changed {
continue
}
changed++
switch {
case ch.Demoted && ch.Escalated:
log.Printf("mcp: %s changed on the server and no longer claims read-only — disabled and marked destructive, re-approve it on /tools", name)
case ch.Demoted:
log.Printf("mcp: %s changed on the server since it was enabled — disabled, re-approve it on /tools", name)
default:
log.Printf("mcp: %s changed on the server; the proposal now shows the new description", name)
}
}
w.withdrawGone(ctx, seen, now)
if fresh > 0 {
log.Printf("mcp: %d new tool proposal(s) waiting on /tools", fresh)
}
if changed > 0 {
log.Printf("mcp: %d tool(s) changed since approval and need another look", changed)
}
}
// withdrawGone disarms rows whose tool the server stopped offering. Only
// servers that are CONNECTED are considered: a tool missing because its server
// is down is not a tool that was withdrawn, and disabling a capability every
// time a process restarts would be worse than the problem.
func (w *mcpWiring) withdrawGone(ctx context.Context, seen map[string]string, now time.Time) {
live := map[string]bool{}
for _, name := range w.mgr.Connected() {
live[name] = true
}
if len(live) == 0 {
return
}
rows, err := w.st.ListTools(ctx, "")
if err != nil {
log.Printf("mcp: list tools: %v", err)
return
}
for _, row := range rows {
server, remote, ok := mcp.ParseCmd(row.Cmd)
if !ok || !live[server] {
continue
}
if _, still := seen[row.Name]; still {
continue
}
note := fmt.Sprintf("mcp %s/%s: no longer offered by the server", server, remote)
wasEnabled, err := w.st.WithdrawTool(ctx, row.Name, note, now)
if err != nil {
log.Printf("mcp: withdraw %s: %v", row.Name, err)
continue
}
if wasEnabled {
log.Printf("mcp: %s was enabled but %s no longer offers it — disabled", row.Name, server)
}
}
}
// run re-dials downed servers and picks up tools that appeared, until ctx is
@@ -104,6 +206,9 @@ func (w *mcpWiring) run(ctx context.Context) {
if w == nil {
return
}
// The first dial happens here rather than at wiring time, so boot never
// waits on someone else's process.
w.connect(ctx)
t := time.NewTicker(mcpRefreshInterval)
defer t.Stop()
for {
+19
View File
@@ -31,6 +31,23 @@ func TestWireMCPOffWhenUnconfigured(t *testing.T) {
}
}
// Wiring must not dial. Boot used to block for the whole per-server timeout
// budget on a black-holed endpoint, and on the passkey path that delay landed
// inside the unlock handler.
func TestWireMCPDoesNotDial(t *testing.T) {
st := newTestStore(t)
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
Name: "dead", Command: "/nonexistent/mcp-server", Enabled: true,
}}}}, st)
if w == nil {
t.Fatal("a configured server should wire")
}
defer w.close()
if s := w.status(); len(s) != 1 || s[0].Err != "" {
t.Fatalf("wireMCP dialled: %+v", s)
}
}
// An unreachable server must not stop the daemon, must be reported as down, and
// must propose nothing.
func TestWireMCPUnreachableServerIsNotFatal(t *testing.T) {
@@ -42,6 +59,7 @@ func TestWireMCPUnreachableServerIsNotFatal(t *testing.T) {
t.Fatal("a configured server should still wire")
}
defer w.close()
w.connect(context.Background())
st2 := w.status()
if len(st2) != 1 || st2[0].Connected || st2[0].Err == "" {
t.Fatalf("status = %+v", st2)
@@ -67,6 +85,7 @@ func TestWireMCPPrivateURLRefusedWithoutAllowPrivate(t *testing.T) {
t.Fatal("should wire")
}
defer w.close()
w.connect(context.Background())
s := w.status()[0]
if s.Connected {
t.Fatal("a loopback server must not connect without allow_private")
+37
View File
@@ -1,7 +1,11 @@
package mcp
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"regexp"
"strconv"
"strings"
)
@@ -59,3 +63,36 @@ func LocalName(server, tool string) string {
// 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 }
// Fingerprint is the declared shape of a discovered tool: its name, its
// description, its input schema and its readOnlyHint, hashed.
//
// It exists because an allowlist row cannot pin an MCP tool's behaviour. The
// row's cmd is ["mcp", server, tool], a reference to a name the REMOTE server
// owns and may redefine — the row does not have to change for the tool to
// become something else. The fingerprint is what Kami actually approved, so a
// later discovery can tell "same tool" from "same name".
//
// The schema is canonicalised through a decode and re-encode, so a server that
// reorders its JSON keys or changes its whitespace does not read as a
// redefinition. Unparseable schema bytes are hashed as they arrived.
func Fingerprint(t Tool) string {
schema := ""
if len(t.InputSchema) > 0 {
var any any
if json.Unmarshal(t.InputSchema, &any) == nil {
if raw, err := json.Marshal(any); err == nil {
schema = string(raw)
}
}
if schema == "" {
schema = string(t.InputSchema)
}
}
h := sha256.New()
for _, part := range []string{t.Name, t.Description, schema, strconv.FormatBool(t.ReadOnly)} {
h.Write([]byte(part))
h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))
}
+32
View File
@@ -0,0 +1,32 @@
package mcp
import (
"encoding/json"
"testing"
)
// The fingerprint must cover everything the approval was given for, and must
// not move when only the JSON spelling of the schema does.
func TestFingerprintCoversTheDeclaredShape(t *testing.T) {
base := Tool{Name: "list_tasks", Description: "list them", ReadOnly: true,
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`)}
same := base
same.InputSchema = json.RawMessage("{\n \"properties\": {},\n \"type\": \"object\"\n}")
if Fingerprint(base) != Fingerprint(same) {
t.Error("reformatting the schema must not read as a redefinition")
}
for name, mut := range map[string]func(*Tool){
"description": func(x *Tool) { x.Description = "delete them" },
"schema": func(x *Tool) { x.InputSchema = json.RawMessage(`{"required":["id"]}`) },
"readonly": func(x *Tool) { x.ReadOnly = false },
"name": func(x *Tool) { x.Name = "delete_tasks" },
} {
t.Run(name, func(t *testing.T) {
got := base
mut(&got)
if Fingerprint(got) == Fingerprint(base) {
t.Error("a redefinition must change the fingerprint")
}
})
}
}
+9
View File
@@ -160,6 +160,15 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_live_norm ON tasks (norm) WHERE status IN ('candidate','open');
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status, created_ts DESC);`,
`ALTER TABLE tools ADD COLUMN fingerprint TEXT NOT NULL DEFAULT '';`,
// #15 — what a discovered tool WAS when it was approved (Vikunja #251).
// An MCP row's cmd is ["mcp", server, tool], which is a late-bound
// reference: it names a tool on a server the remote end owns and it pins
// no behaviour at all. A server upgraded, or taken over, can redefine
// list_tasks into something that writes without the row changing by one
// byte. The fingerprint is the declared shape at approval time, so a
// redefinition is a re-approval instead of a silent upgrade.
}
// migrate applies every migration with a number greater than the DB's current
+118 -5
View File
@@ -65,7 +65,16 @@ func (s *Store) ProposeTool(ctx context.Context, name, utterance, scope string,
// Like ProposeTool it never touches an existing row, so re-discovery on every
// restart is idempotent and cannot silently re-arm a tool that was disabled or
// change the cmd of one already enabled.
func (s *Store) ProposeMCPTool(ctx context.Context, name, scope string, cmd []string, destructive bool, utterance string, ts time.Time) (bool, error) {
//
// The row is NOT what protects him, and it is worth being exact about that.
// cmd is ["mcp", server, tool]: a late-bound reference to a name the remote
// server owns. The tool it points at can be redefined on the far end without
// the row changing at all, so "the cmd cannot change" is true and beside the
// point. fingerprint is what closes that: it records the declared shape (name,
// description, input schema, readOnlyHint) at the time the proposal was
// written, and ReconcileMCPTool compares against it on every later discovery.
// Pass "" for a row with nothing to fingerprint (a Home Assistant device).
func (s *Store) ProposeMCPTool(ctx context.Context, name, scope string, cmd []string, destructive bool, utterance, fingerprint string, ts time.Time) (bool, error) {
if len(cmd) == 0 {
return false, ErrToolCmd
}
@@ -81,10 +90,10 @@ func (s *Store) ProposeMCPTool(ctx context.Context, name, scope string, cmd []st
d = 1
}
res, err := s.db.ExecContext(ctx, `
INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts)
VALUES (?, ?, ?, ?, 'proposed', ?, ?, ?)
INSERT INTO tools (name, scope, cmd, destructive, status, utterance, fingerprint, created_ts, updated_ts)
VALUES (?, ?, ?, ?, 'proposed', ?, ?, ?, ?)
ON CONFLICT(name) DO NOTHING`,
name, scope, string(raw), d, utterance, ts.UnixMilli(), ts.UnixMilli())
name, scope, string(raw), d, utterance, fingerprint, ts.UnixMilli(), ts.UnixMilli())
if err != nil {
return false, fmt.Errorf("propose mcp tool: %w", err)
}
@@ -105,7 +114,111 @@ func (s *Store) ProposeMCPTool(ctx context.Context, name, scope string, cmd []st
// turn. Re-discovery on every refresh is idempotent — an existing row is never
// touched, so a device he disabled stays disabled.
func (s *Store) ProposeSmartHomeTool(ctx context.Context, name, scope string, cmd []string, utterance string, ts time.Time) (bool, error) {
return s.ProposeMCPTool(ctx, name, scope, cmd, true, utterance, ts)
return s.ProposeMCPTool(ctx, name, scope, cmd, true, utterance, "", ts)
}
// ToolChange — what ReconcileMCPTool did to an existing row.
type ToolChange struct {
// Changed — the discovered shape differs from the approved one.
Changed bool
// Demoted — the row was enabled and is now 'proposed' again, so the
// capability is off until a human looks at it a second time.
Demoted bool
// Escalated — destructive went from 0 to 1. It never goes the other way.
Escalated bool
}
// ReconcileMCPTool compares a freshly discovered tool against the row that was
// approved, and escalates when they disagree.
//
// The failure this exists for: day 1 the server offers list_tasks with
// readOnlyHint true, so the row is proposed non-destructive and Kami enables
// it. Day 30 the server is upgraded, or taken over, and list_tasks now writes.
// Insert-or-skip does nothing on that discovery — the row is still enabled,
// still destructive=0 — and the confirm turn never fires, because the flag was
// frozen against a claim the server has since withdrawn.
//
// So: a differing fingerprint drops the row back to 'proposed' and rewrites the
// provenance, and a tool that stopped claiming read-only gets destructive=1.
// destructive is only ever raised, never lowered: relaxing it on the say-so of
// the same server that changed underneath us would undo the point.
//
// A row with an empty stored fingerprint predates this and simply adopts the
// discovered one — an upgrade is not a redefinition.
func (s *Store) ReconcileMCPTool(ctx context.Context, name, fingerprint string, destructive bool, utterance string, ts time.Time) (ToolChange, error) {
var (
stored string
status string
wasDest int
)
err := s.db.QueryRowContext(ctx,
`SELECT fingerprint, status, destructive FROM tools WHERE name = ?`, name).
Scan(&stored, &status, &wasDest)
if errors.Is(err, sql.ErrNoRows) {
return ToolChange{}, ErrToolNotFound
}
if err != nil {
return ToolChange{}, fmt.Errorf("reconcile mcp tool: %w", err)
}
var ch ToolChange
if stored == "" {
if _, err := s.db.ExecContext(ctx,
`UPDATE tools SET fingerprint = ?, updated_ts = ? WHERE name = ?`,
fingerprint, ts.UnixMilli(), name); err != nil {
return ToolChange{}, fmt.Errorf("reconcile mcp tool: %w", err)
}
return ch, nil
}
if stored == fingerprint {
return ch, nil
}
ch.Changed = true
ch.Demoted = status == "enabled"
d := wasDest
if destructive && wasDest == 0 {
d, ch.Escalated = 1, true
}
if _, err := s.db.ExecContext(ctx, `
UPDATE tools
SET fingerprint = ?, destructive = ?, status = 'proposed', utterance = ?, updated_ts = ?
WHERE name = ?`,
fingerprint, d, utterance, ts.UnixMilli(), name); err != nil {
return ToolChange{}, fmt.Errorf("reconcile mcp tool: %w", err)
}
return ch, nil
}
// WithdrawTool disarms a row whose remote tool no longer exists: it drops back
// to 'proposed' and its provenance says why.
//
// Nothing else retracted a proposal, so a tool a server stopped offering kept
// its row forever, and an ENABLED one stayed enabled and failed at call time
// with an internal string the act path does not match. /tools is where he would
// go to find out and it was the one place that did not say. Returns whether the
// row was still enabled.
func (s *Store) WithdrawTool(ctx context.Context, name, utterance string, ts time.Time) (bool, error) {
res, err := s.db.ExecContext(ctx, `
UPDATE tools SET status = 'proposed', utterance = ?, updated_ts = ?
WHERE name = ? AND status = 'enabled'`,
utterance, ts.UnixMilli(), name)
if err != nil {
return false, fmt.Errorf("withdraw tool: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return false, fmt.Errorf("withdraw tool: rows affected: %w", err)
}
if n > 0 {
return true, nil
}
// Not enabled: still refresh the provenance so the proposed row says it.
_, err = s.db.ExecContext(ctx,
`UPDATE tools SET utterance = ?, updated_ts = ? WHERE name = ?`,
utterance, ts.UnixMilli(), name)
if err != nil {
return false, fmt.Errorf("withdraw tool: %w", err)
}
return false, nil
}
// EnableTool fills cmd + destructive and flips status to 'enabled'. This is the
+139 -4
View File
@@ -70,7 +70,7 @@ func TestProposeMCPTool(t *testing.T) {
now := time.Now()
cmd := []string{"mcp", "vikunja", "list_tasks"}
fresh, err := s.ProposeMCPTool(ctx, "vikunja_list_tasks", "mcp:vikunja", cmd, false, "mcp vikunja/list_tasks: List tasks", now)
fresh, err := s.ProposeMCPTool(ctx, "vikunja_list_tasks", "mcp:vikunja", cmd, false, "mcp vikunja/list_tasks: List tasks", "fp1", now)
if err != nil {
t.Fatal(err)
}
@@ -92,7 +92,7 @@ func TestProposeMCPTool(t *testing.T) {
}
// Re-discovery on the next boot is idempotent.
fresh, err = s.ProposeMCPTool(ctx, "vikunja_list_tasks", "mcp:vikunja", cmd, true, "changed", now)
fresh, err = s.ProposeMCPTool(ctx, "vikunja_list_tasks", "mcp:vikunja", cmd, true, "changed", "fp1", now)
if err != nil {
t.Fatal(err)
}
@@ -104,7 +104,7 @@ func TestProposeMCPTool(t *testing.T) {
if err := s.EnableTool(ctx, "vikunja_list_tasks", cmd, false, "mcp:vikunja", now); err != nil {
t.Fatal(err)
}
if _, err := s.ProposeMCPTool(ctx, "vikunja_list_tasks", "mcp:vikunja", []string{"mcp", "vikunja", "delete_task"}, true, "x", now); err != nil {
if _, err := s.ProposeMCPTool(ctx, "vikunja_list_tasks", "mcp:vikunja", []string{"mcp", "vikunja", "delete_task"}, true, "x", "fp2", now); err != nil {
t.Fatal(err)
}
got, err = s.LookupTool(ctx, "vikunja_list_tasks")
@@ -118,7 +118,142 @@ func TestProposeMCPTool(t *testing.T) {
func TestProposeMCPToolNeedsCmd(t *testing.T) {
s := newTestStore(t)
if _, err := s.ProposeMCPTool(context.Background(), "x", "mcp:y", nil, false, "", time.Now()); !errors.Is(err, ErrToolCmd) {
if _, err := s.ProposeMCPTool(context.Background(), "x", "mcp:y", nil, false, "", "", time.Now()); !errors.Is(err, ErrToolCmd) {
t.Fatalf("err = %v, want ErrToolCmd", err)
}
}
// A server that redefines a tool Kami already approved must have to ask again.
// The row stores cmd ["mcp", server, tool], a late-bound reference to a name
// the far end owns, so before the fingerprint a server could turn an enabled
// read-only list_tasks into something that writes and Maven would keep running
// it without a confirm turn.
func TestReconcileMCPToolDemotesARedefinedTool(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now()
cmd := []string{"mcp", "vikunja", "list_tasks"}
if _, err := s.ProposeMCPTool(ctx, "vikunja_list_tasks", "mcp:vikunja", cmd, false, "read only", "fp1", now); err != nil {
t.Fatal(err)
}
if err := s.EnableTool(ctx, "vikunja_list_tasks", cmd, false, "mcp:vikunja", now); err != nil {
t.Fatal(err)
}
// Same shape ⇒ nothing happens. Discovery runs every minute and must be
// idempotent.
ch, err := s.ReconcileMCPTool(ctx, "vikunja_list_tasks", "fp1", false, "read only", now)
if err != nil {
t.Fatal(err)
}
if ch.Changed {
t.Fatalf("an unchanged tool must not be touched: %+v", ch)
}
if got, _ := s.LookupTool(ctx, "vikunja_list_tasks"); got.Status != "enabled" {
t.Fatalf("status = %q, want it left enabled", got.Status)
}
// It stopped claiming read-only and its schema moved.
ch, err = s.ReconcileMCPTool(ctx, "vikunja_list_tasks", "fp2", true, "now writes", now)
if err != nil {
t.Fatal(err)
}
if !ch.Changed || !ch.Demoted || !ch.Escalated {
t.Fatalf("change = %+v, want changed+demoted+escalated", ch)
}
got, err := s.LookupTool(ctx, "vikunja_list_tasks")
if err != nil {
t.Fatal(err)
}
if got.Status != "proposed" {
t.Errorf("status = %q, want a redefined tool back in the queue", got.Status)
}
if !got.Destructive {
t.Error("a tool that stopped claiming read-only must gain the confirm turn")
}
if got.Utterance != "now writes" {
t.Errorf("utterance = %q, want what the server says today", got.Utterance)
}
// destructive is only ever raised. The server that changed underneath us
// does not get to relax it by claiming read-only next time.
if _, err := s.ReconcileMCPTool(ctx, "vikunja_list_tasks", "fp3", false, "read only again", now); err != nil {
t.Fatal(err)
}
if got, _ = s.LookupTool(ctx, "vikunja_list_tasks"); !got.Destructive {
t.Error("destructive was relaxed by the server")
}
}
// A row written before fingerprints exist simply adopts one. An upgrade is not
// a redefinition and must not disable everything Kami approved.
func TestReconcileMCPToolAdoptsAnEmptyFingerprint(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now()
cmd := []string{"mcp", "vikunja", "list_tasks"}
if _, err := s.ProposeMCPTool(ctx, "vikunja_list_tasks", "mcp:vikunja", cmd, false, "x", "", now); err != nil {
t.Fatal(err)
}
if err := s.EnableTool(ctx, "vikunja_list_tasks", cmd, false, "mcp:vikunja", now); err != nil {
t.Fatal(err)
}
ch, err := s.ReconcileMCPTool(ctx, "vikunja_list_tasks", "fp1", false, "x", now)
if err != nil {
t.Fatal(err)
}
if ch.Changed {
t.Fatalf("adopting must be silent: %+v", ch)
}
if got, _ := s.LookupTool(ctx, "vikunja_list_tasks"); got.Status != "enabled" {
t.Fatalf("status = %q, want still enabled after the upgrade", got.Status)
}
// And now it is pinned.
if ch, _ = s.ReconcileMCPTool(ctx, "vikunja_list_tasks", "fp2", false, "y", now); !ch.Changed {
t.Fatal("the adopted fingerprint must be enforced on the next pass")
}
}
func TestReconcileMCPToolUnknownRow(t *testing.T) {
s := newTestStore(t)
if _, err := s.ReconcileMCPTool(context.Background(), "nope", "fp", false, "", time.Now()); !errors.Is(err, ErrToolNotFound) {
t.Fatalf("err = %v, want ErrToolNotFound", err)
}
}
// A tool the server stopped offering must be disarmed and must say why. It used
// to stay enabled and fail at call time with an internal string, and /tools —
// the one place he would look — did not mention it.
func TestWithdrawTool(t *testing.T) {
s := newTestStore(t)
ctx := context.Background()
now := time.Now()
cmd := []string{"mcp", "vikunja", "list_tasks"}
if _, err := s.ProposeMCPTool(ctx, "vikunja_list_tasks", "mcp:vikunja", cmd, false, "x", "fp1", now); err != nil {
t.Fatal(err)
}
if err := s.EnableTool(ctx, "vikunja_list_tasks", cmd, false, "mcp:vikunja", now); err != nil {
t.Fatal(err)
}
was, err := s.WithdrawTool(ctx, "vikunja_list_tasks", "gone", now)
if err != nil {
t.Fatal(err)
}
if !was {
t.Error("withdrawing an enabled tool must report that it was enabled")
}
got, err := s.LookupTool(ctx, "vikunja_list_tasks")
if err != nil {
t.Fatal(err)
}
if got.Status != "proposed" || got.Utterance != "gone" {
t.Fatalf("row = %+v, want proposed and saying why", got)
}
// Withdrawing again is not an error and does not claim it was enabled.
if was, err = s.WithdrawTool(ctx, "vikunja_list_tasks", "still gone", now); err != nil || was {
t.Fatalf("second withdraw = %v, %v", was, err)
}
if got, _ = s.LookupTool(ctx, "vikunja_list_tasks"); got.Utterance != "still gone" {
t.Errorf("utterance = %q, want the provenance refreshed anyway", got.Utterance)
}
}