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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user