package mcp import ( "context" "encoding/json" "fmt" "log/slog" "strings" "github.com/mathiasbq/hyperguild/ingestion/internal/brain" "github.com/mathiasbq/hyperguild/ingestion/internal/notes" ) // noteToolDescriptors are the model-facing halves of brain#4. // // brain_edit_root is deliberately absent: it dispatches but is not listed, the // same de-surfacing ADR-0012 rule 4 applies to brain_index (#78). Hiding the // verb matters here for a specific reason — brain content is ingested from the // outside world and reaches a model's context, so a listed governance-file // editor is a durable prompt-injection path: one poisoned page could rewrite // the CLAUDE.md that a .dispatch-allow agent later obeys. A human who knows // the tool exists can still call it; a model reading tools/list cannot // discover it. func noteToolDescriptors() []map[string]any { str := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} } schema := func(required []string, props map[string]any) json.RawMessage { b, _ := json.Marshal(map[string]any{"type": "object", "required": required, "properties": props}) return b } return []map[string]any{ { "name": "brain_read", "description": "Return a note's raw markdown verbatim — no synthesis, no excerpting. " + "Use this to obtain the exact text before brain_edit; never build old_str from a " + "brain_query excerpt, which is truncated and normalised and will not match.", "inputSchema": schema([]string{"path"}, map[string]any{ "path": str("brain-relative path under knowledge/ or wiki/, e.g. wiki/homelab/facts/x.md"), }), }, { "name": "brain_edit", "description": "Replace exactly one occurrence of old_str with new_str in a note, leaving " + "every other byte identical. Fails loudly if old_str is absent or matches more than " + "once — extend old_str until it is unique. Does NOT stamp updated_at or supersedes " + "(brain_update does); it is a byte-level patch, not a metadata operation.", "inputSchema": schema([]string{"path", "old_str", "new_str"}, map[string]any{ "path": str("brain-relative path under knowledge/ or wiki/"), "old_str": str("exact text to replace; must occur exactly once"), "new_str": str("replacement text; empty string deletes old_str"), }), }, } } type brainReadArgs struct { Path string `json:"path"` } func (s *Server) brainRead(_ context.Context, args json.RawMessage) (json.RawMessage, error) { var a brainReadArgs if err := json.Unmarshal(args, &a); err != nil { return nil, fmt.Errorf("parse args: %w", err) } content, err := notes.Read(s.brainDir, a.Path) if err != nil { return nil, err } return json.Marshal(map[string]any{"path": a.Path, "content": string(content)}) } type brainEditArgs struct { Path string `json:"path"` OldStr string `json:"old_str"` NewStr string `json:"new_str"` } // brainEdit is the model-facing editor: notes only. func (s *Server) brainEdit(_ context.Context, args json.RawMessage) (json.RawMessage, error) { return s.editWithScope(args, notes.ScopeNotes) } // brainEditRoot edits a governance file (CLAUDE.md). De-surfaced AND gated on // the authenticated principal: only the static local-CLI token may call it, so // a claude.ai-origin session — the one that may have just read an untrusted // page — cannot rewrite the instructions later agents load. func (s *Server) brainEditRoot(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { info, ok := ctx.Value(principalKey).(principalInfo) if !ok || !info.viaStatic { return nil, fmt.Errorf("brain_edit_root requires the local static principal; " + "a governance file is not editable from a model-facing origin") } out, err := s.editWithScope(args, notes.ScopeRoot) if err == nil { // A durable instruction rewrite should be loud, not silent. slog.Warn("brain_edit_root applied", "principal", info.principal, "result", string(out)) } return out, err } func (s *Server) editWithScope(args json.RawMessage, scope notes.Scope) (json.RawMessage, error) { var a brainEditArgs if err := json.Unmarshal(args, &a); err != nil { return nil, fmt.Errorf("parse args: %w", err) } res, err := notes.Edit(s.brainDir, a.Path, a.OldStr, a.NewStr, scope) if err != nil { return nil, err } // Reindex only when the edit could have changed what the index reads. // BuildWingIndex consumes title/created_at from the frontmatter block; // BM25 walks the filesystem live and embeddings are mtime-driven, so an // unconditional rebuild would buy nothing and dirty a second file — // _index.md being precisely the class brain-sync already needs a bespoke // conflict resolver for. if wing := wingOf(res.Path); wing != "" && touchesFrontmatter(a.OldStr, a.NewStr) { if ierr := brain.BuildWingIndex(s.brainDir, wing); ierr != nil { // Best-effort, matching every other index hook here: the write // already landed and will be committed, so failing the call would // make the caller retry an edit whose old_str no longer exists. res.ReindexError = ierr.Error() } } return json.Marshal(res) } // wingOf returns the wing for a wiki path, or "" for knowledge/ and root. func wingOf(rel string) string { parts := strings.Split(rel, "/") if len(parts) < 3 || parts[0] != "wiki" { return "" } return parts[1] } // touchesFrontmatter reports whether either side of the edit mentions a field // the wing index reads. func touchesFrontmatter(oldStr, newStr string) bool { for _, s := range []string{oldStr, newStr} { if strings.Contains(s, "title:") || strings.Contains(s, "created_at:") || strings.Contains(s, "---") { return true } } return false }