From 2148565ee67dc43bcae92dd3aca407031311c8ea Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 23 May 2026 15:23:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20expose=20brain=5Fgraph=20tool=20?= =?UTF-8?q?=E2=80=94=20neighbors,=20subgraph,=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 3 of Track A. The MCP server now publishes a new tool that opens the brain knowledge graph (entities + wikilink edges) for external consumers (claude.ai connectors, gitea-mcp, agentsquad). - tools_graph.go: brain_graph handler dispatches by op: neighbors — 1-hop outgoing from slug, optional edge_type filter subgraph — every reachable slug within depth hops (≤6) path — shortest directed path src→dst within depth (≤8) Returns slug + entity metadata + edge_type + hop distance. - server.go: handleCall routes "brain_graph" to brainGraph. - handlers.go: tool descriptor with the op enum + per-op required fields documented in the description. - server_test.go: TestServerToolsList expects brain_graph in the listing. The tool returns an error when BRAIN_GRAPH_ENABLED is unset — same shape as brain_answer when the answer LLM is unconfigured. Co-Authored-By: Claude Opus 4.7 (1M context) --- ingestion/internal/mcp/handlers.go | 13 +++ ingestion/internal/mcp/server.go | 2 + ingestion/internal/mcp/server_test.go | 2 +- ingestion/internal/mcp/tools_graph.go | 116 ++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 ingestion/internal/mcp/tools_graph.go diff --git a/ingestion/internal/mcp/handlers.go b/ingestion/internal/mcp/handlers.go index 82e1f4e..1514785 100644 --- a/ingestion/internal/mcp/handlers.go +++ b/ingestion/internal/mcp/handlers.go @@ -109,6 +109,19 @@ func (s *Server) tools() []map[string]any { "text": str("raw document text to classify (first 3000 chars used)"), }), }, + { + "name": "brain_graph", + "description": "Query the brain knowledge graph (entities + wikilink edges). Op selects the traversal: neighbors (1-hop outgoing from slug), subgraph (every reachable slug within depth hops), or path (shortest directed path src→dst). Returns slug + entity metadata + edge_type + hop distance.", + "inputSchema": schema([]string{"op"}, map[string]any{ + "op": enum("traversal kind", "neighbors", "subgraph", "path"), + "slug": str("origin slug for op=neighbors or op=subgraph"), + "src": str("source slug for op=path"), + "dst": str("destination slug for op=path"), + "edge_type": str("optional edge type filter for op=neighbors (e.g. wikilink); empty matches all"), + "limit": int_("max neighbors to return for op=neighbors, default 25"), + "depth": int_("max traversal depth for op=subgraph (default 2, clamped to 6) and op=path (default 4, clamped to 8)"), + }), + }, { "name": "session_log", "description": "Append a structured entry to brain/sessions/.jsonl.", diff --git a/ingestion/internal/mcp/server.go b/ingestion/internal/mcp/server.go index 51ed470..c10d5f0 100644 --- a/ingestion/internal/mcp/server.go +++ b/ingestion/internal/mcp/server.go @@ -190,6 +190,8 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa return s.brainAnswer(ctx, args) case "brain_classify": return s.brainClassify(ctx, args) + case "brain_graph": + return s.brainGraph(ctx, args) default: return nil, fmt.Errorf("unknown tool: %s", name) } diff --git a/ingestion/internal/mcp/server_test.go b/ingestion/internal/mcp/server_test.go index 80800cb..6727807 100644 --- a/ingestion/internal/mcp/server_test.go +++ b/ingestion/internal/mcp/server_test.go @@ -57,7 +57,7 @@ func TestServerToolsList(t *testing.T) { assert.ElementsMatch(t, []string{ "brain_query", "brain_write", "brain_index", "brain_tunnel", "brain_ingest_raw", "brain_ingest", - "brain_answer", "brain_classify", "session_log", + "brain_answer", "brain_classify", "brain_graph", "session_log", }, names) } diff --git a/ingestion/internal/mcp/tools_graph.go b/ingestion/internal/mcp/tools_graph.go new file mode 100644 index 0000000..ea5eb4f --- /dev/null +++ b/ingestion/internal/mcp/tools_graph.go @@ -0,0 +1,116 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/mathiasbq/hyperguild/ingestion/internal/graphstore" +) + +// graphReader is the read-side surface of graphstore.PGStore the +// brain_graph handler needs. Splitting it out (vs. depending on the +// concrete *PGStore) lets tests inject a fake without standing up +// postgres, and keeps the write-side graphsync.Store interface free +// of query concerns. +type graphReader interface { + Neighbors(ctx context.Context, slug, edgeType string, limit int) ([]graphstore.Neighbor, error) + Subgraph(ctx context.Context, origin string, depth int) ([]graphstore.Neighbor, error) + Path(ctx context.Context, src, dst string, maxDepth int) ([]graphstore.PathStep, error) +} + +// Compile-time check that *graphstore.PGStore satisfies graphReader. +var _ graphReader = (*graphstore.PGStore)(nil) + +type brainGraphArgs struct { + Op string `json:"op"` + Slug string `json:"slug,omitempty"` + Src string `json:"src,omitempty"` + Dst string `json:"dst,omitempty"` + EdgeType string `json:"edge_type,omitempty"` + Limit int `json:"limit,omitempty"` + Depth int `json:"depth,omitempty"` +} + +func (s *Server) brainGraph(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + reader, ok := s.graph.(graphReader) + if s.graph == nil || !ok { + return nil, fmt.Errorf("brain graph not configured: set BRAIN_GRAPH_ENABLED=true") + } + var a brainGraphArgs + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + + switch a.Op { + case "neighbors": + if a.Slug == "" { + return nil, fmt.Errorf("slug is required for op=neighbors") + } + ns, err := reader.Neighbors(ctx, a.Slug, a.EdgeType, a.Limit) + if err != nil { + return nil, fmt.Errorf("neighbors: %w", err) + } + return json.Marshal(map[string]any{"results": neighborsView(ns)}) + + case "subgraph": + if a.Slug == "" { + return nil, fmt.Errorf("slug is required for op=subgraph") + } + ns, err := reader.Subgraph(ctx, a.Slug, a.Depth) + if err != nil { + return nil, fmt.Errorf("subgraph: %w", err) + } + return json.Marshal(map[string]any{"results": neighborsView(ns)}) + + case "path": + if a.Src == "" || a.Dst == "" { + return nil, fmt.Errorf("src and dst are required for op=path") + } + steps, err := reader.Path(ctx, a.Src, a.Dst, a.Depth) + if err != nil { + return nil, fmt.Errorf("path: %w", err) + } + return json.Marshal(map[string]any{"steps": pathView(steps)}) + + default: + return nil, fmt.Errorf("unknown op %q (want neighbors|subgraph|path)", a.Op) + } +} + +type neighborView struct { + Slug string `json:"slug"` + Type string `json:"type,omitempty"` + Wing string `json:"wing,omitempty"` + Hall string `json:"hall,omitempty"` + DocPath string `json:"doc_path,omitempty"` + Title string `json:"title,omitempty"` + EdgeType string `json:"edge_type"` + Distance int `json:"distance"` +} + +func neighborsView(ns []graphstore.Neighbor) []neighborView { + out := make([]neighborView, 0, len(ns)) + for _, n := range ns { + out = append(out, neighborView{ + Slug: n.Slug, Type: n.Type, Wing: n.Wing, Hall: n.Hall, + DocPath: n.DocPath, Title: n.Title, + EdgeType: n.EdgeType, Distance: n.Distance, + }) + } + return out +} + +type pathStepView struct { + From string `json:"from"` + To string `json:"to"` + EdgeType string `json:"edge_type"` +} + +func pathView(steps []graphstore.PathStep) []pathStepView { + out := make([]pathStepView, 0, len(steps)) + for _, s := range steps { + out = append(out, pathStepView{From: s.FromSlug, To: s.ToSlug, EdgeType: s.EdgeType}) + } + return out +}