From 189ff89c34e4a3b9950b1ddb7a798b2e47bedaed Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Tue, 12 May 2026 11:06:17 +0200 Subject: [PATCH] feat(brain): add brain_answer and brain_classify MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new LLM-backed MCP tools to the ingestion service: - brain_answer(query): BM25 retrieval + LLM synthesis → answer + sources - brain_classify(text): classifies doc into type/title/tags via LLM Adds llm.Router for primary→fallback routing (berget.ai → iguana). Wired via BRAIN_LLM_PRIMARY_URL/BRAIN_LLM_FALLBACK_URL env vars; no-op when unset so existing deployments are unaffected. Co-Authored-By: Claude Sonnet 4.6 --- ingestion/cmd/server/main.go | 20 +++- ingestion/internal/llm/router.go | 29 +++++ ingestion/internal/llm/router_test.go | 71 ++++++++++++ ingestion/internal/mcp/handlers.go | 14 +++ ingestion/internal/mcp/handlers_test.go | 22 ++-- ingestion/internal/mcp/integration_test.go | 2 +- ingestion/internal/mcp/server.go | 16 ++- ingestion/internal/mcp/server_test.go | 11 +- ingestion/internal/mcp/tools_answer.go | 114 ++++++++++++++++++++ ingestion/internal/mcp/tools_answer_test.go | 103 ++++++++++++++++++ 10 files changed, 379 insertions(+), 23 deletions(-) create mode 100644 ingestion/internal/llm/router.go create mode 100644 ingestion/internal/llm/router_test.go create mode 100644 ingestion/internal/mcp/tools_answer.go create mode 100644 ingestion/internal/mcp/tools_answer_test.go diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index 85f4435..65bf27e 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -56,7 +56,25 @@ func main() { h := api.NewHandler(brainDir, logger, pipelineCfg) - mcpSrv := mcp.NewServer(brainDir, &pipelineCfg, llmClient.Complete) + var answerComplete pipeline.CompleteFunc + if primaryURL := os.Getenv("BRAIN_LLM_PRIMARY_URL"); primaryURL != "" { + primaryModel := envOr("BRAIN_LLM_PRIMARY_MODEL", "gemma4:31b") + primaryKey := os.Getenv("BERGET_API_KEY") + timeoutMS := envInt("BRAIN_LLM_TIMEOUT_MS", 10000) + timeout := time.Duration(timeoutMS) * time.Millisecond + + primary := llm.New(primaryURL, primaryKey, primaryModel, timeout) + router := &llm.Router{Primary: primary} + + if fallbackURL := os.Getenv("BRAIN_LLM_FALLBACK_URL"); fallbackURL != "" { + fallbackModel := envOr("BRAIN_LLM_FALLBACK_MODEL", "gemma4:31b") + router.Fallback = llm.New(fallbackURL, "", fallbackModel, timeout) + } + answerComplete = router.Complete + logger.Info("brain answer LLM configured", "primary", primaryURL, "model", primaryModel) + } + + mcpSrv := mcp.NewServer(brainDir, &pipelineCfg, llmClient.Complete, answerComplete) mcpToken := os.Getenv("BRAIN_MCP_TOKEN") if mcpToken == "" { diff --git a/ingestion/internal/llm/router.go b/ingestion/internal/llm/router.go new file mode 100644 index 0000000..ab61f0f --- /dev/null +++ b/ingestion/internal/llm/router.go @@ -0,0 +1,29 @@ +package llm + +import ( + "context" + "fmt" +) + +// Router calls Primary first; on any error falls back to Fallback. +// Fallback may be nil, in which case primary errors are returned directly. +type Router struct { + Primary *Client + Fallback *Client +} + +// Complete implements pipeline.CompleteFunc, routing through Primary then Fallback. +func (r *Router) Complete(ctx context.Context, system, user string) (string, error) { + out, err := r.Primary.Complete(ctx, system, user) + if err == nil { + return out, nil + } + if r.Fallback == nil { + return "", fmt.Errorf("primary llm: %w", err) + } + out, err2 := r.Fallback.Complete(ctx, system, user) + if err2 != nil { + return "", fmt.Errorf("primary llm: %w; fallback llm: %v", err, err2) + } + return out, nil +} diff --git a/ingestion/internal/llm/router_test.go b/ingestion/internal/llm/router_test.go new file mode 100644 index 0000000..04aaa19 --- /dev/null +++ b/ingestion/internal/llm/router_test.go @@ -0,0 +1,71 @@ +package llm + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRouter_PrimarySucceeds(t *testing.T) { + primary := mockServer(t, "from-primary") + defer primary.Close() + fallback := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("fallback must not be called when primary succeeds") + })) + defer fallback.Close() + + r := &Router{ + Primary: New(primary.URL, "", "m", time.Second), + Fallback: New(fallback.URL, "", "m", time.Second), + } + out, err := r.Complete(context.Background(), "sys", "user") + require.NoError(t, err) + assert.Equal(t, "from-primary", out) +} + +func TestRouter_FallsBackOnPrimaryError(t *testing.T) { + primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + })) + defer primary.Close() + fallback := mockServer(t, "from-fallback") + defer fallback.Close() + + r := &Router{ + Primary: New(primary.URL, "", "m", time.Second), + Fallback: New(fallback.URL, "", "m", time.Second), + } + out, err := r.Complete(context.Background(), "sys", "user") + require.NoError(t, err) + assert.Equal(t, "from-fallback", out) +} + +func TestRouter_BothFail(t *testing.T) { + fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "err", http.StatusBadGateway) + })) + defer fail.Close() + + r := &Router{ + Primary: New(fail.URL, "", "m", time.Second), + Fallback: New(fail.URL, "", "m", time.Second), + } + _, err := r.Complete(context.Background(), "sys", "user") + assert.Error(t, err) +} + +func TestRouter_NilFallback(t *testing.T) { + fail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "err", http.StatusBadGateway) + })) + defer fail.Close() + + r := &Router{Primary: New(fail.URL, "", "m", time.Second)} + _, err := r.Complete(context.Background(), "sys", "user") + assert.Error(t, err) +} diff --git a/ingestion/internal/mcp/handlers.go b/ingestion/internal/mcp/handlers.go index 1d3fa9d..05ede71 100644 --- a/ingestion/internal/mcp/handlers.go +++ b/ingestion/internal/mcp/handlers.go @@ -69,6 +69,20 @@ func (s *Server) tools() []map[string]any { "dry_run": map[string]any{"type": "boolean"}, }), }, + { + "name": "brain_answer", + "description": "Retrieve relevant brain content via BM25 and synthesize a coherent answer using an LLM.", + "inputSchema": schema([]string{"query"}, map[string]any{ + "query": str("question to answer"), + }), + }, + { + "name": "brain_classify", + "description": "Classify raw text into doc type, title, and tags using an LLM.", + "inputSchema": schema([]string{"text"}, map[string]any{ + "text": str("raw document text to classify (first 3000 chars used)"), + }), + }, { "name": "session_log", "description": "Append a structured entry to brain/sessions/.jsonl.", diff --git a/ingestion/internal/mcp/handlers_test.go b/ingestion/internal/mcp/handlers_test.go index 6d3fce7..018f28e 100644 --- a/ingestion/internal/mcp/handlers_test.go +++ b/ingestion/internal/mcp/handlers_test.go @@ -40,7 +40,7 @@ func TestBrainQueryReturnsResults(t *testing.T) { 0o644, )) - srv := mcp.NewServer(brainDir, nil, nil) + srv := mcp.NewServer(brainDir, nil, nil, nil) resp := toolCall(t, srv, "brain_query", map[string]any{"query": "tdd"}) require.Nil(t, resp["error"]) @@ -53,7 +53,7 @@ func TestBrainQueryReturnsResults(t *testing.T) { func TestBrainWriteCreatesFile(t *testing.T) { brainDir := t.TempDir() - srv := mcp.NewServer(brainDir, nil, nil) + srv := mcp.NewServer(brainDir, nil, nil, nil) resp := toolCall(t, srv, "brain_write", map[string]any{ "content": "# Test\n\nbody", @@ -72,7 +72,7 @@ func TestBrainWriteCreatesFile(t *testing.T) { func TestBrainWriteRejectsTraversal(t *testing.T) { brainDir := t.TempDir() - srv := mcp.NewServer(brainDir, nil, nil) + srv := mcp.NewServer(brainDir, nil, nil, nil) resp := toolCall(t, srv, "brain_write", map[string]any{ "content": "x", @@ -83,7 +83,7 @@ func TestBrainWriteRejectsTraversal(t *testing.T) { func TestBrainWriteAcceptsDoubleDotInName(t *testing.T) { brainDir := t.TempDir() - srv := mcp.NewServer(brainDir, nil, nil) + srv := mcp.NewServer(brainDir, nil, nil, nil) resp := toolCall(t, srv, "brain_write", map[string]any{ "content": "x", @@ -98,7 +98,7 @@ func TestBrainWriteAcceptsDoubleDotInName(t *testing.T) { func TestBrainIngestRawDryRun(t *testing.T) { brainDir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "wiki", "concepts"), 0o755)) - srv := mcp.NewServer(brainDir, nil, nil) + srv := mcp.NewServer(brainDir, nil, nil, nil) resp := toolCall(t, srv, "brain_ingest_raw", map[string]any{ "source": "test-source", @@ -130,7 +130,7 @@ func TestBrainIngestRawDryRun(t *testing.T) { func TestBrainIngestRejectsBoth(t *testing.T) { brainDir := t.TempDir() - srv := mcp.NewServer(brainDir, nil, nil) + srv := mcp.NewServer(brainDir, nil, nil, nil) resp := toolCall(t, srv, "brain_ingest", map[string]any{ "content": "x", @@ -142,7 +142,7 @@ func TestBrainIngestRejectsBoth(t *testing.T) { func TestBrainIngestRequiresOne(t *testing.T) { brainDir := t.TempDir() - srv := mcp.NewServer(brainDir, nil, nil) + srv := mcp.NewServer(brainDir, nil, nil, nil) resp := toolCall(t, srv, "brain_ingest", map[string]any{}) require.NotNil(t, resp["error"]) @@ -150,7 +150,7 @@ func TestBrainIngestRequiresOne(t *testing.T) { func TestBrainIngestRejectsContentWithoutSource(t *testing.T) { brainDir := t.TempDir() - srv := mcp.NewServer(brainDir, nil, nil) + srv := mcp.NewServer(brainDir, nil, nil, nil) resp := toolCall(t, srv, "brain_ingest", map[string]any{ "content": "x", @@ -160,7 +160,7 @@ func TestBrainIngestRejectsContentWithoutSource(t *testing.T) { func TestBrainIngestRequiresLLMConfigured(t *testing.T) { brainDir := t.TempDir() - srv := mcp.NewServer(brainDir, nil, nil) // nil pipelineCfg → no LLM + srv := mcp.NewServer(brainDir, nil, nil, nil) // nil pipelineCfg → no LLM resp := toolCall(t, srv, "brain_ingest", map[string]any{ "content": "some content", @@ -173,7 +173,7 @@ func TestBrainIngestRequiresLLMConfigured(t *testing.T) { func TestSessionLogAppends(t *testing.T) { brainDir := t.TempDir() - srv := mcp.NewServer(brainDir, nil, nil) + srv := mcp.NewServer(brainDir, nil, nil, nil) resp := toolCall(t, srv, "session_log", map[string]any{ "session_id": "session-x", @@ -190,7 +190,7 @@ func TestSessionLogAppends(t *testing.T) { } func TestSessionLogRequiresSessionID(t *testing.T) { - srv := mcp.NewServer(t.TempDir(), nil, nil) + srv := mcp.NewServer(t.TempDir(), nil, nil, nil) resp := toolCall(t, srv, "session_log", map[string]any{"skill": "tdd"}) require.NotNil(t, resp["error"]) } diff --git a/ingestion/internal/mcp/integration_test.go b/ingestion/internal/mcp/integration_test.go index 98f1735..e03a13f 100644 --- a/ingestion/internal/mcp/integration_test.go +++ b/ingestion/internal/mcp/integration_test.go @@ -14,7 +14,7 @@ import ( ) func TestMCPMountedHandler(t *testing.T) { - srv := mcp.NewServer(t.TempDir(), nil, nil) + srv := mcp.NewServer(t.TempDir(), nil, nil, nil) mux := http.NewServeMux() mux.Handle("POST /mcp", srv) diff --git a/ingestion/internal/mcp/server.go b/ingestion/internal/mcp/server.go index 26c0983..8f4f58e 100644 --- a/ingestion/internal/mcp/server.go +++ b/ingestion/internal/mcp/server.go @@ -32,19 +32,21 @@ type rpcError struct { // Server handles MCP JSON-RPC over HTTP for the ingestion service. type Server struct { - brainDir string - pipeline pipeline.Config - llm pipeline.CompleteFunc + brainDir string + pipeline pipeline.Config + llm pipeline.CompleteFunc + answerLLM pipeline.CompleteFunc // nil = brain_answer and brain_classify unavailable } // NewServer constructs a Server bound to brainDir. pipelineCfg supplies the // LLM-backed pipeline; llm may be nil for non-LLM tools only. -func NewServer(brainDir string, pipelineCfg *pipeline.Config, llm pipeline.CompleteFunc) *Server { +// answerLLM drives brain_answer and brain_classify; nil disables those tools. +func NewServer(brainDir string, pipelineCfg *pipeline.Config, llm pipeline.CompleteFunc, answerLLM pipeline.CompleteFunc) *Server { cfg := pipeline.Config{} if pipelineCfg != nil { cfg = *pipelineCfg } - return &Server{brainDir: brainDir, pipeline: cfg, llm: llm} + return &Server{brainDir: brainDir, pipeline: cfg, llm: llm, answerLLM: answerLLM} } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -140,6 +142,10 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa return s.brainIngest(ctx, args) case "session_log": return s.sessionLog(ctx, args) + case "brain_answer": + return s.brainAnswer(ctx, args) + case "brain_classify": + return s.brainClassify(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 cf36b51..2918b09 100644 --- a/ingestion/internal/mcp/server_test.go +++ b/ingestion/internal/mcp/server_test.go @@ -21,7 +21,7 @@ func body(t *testing.T, v any) *bytes.Buffer { } func TestServerInitialize(t *testing.T) { - srv := mcp.NewServer(t.TempDir(), nil, nil) + srv := mcp.NewServer(t.TempDir(), nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{ "jsonrpc": "2.0", "id": 1, "method": "initialize", @@ -38,7 +38,7 @@ func TestServerInitialize(t *testing.T) { } func TestServerToolsList(t *testing.T) { - srv := mcp.NewServer(t.TempDir(), nil, nil) + srv := mcp.NewServer(t.TempDir(), nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", @@ -55,12 +55,13 @@ func TestServerToolsList(t *testing.T) { names = append(names, t.(map[string]any)["name"].(string)) } assert.ElementsMatch(t, []string{ - "brain_query", "brain_write", "brain_ingest_raw", "brain_ingest", "session_log", + "brain_query", "brain_write", "brain_ingest_raw", "brain_ingest", + "brain_answer", "brain_classify", "session_log", }, names) } func TestServerNotificationGetsNoBody(t *testing.T) { - srv := mcp.NewServer(t.TempDir(), nil, nil) + srv := mcp.NewServer(t.TempDir(), nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{ "jsonrpc": "2.0", "method": "notifications/initialized", @@ -73,7 +74,7 @@ func TestServerNotificationGetsNoBody(t *testing.T) { } func TestServerUnknownMethodReturnsError(t *testing.T) { - srv := mcp.NewServer(t.TempDir(), nil, nil) + srv := mcp.NewServer(t.TempDir(), nil, nil, nil) req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{ "jsonrpc": "2.0", "id": 3, "method": "unknown/method", diff --git a/ingestion/internal/mcp/tools_answer.go b/ingestion/internal/mcp/tools_answer.go new file mode 100644 index 0000000..edfb299 --- /dev/null +++ b/ingestion/internal/mcp/tools_answer.go @@ -0,0 +1,114 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/mathiasbq/hyperguild/ingestion/internal/search" +) + +const ( + answerSystemPrompt = `You are a knowledge assistant. Answer the question using ONLY the provided sources. +Cite source file paths inline when referencing specific content. +If the context does not contain enough information to answer, say so clearly.` + + classifySystemPrompt = `Classify the document. Respond with JSON only, no markdown fences. +{"type":"...","title":"...","tags":["..."]} +Valid types: spec, plan, decision, note, wiki, log, code, unknown.` +) + +type brainAnswerArgs struct { + Query string `json:"query"` +} + +func (s *Server) brainAnswer(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + if s.answerLLM == nil { + return nil, fmt.Errorf("answer LLM not configured: set BRAIN_LLM_PRIMARY_URL") + } + var a brainAnswerArgs + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + if a.Query == "" { + return nil, fmt.Errorf("query is required") + } + + results, err := search.Query(s.brainDir, a.Query, 10) + if err != nil { + return nil, fmt.Errorf("search: %w", err) + } + if len(results) == 0 { + return json.Marshal(map[string]any{ + "answer": "No relevant content found in brain.", + "sources": []string{}, + }) + } + + var sb strings.Builder + sources := make([]string, 0, len(results)) + for _, r := range results { + fmt.Fprintf(&sb, "\n%s\n\n\n", r.Path, r.Excerpt) + sources = append(sources, r.Path) + } + + answer, err := s.answerLLM(ctx, answerSystemPrompt, sb.String()+"Question: "+a.Query) + if err != nil { + return nil, fmt.Errorf("llm: %w", err) + } + + return json.Marshal(map[string]any{ + "answer": answer, + "sources": sources, + }) +} + +type brainClassifyArgs struct { + Text string `json:"text"` +} + +type classifyResult struct { + Type string `json:"type"` + Title string `json:"title"` + Tags []string `json:"tags"` +} + +func (s *Server) brainClassify(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + if s.answerLLM == nil { + return nil, fmt.Errorf("answer LLM not configured: set BRAIN_LLM_PRIMARY_URL") + } + var a brainClassifyArgs + if err := json.Unmarshal(args, &a); err != nil { + return nil, fmt.Errorf("parse args: %w", err) + } + if a.Text == "" { + return nil, fmt.Errorf("text is required") + } + + text := a.Text + if len(text) > 3000 { + text = text[:3000] + } + + raw, err := s.answerLLM(ctx, classifySystemPrompt, text) + if err != nil { + return nil, fmt.Errorf("llm: %w", err) + } + + // Strip markdown fences if model adds them despite the instruction. + raw = strings.TrimSpace(raw) + raw = strings.TrimPrefix(raw, "```json") + raw = strings.TrimPrefix(raw, "```") + raw = strings.TrimSuffix(raw, "```") + raw = strings.TrimSpace(raw) + + var cr classifyResult + if err := json.Unmarshal([]byte(raw), &cr); err != nil { + return nil, fmt.Errorf("parse classify response %q: %w", raw, err) + } + if cr.Tags == nil { + cr.Tags = []string{} + } + return json.Marshal(cr) +} diff --git a/ingestion/internal/mcp/tools_answer_test.go b/ingestion/internal/mcp/tools_answer_test.go new file mode 100644 index 0000000..baa5f78 --- /dev/null +++ b/ingestion/internal/mcp/tools_answer_test.go @@ -0,0 +1,103 @@ +package mcp_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/mathiasbq/hyperguild/ingestion/internal/mcp" + "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mockAnswerLLM(response string) pipeline.CompleteFunc { + return func(_ context.Context, _, _ string) (string, error) { + return response, nil + } +} + +func brainDirWithContent(t *testing.T) string { + t.Helper() + dir := t.TempDir() + wikiDir := filepath.Join(dir, "wiki") + require.NoError(t, os.MkdirAll(wikiDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(wikiDir, "test.md"), []byte( + "---\ntitle: Pass-rate Logging\ntype: spec\n---\n\nPass-rate logging tracks skill invocations.", + ), 0o644)) + return dir +} + +func callTool(t *testing.T, ts *httptest.Server, name string, arguments map[string]any) map[string]any { + t.Helper() + req := map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": map[string]any{"name": name, "arguments": arguments}, + } + resp, err := http.Post(ts.URL, "application/json", body(t, req)) + require.NoError(t, err) + defer resp.Body.Close() //nolint:errcheck + var out map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&out)) + return out +} + +func TestBrainAnswer_NoLLM(t *testing.T) { + srv := mcp.NewServer(t.TempDir(), nil, nil, nil) + ts := httptest.NewServer(srv) + defer ts.Close() + + rpc := callTool(t, ts, "brain_answer", map[string]any{"query": "test"}) + assert.NotNil(t, rpc["error"], "expected error when answerLLM is nil") +} + +func TestBrainAnswer_Synthesizes(t *testing.T) { + brainDir := brainDirWithContent(t) + srv := mcp.NewServer(brainDir, nil, nil, mockAnswerLLM("Pass-rate logging is described in spec.")) + ts := httptest.NewServer(srv) + defer ts.Close() + + rpc := callTool(t, ts, "brain_answer", map[string]any{"query": "pass-rate logging"}) + require.Nil(t, rpc["error"]) + + content := rpc["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(content), &result)) + assert.Equal(t, "Pass-rate logging is described in spec.", result["answer"]) + assert.NotEmpty(t, result["sources"]) +} + +func TestBrainClassify_ReturnsJSON(t *testing.T) { + llmResp := `{"type":"spec","title":"My Spec","tags":["go","mcp"]}` + srv := mcp.NewServer(t.TempDir(), nil, nil, mockAnswerLLM(llmResp)) + ts := httptest.NewServer(srv) + defer ts.Close() + + rpc := callTool(t, ts, "brain_classify", map[string]any{"text": "# My Spec\n\nThis is a Go MCP spec."}) + require.Nil(t, rpc["error"]) + + content := rpc["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(content), &result)) + assert.Equal(t, "spec", result["type"]) + assert.Equal(t, "My Spec", result["title"]) +} + +func TestBrainClassify_StripsFences(t *testing.T) { + llmResp := "```json\n{\"type\":\"note\",\"title\":\"T\",\"tags\":[]}\n```" + srv := mcp.NewServer(t.TempDir(), nil, nil, mockAnswerLLM(llmResp)) + ts := httptest.NewServer(srv) + defer ts.Close() + + rpc := callTool(t, ts, "brain_classify", map[string]any{"text": "some text"}) + require.Nil(t, rpc["error"]) + + content := rpc["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + var result map[string]any + require.NoError(t, json.Unmarshal([]byte(content), &result)) + assert.Equal(t, "note", result["type"]) +}