diff --git a/ingestion/internal/mcp/handlers.go b/ingestion/internal/mcp/handlers.go new file mode 100644 index 0000000..6e64a2f --- /dev/null +++ b/ingestion/internal/mcp/handlers.go @@ -0,0 +1,75 @@ +package mcp + +import "encoding/json" + +// tools returns the tool descriptors. Handler bodies for each tool are filled +// in subsequent tasks; this file currently only provides the descriptors. +func (s *Server) tools() []map[string]any { + str := func(desc string) map[string]any { + return map[string]any{"type": "string", "description": desc} + } + int_ := func(desc string) map[string]any { + return map[string]any{"type": "integer", "description": desc} + } + schema := func(required []string, props map[string]any) json.RawMessage { + b, _ := json.Marshal(map[string]any{ + "type": "object", "required": required, "properties": props, + }) + return b + } + + return []map[string]any{ + { + "name": "brain_query", + "description": "BM25 full-text search across brain/knowledge/ and brain/wiki/ markdown files.", + "inputSchema": schema([]string{"query"}, map[string]any{ + "query": str("search terms"), + "limit": int_("max results, default 5"), + }), + }, + { + "name": "brain_write", + "description": "Write a raw knowledge note to brain/knowledge/.", + "inputSchema": schema([]string{"content"}, map[string]any{ + "content": str("markdown content"), + "filename": str("optional filename"), + "type": str("optional frontmatter type"), + "domain": str("optional frontmatter domain"), + }), + }, + { + "name": "brain_ingest_raw", + "description": "Ingest pre-structured pages into the brain wiki, bypassing the LLM extraction step.", + "inputSchema": schema([]string{"source", "pages"}, map[string]any{ + "source": str("source name"), + "pages": map[string]any{"type": "array"}, + "dry_run": map[string]any{"type": "boolean"}, + }), + }, + { + "name": "brain_ingest", + "description": "Ingest content into the brain wiki via the LLM extraction pipeline.", + "inputSchema": schema([]string{}, map[string]any{ + "content": str("raw content; required when path is empty"), + "source": str("source name; required when path is empty"), + "path": str("file path; mutually exclusive with content+source"), + "dry_run": map[string]any{"type": "boolean"}, + }), + }, + { + "name": "session_log", + "description": "Append a structured entry to brain/sessions/.jsonl.", + "inputSchema": schema([]string{"session_id"}, map[string]any{ + "session_id": str("session identifier"), + "skill": str("skill name"), + "phase": str("phase within the skill"), + "project_root": str("absolute project root"), + "final_status": str("ok | error | skipped"), + "file_path": str("optional file produced"), + "model_used": str("optional model identifier"), + "duration_ms": int_("optional duration in ms"), + "message": str("optional free-text"), + }), + }, + } +} diff --git a/ingestion/internal/mcp/server.go b/ingestion/internal/mcp/server.go new file mode 100644 index 0000000..3266928 --- /dev/null +++ b/ingestion/internal/mcp/server.go @@ -0,0 +1,119 @@ +// Package mcp implements an MCP HTTP handler for the ingestion service. +// Exposed tools: brain_query, brain_write, brain_ingest, brain_ingest_raw, session_log. +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" +) + +type request struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +type response struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// Server handles MCP JSON-RPC over HTTP for the ingestion service. +type Server struct { + brainDir string + pipeline pipeline.Config + llm pipeline.CompleteFunc +} + +// 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 { + cfg := pipeline.Config{} + if pipelineCfg != nil { + cfg = *pipelineCfg + } + return &Server{brainDir: brainDir, pipeline: cfg, llm: llm} +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var req request + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, nil, -32700, "parse error") + return + } + + // JSON-RPC 2.0 notifications (no id) must not receive a response. + if req.ID == nil { + return + } + + var result any + var rpcErr *rpcError + + switch req.Method { + case "initialize": + result = map[string]any{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]any{"tools": map[string]any{}}, + "serverInfo": map[string]any{"name": "ingestion-brain", "version": "0.1.0"}, + } + + case "tools/list": + result = map[string]any{"tools": s.tools()} + + case "tools/call": + var p struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` + } + if err := json.Unmarshal(req.Params, &p); err != nil { + rpcErr = &rpcError{Code: -32602, Message: "invalid params"} + break + } + out, err := s.handleCall(r.Context(), p.Name, p.Arguments) + if err != nil { + rpcErr = &rpcError{Code: -32000, Message: err.Error()} + break + } + result = map[string]any{ + "content": []map[string]any{{"type": "text", "text": string(out)}}, + } + + default: + rpcErr = &rpcError{Code: -32601, Message: "method not found: " + req.Method} + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response{ + JSONRPC: "2.0", + ID: req.ID, + Result: result, + Error: rpcErr, + }) +} + +func writeError(w http.ResponseWriter, id any, code int, msg string) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response{ + JSONRPC: "2.0", + ID: id, + Error: &rpcError{Code: code, Message: msg}, + }) +} + +// handleCall dispatches a tools/call. Stub for Task 1; expanded in later tasks. +func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessage) (json.RawMessage, error) { + return nil, fmt.Errorf("unknown tool: %s", name) +} diff --git a/ingestion/internal/mcp/server_test.go b/ingestion/internal/mcp/server_test.go new file mode 100644 index 0000000..cf36b51 --- /dev/null +++ b/ingestion/internal/mcp/server_test.go @@ -0,0 +1,91 @@ +package mcp_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mathiasbq/hyperguild/ingestion/internal/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func body(t *testing.T, v any) *bytes.Buffer { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + return bytes.NewBuffer(b) +} + +func TestServerInitialize(t *testing.T) { + srv := mcp.NewServer(t.TempDir(), nil, nil) + + req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": map[string]any{}, + })) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + result := resp["result"].(map[string]any) + assert.Equal(t, "2024-11-05", result["protocolVersion"]) +} + +func TestServerToolsList(t *testing.T) { + srv := mcp.NewServer(t.TempDir(), nil, nil) + + req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{ + "jsonrpc": "2.0", "id": 2, "method": "tools/list", + })) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + tools := resp["result"].(map[string]any)["tools"].([]any) + names := make([]string, 0, len(tools)) + for _, t := range tools { + names = append(names, t.(map[string]any)["name"].(string)) + } + assert.ElementsMatch(t, []string{ + "brain_query", "brain_write", "brain_ingest_raw", "brain_ingest", "session_log", + }, names) +} + +func TestServerNotificationGetsNoBody(t *testing.T) { + srv := mcp.NewServer(t.TempDir(), nil, nil) + + req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{ + "jsonrpc": "2.0", "method": "notifications/initialized", + })) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Empty(t, strings.TrimSpace(rr.Body.String())) +} + +func TestServerUnknownMethodReturnsError(t *testing.T) { + srv := mcp.NewServer(t.TempDir(), nil, nil) + + req := httptest.NewRequest(http.MethodPost, "/mcp", body(t, map[string]any{ + "jsonrpc": "2.0", "id": 3, "method": "unknown/method", + })) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + require.NotNil(t, resp["error"]) + errObj := resp["error"].(map[string]any) + assert.Equal(t, float64(-32601), errObj["code"]) + assert.Contains(t, errObj["message"].(string), "unknown/method") +}