feat(mcp): brain_pending + brain_promote tools + REST routes (#38)
Wires the curation primitives into the MCP surface (all three sites: tools() descriptors, handleCall dispatch, package doc) and registers the GET /pending + POST /promote REST routes. brain_promote additionally re-indexes the promoted note into the graph (best-effort), matching the other write paths. brain_pending is the human-review-queue complement to the agent-facing brain_write. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -369,6 +369,8 @@ func main() {
|
||||
mux.HandleFunc("POST /ingest-path", h.IngestPath)
|
||||
mux.HandleFunc("POST /ingest-raw", h.IngestRaw)
|
||||
mux.HandleFunc("POST /backfill-refs", h.BackfillRefs)
|
||||
mux.HandleFunc("GET /pending", h.Pending)
|
||||
mux.HandleFunc("POST /promote", h.Promote)
|
||||
mux.HandleFunc("POST /backfill-embeddings", h.BackfillEmbeddings)
|
||||
mux.HandleFunc("GET /pass-rate", h.PassRate)
|
||||
jwtValidator, err := chassisauth.NewJWTValidator(ctx, os.Getenv("DEX_ISSUER_URL"), os.Getenv("MCP_AUDIENCE"))
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||
@@ -81,6 +82,21 @@ func (s *Server) tools() []map[string]any {
|
||||
"path": str("brain-relative path to the note; equivalent to id"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "brain_pending",
|
||||
"description": "List notes in brain/raw/ awaiting human promotion to the wiki, oldest-first. Returns filename, created_at, size_bytes, excerpt. The human-review queue complement to brain_promote.",
|
||||
"inputSchema": schema([]string{}, map[string]any{}),
|
||||
},
|
||||
{
|
||||
"name": "brain_promote",
|
||||
"description": "Promote a brain/raw/ note into brain/wiki/<wing>/<hall>/: rewrites frontmatter (sets wing/hall/promoted_at, preserves created_at + custom fields), deletes the source, rebuilds the wing index, runs auto-tunnel. Errors (without touching the fs) on invalid hall or a slug collision. Returns {path}.",
|
||||
"inputSchema": schema([]string{"filename", "wing", "hall"}, map[string]any{
|
||||
"filename": str("basename in brain/raw/, e.g. 2026-06-01-lejpa-decision.md"),
|
||||
"wing": str("target wing, e.g. jepa-fx"),
|
||||
"hall": enum("target hall", halls...),
|
||||
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
"name": "brain_tunnel",
|
||||
"description": "Create an explicit bidirectional [[wikilink]] between two notes in different wings. Idempotent.",
|
||||
@@ -321,6 +337,40 @@ func (s *Server) brainGet(ctx context.Context, args json.RawMessage) (json.RawMe
|
||||
})
|
||||
}
|
||||
|
||||
// brainPending lists the raw/ review queue (oldest-first).
|
||||
func (s *Server) brainPending(_ context.Context, _ json.RawMessage) (json.RawMessage, error) {
|
||||
pending, err := api.ListPending(s.brainDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(map[string]any{"pending": pending})
|
||||
}
|
||||
|
||||
type brainPromoteArgs struct {
|
||||
Filename string `json:"filename"`
|
||||
Wing string `json:"wing"`
|
||||
Hall string `json:"hall"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
}
|
||||
|
||||
// brainPromote moves a raw/ note into the structured wiki (frontmatter
|
||||
// rewrite + index + auto-tunnel, all owned by api.PromoteNote) and then
|
||||
// re-indexes it into the graph. The human-facing complement to brain_write.
|
||||
func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
||||
var a brainPromoteArgs
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return nil, fmt.Errorf("parse args: %w", err)
|
||||
}
|
||||
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
|
||||
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.indexInGraph(ctx, "brain_promote", relPath)
|
||||
return json.Marshal(map[string]string{"path": relPath})
|
||||
}
|
||||
|
||||
// indexInGraph is a best-effort wrapper around graphsync.IndexDoc that
|
||||
// logs failures but never propagates them — the underlying write/ingest
|
||||
// has already succeeded and the graph is an augmentation, not a
|
||||
|
||||
@@ -332,3 +332,61 @@ func TestSessionLogRequiresSessionID(t *testing.T) {
|
||||
resp := toolCall(t, srv, "session_log", map[string]any{"skill": "tdd"})
|
||||
require.NotNil(t, resp["error"])
|
||||
}
|
||||
|
||||
func TestBrainPendingListsRaw(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
raw := filepath.Join(brainDir, "raw")
|
||||
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-06-01-x.md"),
|
||||
[]byte("---\ncreated_at: 2026-06-01T00:00:00Z\n---\npending body\n"), 0o644))
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
|
||||
resp := toolCall(t, srv, "brain_pending", map[string]any{})
|
||||
require.Nil(t, resp["error"])
|
||||
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
assert.Contains(t, text, "2026-06-01-x.md")
|
||||
assert.Contains(t, text, "pending body")
|
||||
}
|
||||
|
||||
func TestBrainPendingEmpty(t *testing.T) {
|
||||
srv := mcp.NewServer(t.TempDir(), nil, nil, nil)
|
||||
resp := toolCall(t, srv, "brain_pending", map[string]any{})
|
||||
require.Nil(t, resp["error"])
|
||||
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
assert.Contains(t, text, `"pending":[]`)
|
||||
}
|
||||
|
||||
func TestBrainPromoteMovesToWiki(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
raw := filepath.Join(brainDir, "raw")
|
||||
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "2026-06-01-decision.md"),
|
||||
[]byte("---\ncreated_at: 2026-06-01T00:00:00Z\n---\n# D\n\nbody\n"), 0o644))
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
|
||||
resp := toolCall(t, srv, "brain_promote", map[string]any{
|
||||
"filename": "2026-06-01-decision.md", "wing": "jepa-fx", "hall": "decisions",
|
||||
})
|
||||
require.Nil(t, resp["error"], "got: %v", resp["error"])
|
||||
text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
assert.Contains(t, text, "wiki/jepa-fx/decisions/decision.md")
|
||||
|
||||
_, err := os.Stat(filepath.Join(brainDir, "wiki/jepa-fx/decisions/decision.md"))
|
||||
require.NoError(t, err)
|
||||
_, srcErr := os.Stat(filepath.Join(raw, "2026-06-01-decision.md"))
|
||||
assert.True(t, os.IsNotExist(srcErr), "source removed")
|
||||
}
|
||||
|
||||
func TestBrainPromoteInvalidHallErrors(t *testing.T) {
|
||||
brainDir := t.TempDir()
|
||||
raw := filepath.Join(brainDir, "raw")
|
||||
require.NoError(t, os.MkdirAll(raw, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(raw, "x.md"), []byte("body\n"), 0o644))
|
||||
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||
resp := toolCall(t, srv, "brain_promote", map[string]any{
|
||||
"filename": "x.md", "wing": "a", "hall": "garbage",
|
||||
})
|
||||
require.NotNil(t, resp["error"])
|
||||
_, srcErr := os.Stat(filepath.Join(raw, "x.md"))
|
||||
assert.NoError(t, srcErr, "source untouched on validation error")
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
||||
// 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,
|
||||
// and capture (the #55 relay tool, registered only when WithCapture is set).
|
||||
// brain_pending, brain_promote, brain_index, brain_tunnel, brain_ingest,
|
||||
// brain_ingest_raw, brain_answer, brain_classify, brain_graph,
|
||||
// brain_context, session_log, and capture (the #55 relay tool, registered
|
||||
// only when WithCapture is set).
|
||||
package mcp
|
||||
|
||||
import (
|
||||
@@ -263,6 +264,10 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
||||
return s.brainUpdate(ctx, args)
|
||||
case "brain_get":
|
||||
return s.brainGet(ctx, args)
|
||||
case "brain_pending":
|
||||
return s.brainPending(ctx, args)
|
||||
case "brain_promote":
|
||||
return s.brainPromote(ctx, args)
|
||||
case "capture":
|
||||
return s.brainCapture(ctx, args)
|
||||
case "brain_index":
|
||||
|
||||
@@ -58,6 +58,7 @@ func TestServerToolsList(t *testing.T) {
|
||||
}
|
||||
assert.ElementsMatch(t, []string{
|
||||
"brain_query", "brain_write", "brain_update", "brain_get",
|
||||
"brain_pending", "brain_promote",
|
||||
"brain_index", "brain_tunnel",
|
||||
"brain_ingest_raw", "brain_ingest",
|
||||
"brain_answer", "brain_classify", "brain_graph", "brain_context",
|
||||
|
||||
Reference in New Issue
Block a user