From 7dcb5610fe419dccfd53dc46b78acdf34ecddb1e Mon Sep 17 00:00:00 2001 From: Mathias Bergqvist Date: Fri, 1 May 2026 09:56:40 +0200 Subject: [PATCH] feat(ingestion): implement brain_query MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the existing search.Query function. Same BM25 over brain/knowledge/ and brain/wiki/ that the HTTP /query endpoint serves. Plan note: handleCall switch replaces the single-line stub from Task 1 — no unknownToolError type to remove since Task 1 inlined the error. Co-Authored-By: Claude Opus 4.7 (1M context) --- ingestion/internal/mcp/handlers.go | 31 ++++++++++++++- ingestion/internal/mcp/handlers_test.go | 52 +++++++++++++++++++++++++ ingestion/internal/mcp/server.go | 9 ++++- 3 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 ingestion/internal/mcp/handlers_test.go diff --git a/ingestion/internal/mcp/handlers.go b/ingestion/internal/mcp/handlers.go index 6e64a2f..0e18284 100644 --- a/ingestion/internal/mcp/handlers.go +++ b/ingestion/internal/mcp/handlers.go @@ -1,6 +1,12 @@ package mcp -import "encoding/json" +import ( + "context" + "encoding/json" + "fmt" + + "github.com/mathiasbq/hyperguild/ingestion/internal/search" +) // tools returns the tool descriptors. Handler bodies for each tool are filled // in subsequent tasks; this file currently only provides the descriptors. @@ -73,3 +79,26 @@ func (s *Server) tools() []map[string]any { }, } } + +type brainQueryArgs struct { + Query string `json:"query"` + Limit int `json:"limit,omitempty"` +} + +func (s *Server) brainQuery(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { + var a brainQueryArgs + 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") + } + if a.Limit == 0 { + a.Limit = 5 + } + results, err := search.Query(s.brainDir, a.Query, a.Limit) + if err != nil { + return nil, fmt.Errorf("search: %w", err) + } + return json.Marshal(map[string]any{"results": results}) +} diff --git a/ingestion/internal/mcp/handlers_test.go b/ingestion/internal/mcp/handlers_test.go new file mode 100644 index 0000000..e926e6b --- /dev/null +++ b/ingestion/internal/mcp/handlers_test.go @@ -0,0 +1,52 @@ +package mcp_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/mathiasbq/hyperguild/ingestion/internal/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func toolCall(t *testing.T, srv http.Handler, name string, args map[string]any) map[string]any { + t.Helper() + bodyBytes, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": map[string]any{"name": name, "arguments": args}, + }) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(bodyBytes)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + return resp +} + +func TestBrainQueryReturnsResults(t *testing.T) { + brainDir := t.TempDir() + knowledge := filepath.Join(brainDir, "knowledge") + require.NoError(t, os.MkdirAll(knowledge, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(knowledge, "tdd.md"), + []byte("# TDD\n\nTest-driven development is iterative.\n"), + 0o644, + )) + + srv := mcp.NewServer(brainDir, nil, nil) + resp := toolCall(t, srv, "brain_query", map[string]any{"query": "tdd"}) + + require.Nil(t, resp["error"]) + result := resp["result"].(map[string]any) + content := result["content"].([]any) + require.NotEmpty(t, content) + text := content[0].(map[string]any)["text"].(string) + assert.Contains(t, text, "tdd.md") +} diff --git a/ingestion/internal/mcp/server.go b/ingestion/internal/mcp/server.go index 3266928..e955fe7 100644 --- a/ingestion/internal/mcp/server.go +++ b/ingestion/internal/mcp/server.go @@ -113,7 +113,12 @@ func writeError(w http.ResponseWriter, id any, code int, msg string) { }) } -// handleCall dispatches a tools/call. Stub for Task 1; expanded in later tasks. +// handleCall dispatches a tools/call to the appropriate tool handler. func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessage) (json.RawMessage, error) { - return nil, fmt.Errorf("unknown tool: %s", name) + switch name { + case "brain_query": + return s.brainQuery(ctx, args) + default: + return nil, fmt.Errorf("unknown tool: %s", name) + } }