From 6c61f93146d06f490982494caffa903335f5e3fb Mon Sep 17 00:00:00 2001 From: Mathias Date: Mon, 22 Jun 2026 08:25:10 +0200 Subject: [PATCH] feat(mcp): register brain_update + brain_get, extend brain_write handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the #45 verbs into the MCP surface (all three sites: tools() descriptors, handleCall dispatch, package doc comment). - brain_update: supersede-by-slug or full path; rebuilds wing _index and re-tunnels cross-wing matches against the new body (idempotent, best-effort), re-indexes the graph, returns {id, path, content_hash, superseded}. - brain_get: fetch by id or path (both are the brain-relative handle); returns {id, path, content_hash, frontmatter, body}. - brain_write: return contract extended from {path} to {id, path, content_hash} — path kept for backward compat — so the create path also yields a stable handle. id == relPath; content_hash == sha256 of the file bytes. Tests cover the supersede happy path, missing-target error + no-create, get by id/path, write handle, and an end-to-end re-embed test that drives the real vectorstore.Sync re-index after an update. Co-Authored-By: Claude Opus 4.8 (1M context) --- ingestion/internal/mcp/brain_update_test.go | 204 ++++++++++++++++++++ ingestion/internal/mcp/handlers.go | 126 +++++++++++- ingestion/internal/mcp/server.go | 10 +- ingestion/internal/mcp/server_test.go | 3 +- 4 files changed, 338 insertions(+), 5 deletions(-) create mode 100644 ingestion/internal/mcp/brain_update_test.go diff --git a/ingestion/internal/mcp/brain_update_test.go b/ingestion/internal/mcp/brain_update_test.go new file mode 100644 index 0000000..742d4cb --- /dev/null +++ b/ingestion/internal/mcp/brain_update_test.go @@ -0,0 +1,204 @@ +package mcp_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mathiasbq/hyperguild/ingestion/internal/mcp" + "github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// callResult parses the JSON text payload of a successful tool call. +func callResult(t *testing.T, resp map[string]any) map[string]any { + t.Helper() + require.Nil(t, resp["error"], "tool returned error: %v", resp["error"]) + text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + var out map[string]any + require.NoError(t, json.Unmarshal([]byte(text), &out)) + return out +} + +func TestBrainUpdateSupersedesExisting(t *testing.T) { + brainDir := t.TempDir() + srv := mcp.NewServer(brainDir, nil, nil, nil) + + // Seed via brain_write so the note carries real frontmatter. + callResult(t, toolCall(t, srv, "brain_write", map[string]any{ + "content": "# Old\n\nold body\n", "filename": "val-vol", + "wing": "jepa-fx", "hall": "facts", + })) + + out := callResult(t, toolCall(t, srv, "brain_update", map[string]any{ + "wing": "jepa-fx", "hall": "facts", "slug": "val-vol", + "content": "# New\n\nnew body\n", "reason": "facts changed", + })) + assert.Equal(t, "wiki/jepa-fx/facts/val-vol.md", out["path"]) + assert.Equal(t, out["path"], out["id"]) + assert.NotEmpty(t, out["content_hash"]) + assert.Equal(t, true, out["superseded"]) + + got, err := os.ReadFile(filepath.Join(brainDir, "wiki/jepa-fx/facts/val-vol.md")) + require.NoError(t, err) + s := string(got) + assert.Contains(t, s, "# New") + assert.NotContains(t, s, "old body") + assert.Contains(t, s, "wing: jepa-fx") + assert.Contains(t, s, "supersede_reason: facts changed") + assert.Contains(t, s, "supersedes:") +} + +func TestBrainUpdateMissingTargetErrorsNoCreate(t *testing.T) { + brainDir := t.TempDir() + srv := mcp.NewServer(brainDir, nil, nil, nil) + + resp := toolCall(t, srv, "brain_update", map[string]any{ + "wing": "jepa-fx", "hall": "facts", "slug": "ghost", + "content": "x\n", + }) + require.NotNil(t, resp["error"]) + assert.Contains(t, resp["error"].(map[string]any)["message"].(string), "does not exist") + _, statErr := os.Stat(filepath.Join(brainDir, "wiki/jepa-fx/facts/ghost.md")) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestBrainUpdateByFullPath(t *testing.T) { + brainDir := t.TempDir() + srv := mcp.NewServer(brainDir, nil, nil, nil) + callResult(t, toolCall(t, srv, "brain_write", map[string]any{ + "content": "old\n", "filename": "n", "wing": "a", "hall": "facts", + })) + + out := callResult(t, toolCall(t, srv, "brain_update", map[string]any{ + "slug": "wiki/a/facts/n.md", "content": "fresh\n", + })) + assert.Equal(t, "wiki/a/facts/n.md", out["path"]) +} + +func TestBrainGetByIDAndPath(t *testing.T) { + brainDir := t.TempDir() + srv := mcp.NewServer(brainDir, nil, nil, nil) + w := callResult(t, toolCall(t, srv, "brain_write", map[string]any{ + "content": "# Body\n\ntext\n", "filename": "n", "wing": "a", "hall": "facts", + })) + id := w["id"].(string) + hash := w["content_hash"].(string) + require.NotEmpty(t, id) + require.NotEmpty(t, hash) + + // by id + g1 := callResult(t, toolCall(t, srv, "brain_get", map[string]any{"id": id})) + assert.Equal(t, id, g1["path"]) + assert.Equal(t, hash, g1["content_hash"], "content_hash must round-trip write→get") + assert.Contains(t, g1["body"].(string), "# Body") + fm := g1["frontmatter"].(map[string]any) + assert.Equal(t, "a", fm["wing"]) + + // by path + g2 := callResult(t, toolCall(t, srv, "brain_get", map[string]any{"path": id})) + assert.Equal(t, hash, g2["content_hash"]) +} + +func TestBrainGetMissingArgsErrors(t *testing.T) { + srv := mcp.NewServer(t.TempDir(), nil, nil, nil) + resp := toolCall(t, srv, "brain_get", map[string]any{}) + require.NotNil(t, resp["error"]) +} + +func TestBrainWriteReturnsHandle(t *testing.T) { + brainDir := t.TempDir() + srv := mcp.NewServer(brainDir, nil, nil, nil) + out := callResult(t, toolCall(t, srv, "brain_write", map[string]any{ + "content": "# X\n\nbody\n", "filename": "x", "wing": "a", "hall": "facts", + })) + assert.Equal(t, "wiki/a/facts/x.md", out["path"]) + assert.Equal(t, out["path"], out["id"]) + assert.NotEmpty(t, out["content_hash"]) +} + +// --- retrieval-reflects-new-content: exercises the real mtime-driven Sync --- + +type fakeVecStore struct { + chunks map[string][]float32 + deleted []string +} + +func (f *fakeVecStore) KnownPathsWithTime(_ context.Context) (map[string]time.Time, error) { + m := make(map[string]time.Time, len(f.chunks)) + for p := range f.chunks { + m[p] = time.Unix(0, 0) // always stale → mtime(now) is always newer + } + return m, nil +} + +func (f *fakeVecStore) Upsert(_ context.Context, path string, vec []float32) error { + f.chunks[path] = vec + return nil +} + +func (f *fakeVecStore) Delete(_ context.Context, path string) error { + delete(f.chunks, path) + f.deleted = append(f.deleted, path) + return nil +} + +type fakeEmbedder struct{ seen []string } + +func (e *fakeEmbedder) Embed(_ context.Context, text string) ([]float32, error) { + e.seen = append(e.seen, text) + return []float32{1, 0, 0}, nil +} + +// TestBrainUpdateReembedsNewContent proves the supersede contract end to +// end against the actual embedding mechanism: brain_update rewrites the +// file, advancing its mtime, and the next vectorstore.Sync pass re-embeds +// the NEW body and drops the stale chunk. No stub of the re-index path. +func TestBrainUpdateReembedsNewContent(t *testing.T) { + brainDir := t.TempDir() + srv := mcp.NewServer(brainDir, nil, nil, nil) + ctx := context.Background() + + callResult(t, toolCall(t, srv, "brain_write", map[string]any{ + "content": "# Note\n\nthe OLD distinctive payload\n", + "filename": "n", "wing": "a", "hall": "facts", + })) + + store := &fakeVecStore{chunks: map[string][]float32{}} + emb := &fakeEmbedder{} + + // First sync embeds the original content. + _, err := vectorstore.Sync(ctx, brainDir, store, emb) + require.NoError(t, err) + require.NotEmpty(t, store.chunks) + require.True(t, anyContains(emb.seen, "OLD distinctive payload")) + + callResult(t, toolCall(t, srv, "brain_update", map[string]any{ + "wing": "a", "hall": "facts", "slug": "n", + "content": "# Note\n\nthe NEW distinctive payload\n", + })) + + emb.seen = nil // only watch what the second pass embeds + _, err = vectorstore.Sync(ctx, brainDir, store, emb) + require.NoError(t, err) + + assert.True(t, anyContains(emb.seen, "NEW distinctive payload"), + "Sync must re-embed the superseded body; saw %v", emb.seen) + assert.False(t, anyContains(emb.seen, "OLD distinctive payload"), + "the old body must not be re-embedded") + assert.NotEmpty(t, store.deleted, "stale chunks must be deleted before re-embed") +} + +func anyContains(ss []string, sub string) bool { + for _, s := range ss { + if strings.Contains(s, sub) { + return true + } + } + return false +} diff --git a/ingestion/internal/mcp/handlers.go b/ingestion/internal/mcp/handlers.go index 573cd06..8966b9b 100644 --- a/ingestion/internal/mcp/handlers.go +++ b/ingestion/internal/mcp/handlers.go @@ -61,6 +61,26 @@ func (s *Server) tools() []map[string]any { "hall": enum("optional memory type (requires wing)", halls...), }), }, + { + "name": "brain_update", + "description": "Supersede an existing brain note in place: whole-note body replace + frontmatter re-stamp (updated_at, supersedes=prior content hash, supersede_reason). Errors if the target does not exist — use brain_write to create. Returns {id, path, content_hash, superseded}. Prior version recoverable from git.", + "inputSchema": schema([]string{"content"}, map[string]any{ + "content": str("new full body (whole-note replace)"), + "slug": str("target note slug within wing/hall, OR a full brain-relative path (e.g. wiki/jepa-fx/facts/x.md)"), + "wing": str("wing of the target (required unless slug/path is a full path)"), + "hall": enum("hall of the target (required unless slug/path is a full path)", halls...), + "path": str("full brain-relative path to the target; takes precedence over slug/wing/hall"), + "reason": str("optional short note on why superseded — stamped into frontmatter"), + }), + }, + { + "name": "brain_get", + "description": "Fetch a single brain note by id or path (both are the brain-relative path — the note handle). Returns {id, path, content_hash, frontmatter, body}. Read-after-write confirmation without a lexical re-query.", + "inputSchema": schema([]string{}, map[string]any{ + "id": str("note id (brain-relative path) as returned by brain_write/brain_update"), + "path": str("brain-relative path to the note; equivalent to id"), + }), + }, { "name": "brain_tunnel", "description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.", @@ -222,7 +242,111 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw } } s.indexInGraph(ctx, "brain_write", relPath) - return json.Marshal(map[string]string{"path": relPath}) + // Read-after-write handle: id == relPath, content_hash == sha256 of + // the bytes just written. path is kept for backward compatibility. + _, _, hash, _ := api.ReadNote(s.brainDir, relPath) + return json.Marshal(map[string]string{"id": relPath, "path": relPath, "content_hash": hash}) +} + +type brainUpdateArgs struct { + Slug string `json:"slug,omitempty"` + Wing string `json:"wing,omitempty"` + Hall string `json:"hall,omitempty"` + Path string `json:"path,omitempty"` + Content string `json:"content"` + Reason string `json:"reason,omitempty"` +} + +// brainUpdate supersedes an existing note in place: whole-note body +// replace, frontmatter re-stamp (updated_at/supersedes/supersede_reason), +// graph re-index, and wing _index rebuild. It never creates — a missing +// target is an error so the caller can fall back to brain_write. +// +// Embedding re-sync is delegated to the out-of-band vectorstore.Sync +// ticker: the rewritten file's mtime advances, so the next pass re-embeds +// it. This mirrors brain_write, which likewise does not embed in-handler. +func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a brainUpdateArgs + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + if a.Content == "" { + return nil, fmt.Errorf("content is required") + } + + opts := api.UpdateNoteOptions{Content: a.Content, Reason: a.Reason} + switch { + case a.Path != "": + opts.Path = a.Path + case strings.Contains(a.Slug, "/"): + // slug carries a full path (issue #45: "slug ... OR full path"). + opts.Path = a.Slug + default: + opts.Wing, opts.Hall, opts.Slug = a.Wing, a.Hall, a.Slug + } + + relPath, hash, _, err := api.UpdateNote(s.brainDir, opts) + if err != nil { + return nil, err + } + + // Best-effort wiki upkeep, mirroring brain_write: rebuild the wing + // _index and re-tunnel cross-wing matches against the new body. Both + // are idempotent and never block — the note is already superseded. + if wing := wingFromRelPath(relPath); wing != "" { + if err := brain.BuildWingIndex(s.brainDir, wing); err != nil { + slog.Warn("brain_update: auto-index failed", "wing", wing, "err", err) + } + if err := brain.AutoTunnel(s.brainDir, relPath, a.Content); err != nil { + slog.Warn("brain_update: auto-tunnel failed", "src", relPath, "err", err) + } + } + s.indexInGraph(ctx, "brain_update", relPath) + + return json.Marshal(map[string]any{ + "id": relPath, "path": relPath, "content_hash": hash, "superseded": true, + }) +} + +// wingFromRelPath extracts the wing segment from a structured wiki path +// (wiki///.md). Returns "" for legacy/non-wiki paths. +func wingFromRelPath(relPath string) string { + parts := strings.Split(relPath, "/") + if len(parts) >= 4 && parts[0] == "wiki" { + return parts[1] + } + return "" +} + +type brainGetArgs struct { + ID string `json:"id,omitempty"` + Path string `json:"path,omitempty"` +} + +// brainGet fetches a note by id or path (both are the brainDir-relative +// path — the de-facto handle). Read-only; the create-path read-after- +// write primitive that lets callers confirm a write landed without a +// lexical re-query. +func (s *Server) brainGet(_ context.Context, args json.RawMessage) (json.RawMessage, error) { + var a brainGetArgs + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + target := a.Path + if target == "" { + target = a.ID + } + if target == "" { + return nil, fmt.Errorf("id or path is required") + } + fm, body, hash, err := api.ReadNote(s.brainDir, target) + if err != nil { + return nil, err + } + return json.Marshal(map[string]any{ + "id": target, "path": target, "content_hash": hash, + "frontmatter": fm, "body": body, + }) } // indexInGraph is a best-effort wrapper around graphsync.IndexDoc that diff --git a/ingestion/internal/mcp/server.go b/ingestion/internal/mcp/server.go index 0ba1cc0..0f9051d 100644 --- a/ingestion/internal/mcp/server.go +++ b/ingestion/internal/mcp/server.go @@ -1,7 +1,7 @@ // Package mcp implements an MCP HTTP handler for the ingestion service. -// Exposed tools: brain_query, brain_write, brain_index, brain_tunnel, -// brain_ingest, brain_ingest_raw, brain_answer, brain_classify, -// brain_graph, brain_context, session_log. +// Exposed tools: brain_query, brain_write, brain_update, brain_get, +// brain_index, brain_tunnel, brain_ingest, brain_ingest_raw, +// brain_answer, brain_classify, brain_graph, brain_context, session_log. package mcp import ( @@ -177,6 +177,10 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa return s.brainQuery(ctx, args) case "brain_write": return s.brainWrite(ctx, args) + case "brain_update": + return s.brainUpdate(ctx, args) + case "brain_get": + return s.brainGet(ctx, args) case "brain_index": return s.brainIndex(ctx, args) case "brain_tunnel": diff --git a/ingestion/internal/mcp/server_test.go b/ingestion/internal/mcp/server_test.go index 4baf35e..6d6494d 100644 --- a/ingestion/internal/mcp/server_test.go +++ b/ingestion/internal/mcp/server_test.go @@ -55,7 +55,8 @@ func TestServerToolsList(t *testing.T) { names = append(names, t.(map[string]any)["name"].(string)) } assert.ElementsMatch(t, []string{ - "brain_query", "brain_write", "brain_index", "brain_tunnel", + "brain_query", "brain_write", "brain_update", "brain_get", + "brain_index", "brain_tunnel", "brain_ingest_raw", "brain_ingest", "brain_answer", "brain_classify", "brain_graph", "brain_context", "session_log",