package store import ( "context" "database/sql" "encoding/json" "errors" "fmt" "time" ) // Tool — one act in the allowlist. Scope namespaces tools (e.g. "homelab"). // Cmd is the fixed argv prefix run with the utterance's args appended (no // shell). Status 'proposed' is a scaffold that drives nothing; 'enabled' is // the human-flipped, runnable form. type Tool struct { Name string Scope string Cmd []string Destructive bool Status string // proposed | enabled Utterance string // provenance: the utterance that scaffolded a proposal CreatedTs time.Time UpdatedTs time.Time } var ( // ErrToolNotFound — no tool row with this name. ErrToolNotFound = errors.New("store: tool not found") // ErrToolCmd — an enable supplied an empty argv (an enabled tool must run something). ErrToolCmd = errors.New("store: enabled tool needs a non-empty cmd") ) // ProposeTool inserts a 'proposed' scaffold for name (provenance = utterance) // if no row for name exists yet. Returns true when a new proposal was written, // false when a row (proposed or enabled) already existed. maven calls this when // she classifies an act whose verb isn't on the enabled allowlist — she drafts // the registration; a human enables it. Never overwrites an enabled tool. // scope defaults to "homelab" when empty. func (s *Store) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) { if scope == "" { scope = "homelab" } res, err := s.db.ExecContext(ctx, ` INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts) VALUES (?, ?, '[]', 0, 'proposed', ?, ?, ?) ON CONFLICT(name) DO NOTHING`, name, scope, utterance, ts.UnixMilli(), ts.UnixMilli()) if err != nil { return false, fmt.Errorf("propose tool: %w", err) } n, err := res.RowsAffected() if err != nil { return false, fmt.Errorf("propose tool: rows affected: %w", err) } return n > 0, nil } // ProposeMCPTool is ProposeTool for a tool discovered on an MCP server // (Vikunja #251): the proposal already knows what it would run, so cmd and // destructive are written with it and Kami only has to press enable. // // It is still a PROPOSAL. Discovery cannot grant a capability — that is the // whole reason a server can be configured without its tools becoming live. // 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. // // 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 } if scope == "" { scope = "homelab" } raw, err := json.Marshal(cmd) if err != nil { return false, fmt.Errorf("propose mcp tool: %w", err) } d := 0 if destructive { d = 1 } res, err := s.db.ExecContext(ctx, ` 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, fingerprint, ts.UnixMilli(), ts.UnixMilli()) if err != nil { return false, fmt.Errorf("propose mcp tool: %w", err) } n, err := res.RowsAffected() if err != nil { return false, fmt.Errorf("propose mcp tool: rows affected: %w", err) } return n > 0, nil } // ProposeSmartHomeTool is ProposeTool for a controllable device discovered on // the Home Assistant instance (Vikunja #256). Like ProposeMCPTool the proposal // already knows what it would run, so cmd is written with it and Kami only has // to press enable. // // It is still a PROPOSAL, and destructive is not a parameter: there is no // read-only way to turn a lamp off, so every house row carries the confirm // 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) } // 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 // human "enable" act (the authed surface calls it); it upserts so enabling a // name that was never proposed still works. An empty cmd is refused — an // enabled tool that runs nothing is a footgun, not a tool. // scope defaults to "homelab" when empty. func (s *Store) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error { if len(cmd) == 0 { return ErrToolCmd } if scope == "" { scope = "homelab" } raw, err := json.Marshal(cmd) if err != nil { return fmt.Errorf("enable tool: %w", err) } d := 0 if destructive { d = 1 } _, err = s.db.ExecContext(ctx, ` INSERT INTO tools (name, scope, cmd, destructive, status, utterance, created_ts, updated_ts) VALUES (?, ?, ?, ?, 'enabled', '', ?, ?) ON CONFLICT(name) DO UPDATE SET scope=excluded.scope, cmd=excluded.cmd, destructive=excluded.destructive, status='enabled', updated_ts=excluded.updated_ts`, name, scope, string(raw), d, ts.UnixMilli(), ts.UnixMilli()) if err != nil { return fmt.Errorf("enable tool: %w", err) } return nil } // DeleteTool permanently removes a tool row. Used for "dismiss" on proposed // tools — there's no dismissed status, the proposal is simply gone and maven // can re-propose it later if the same gap is encountered. Idempotent: deleting // a tool that doesn't exist is a no-op. func (s *Store) DeleteTool(ctx context.Context, name string) error { _, err := s.db.ExecContext(ctx, `DELETE FROM tools WHERE name = ?`, name) return err } // DisableTool sets a tool's status from 'enabled' back to 'proposed'. This is // the "disable" act on the authed surface — the tool stays in the store (its // provenance preserved) but won't run until re-enabled. Idempotent: disabling // a tool that is already proposed or doesn't exist is a no-op. func (s *Store) DisableTool(ctx context.Context, name string) error { _, err := s.db.ExecContext(ctx, `UPDATE tools SET status = 'proposed', updated_ts = ? WHERE name = ? AND status = 'enabled'`, time.Now().UnixMilli(), name) if err != nil { return fmt.Errorf("disable tool: %w", err) } return nil } // LookupTool returns the tool by name. ErrToolNotFound when absent. func (s *Store) LookupTool(ctx context.Context, name string) (Tool, error) { row := s.db.QueryRowContext(ctx, ` SELECT name, scope, cmd, destructive, status, utterance, created_ts, updated_ts FROM tools WHERE name = ?`, name) t, err := scanTool(row) if errors.Is(err, sql.ErrNoRows) { return Tool{}, ErrToolNotFound } return t, err } // ListTools returns tools filtered by status ("" ⇒ all), name-sorted. func (s *Store) ListTools(ctx context.Context, status string) ([]Tool, error) { q := `SELECT name, scope, cmd, destructive, status, utterance, created_ts, updated_ts FROM tools` var args []any if status != "" { q += ` WHERE status = ?` args = append(args, status) } q += ` ORDER BY name ASC` rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, fmt.Errorf("list tools: %w", err) } defer rows.Close() var out []Tool for rows.Next() { t, err := scanTool(rows) if err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } // scanner is the shared shape of *sql.Row and *sql.Rows. type scanner interface{ Scan(...any) error } func scanTool(sc scanner) (Tool, error) { var t Tool var cmdJSON string var d int var created, updated int64 if err := sc.Scan(&t.Name, &t.Scope, &cmdJSON, &d, &t.Status, &t.Utterance, &created, &updated); err != nil { return Tool{}, err } if err := json.Unmarshal([]byte(cmdJSON), &t.Cmd); err != nil { return Tool{}, fmt.Errorf("scan tool %q cmd: %w", t.Name, err) } t.Destructive = d != 0 t.CreatedTs = time.UnixMilli(created).UTC() t.UpdatedTs = time.UnixMilli(updated).UTC() return t, nil }