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