da62a2f25e
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.
99 lines
3.4 KiB
Go
99 lines
3.4 KiB
Go
package mcp
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// CmdPrefix is the reserved first argv element that marks an allowlist row as
|
|
// an MCP call rather than a process. An MCP tool row looks like
|
|
//
|
|
// name: "vikunja_list_tasks" cmd: ["mcp", "vikunja", "list_tasks"]
|
|
//
|
|
// which is why there is no new column and no migration: the store, the /tools
|
|
// page, ProposeTool, EnableTool, DisableTool, the act matcher and the confirm
|
|
// turn all keep working unchanged. The executor is the only place that has to
|
|
// know the difference, and it is one branch on Cmd[0].
|
|
//
|
|
// The rest of the allowlist discipline is inherited whole: a row that is not
|
|
// status='enabled' does not run, and a row marked destructive does not run on
|
|
// first hearing. Nothing here can enable itself — discovery only proposes.
|
|
const CmdPrefix = "mcp"
|
|
|
|
// Cmd builds the argv encoding for a discovered tool.
|
|
func Cmd(server, tool string) []string { return []string{CmdPrefix, server, tool} }
|
|
|
|
// ParseCmd recognises an MCP allowlist row. ok=false for an ordinary process
|
|
// tool, which is what almost every row is.
|
|
func ParseCmd(cmd []string) (server, tool string, ok bool) {
|
|
if len(cmd) != 3 || cmd[0] != CmdPrefix {
|
|
return "", "", false
|
|
}
|
|
if cmd[1] == "" || cmd[2] == "" {
|
|
return "", "", false
|
|
}
|
|
return cmd[1], cmd[2], true
|
|
}
|
|
|
|
var notName = regexp.MustCompile(`[^a-z0-9_]+`)
|
|
|
|
// LocalName is the allowlist name for a discovered tool: the server handle, an
|
|
// underscore, the remote name, lowercased and stripped of anything that is not
|
|
// a word character. Namespacing by server is what keeps two servers that both
|
|
// offer "search" from colliding, and what makes the provenance of a row on the
|
|
// /tools page obvious without opening the diff.
|
|
func LocalName(server, tool string) string {
|
|
clean := func(s string) string {
|
|
return strings.Trim(notName.ReplaceAllString(strings.ToLower(strings.TrimSpace(s)), "_"), "_")
|
|
}
|
|
s, t := clean(server), clean(tool)
|
|
switch {
|
|
case s == "":
|
|
return t
|
|
case t == "":
|
|
return s
|
|
}
|
|
return s + "_" + t
|
|
}
|
|
|
|
// 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))
|
|
}
|