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>
This commit is contained in:
@@ -109,6 +109,19 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"text": str("raw document text to classify (first 3000 chars used)"),
|
"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",
|
"name": "session_log",
|
||||||
"description": "Append a structured entry to brain/sessions/<session_id>.jsonl.",
|
"description": "Append a structured entry to brain/sessions/<session_id>.jsonl.",
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
|||||||
return s.brainAnswer(ctx, args)
|
return s.brainAnswer(ctx, args)
|
||||||
case "brain_classify":
|
case "brain_classify":
|
||||||
return s.brainClassify(ctx, args)
|
return s.brainClassify(ctx, args)
|
||||||
|
case "brain_graph":
|
||||||
|
return s.brainGraph(ctx, args)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown tool: %s", name)
|
return nil, fmt.Errorf("unknown tool: %s", name)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func TestServerToolsList(t *testing.T) {
|
|||||||
assert.ElementsMatch(t, []string{
|
assert.ElementsMatch(t, []string{
|
||||||
"brain_query", "brain_write", "brain_index", "brain_tunnel",
|
"brain_query", "brain_write", "brain_index", "brain_tunnel",
|
||||||
"brain_ingest_raw", "brain_ingest",
|
"brain_ingest_raw", "brain_ingest",
|
||||||
"brain_answer", "brain_classify", "session_log",
|
"brain_answer", "brain_classify", "brain_graph", "session_log",
|
||||||
}, names)
|
}, names)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
116
ingestion/internal/mcp/tools_graph.go
Normal file
116
ingestion/internal/mcp/tools_graph.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user