Files
hyperguild/ingestion/internal/mcp/tools_graph.go
Mathias 2148565ee6
All checks were successful
CI / Lint / Test / Vet (push) Successful in 12s
CI / Mirror to GitHub (push) Successful in 4s
feat(mcp): expose brain_graph tool — neighbors, subgraph, path
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) <noreply@anthropic.com>
2026-05-23 15:23:18 +02:00

117 lines
3.5 KiB
Go

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
}