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
+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")
}
})
}
}