mcp: pin what a tool was when it was approved

An allowlist row stores cmd ["mcp", server, tool]. That is a late-bound
reference to a name the far end owns, so the row pins nothing about
behaviour: a server could redefine an enabled read-only list_tasks into
something that writes, and Maven would keep calling it with no confirm
turn and no second approval. Discovery now stores a fingerprint of the
declared shape, name, description, input schema and readOnlyHint, and
compares it on every refresh. A mismatch drops the row back to proposed
and, if it stopped claiming read-only, marks it destructive. destructive
is only ever raised. A row predating the column adopts its fingerprint
silently, because an upgrade is not a redefinition.

Nothing retracted a proposal either, so a tool a connected server no
longer offers stayed enabled and failed at call time with an internal
string. Those rows are withdrawn, with provenance saying why, and only
for servers that are actually connected so a restart does not disarm
what he approved.

Argument binding rested on readOnlyHint, which the same server writes.
A server advertising delete_project as read-only got an unconfirmed
argument-carrying call. Binding now also requires the tool be named in
allow_tools, something local, and refuses a required property the schema
never describes rather than guessing it is a string.

wireMCP dialled synchronously from run, and on the passkey path from
inside the unlock handler, so one black-holed endpoint delayed boot and
the answer to an unlock. The first dial happens on the refresh goroutine
under the daemon context. Two servers whose names flatten to one local
allowlist name no longer share a row.

Found in review of #71.
This commit is contained in:
kami
2026-08-01 14:11:57 +04:00
parent 52f56947bb
commit da62a2f25e
7 changed files with 479 additions and 29 deletions
+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