diff --git a/cmd/mcpcmd/cmd_mcp.go b/cmd/mcpcmd/cmd_mcp.go index 6f2a02f..5e03ee4 100644 --- a/cmd/mcpcmd/cmd_mcp.go +++ b/cmd/mcpcmd/cmd_mcp.go @@ -12,6 +12,7 @@ import ( "forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/mcp/pkg/mcp" + "forge.lthn.ai/core/mcp/pkg/mcp/brain" ) var workspaceFlag string @@ -69,6 +70,9 @@ func runServe() error { opts = append(opts, mcp.WithWorkspaceRoot("")) } + // Register OpenBrain subsystem (direct HTTP to api.lthn.sh) + opts = append(opts, mcp.WithSubsystem(brain.NewDirect())) + // Create the MCP service svc, err := mcp.New(opts...) if err != nil { diff --git a/pkg/mcp/brain/direct.go b/pkg/mcp/brain/direct.go new file mode 100644 index 0000000..aa90e0d --- /dev/null +++ b/pkg/mcp/brain/direct.go @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package brain + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// DirectSubsystem implements mcp.Subsystem for OpenBrain via direct HTTP calls. +// Unlike Subsystem (which uses the IDE WebSocket bridge), this calls the +// Laravel API directly — suitable for standalone core-mcp usage. +type DirectSubsystem struct { + apiURL string + apiKey string + client *http.Client +} + +// NewDirect creates a brain subsystem that calls the OpenBrain API directly. +// Reads CORE_BRAIN_URL and CORE_BRAIN_KEY from environment, or falls back +// to ~/.claude/brain.key for the API key. +func NewDirect() *DirectSubsystem { + apiURL := os.Getenv("CORE_BRAIN_URL") + if apiURL == "" { + apiURL = "https://api.lthn.sh" + } + + apiKey := os.Getenv("CORE_BRAIN_KEY") + if apiKey == "" { + if data, err := os.ReadFile(os.ExpandEnv("$HOME/.claude/brain.key")); err == nil { + apiKey = strings.TrimSpace(string(data)) + } + } + + return &DirectSubsystem{ + apiURL: apiURL, + apiKey: apiKey, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +// Name implements mcp.Subsystem. +func (s *DirectSubsystem) Name() string { return "brain" } + +// RegisterTools implements mcp.Subsystem. +func (s *DirectSubsystem) RegisterTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "brain_remember", + Description: "Store a memory in OpenBrain. Types: fact, decision, observation, plan, convention, architecture, research, documentation, service, bug, pattern, context, procedure.", + }, s.remember) + + mcp.AddTool(server, &mcp.Tool{ + Name: "brain_recall", + Description: "Semantic search across OpenBrain memories. Returns memories ranked by similarity. Use agent_id 'cladius' for Cladius's memories.", + }, s.recall) + + mcp.AddTool(server, &mcp.Tool{ + Name: "brain_forget", + Description: "Remove a memory from OpenBrain by ID.", + }, s.forget) +} + +// Shutdown implements mcp.SubsystemWithShutdown. +func (s *DirectSubsystem) Shutdown(_ context.Context) error { return nil } + +func (s *DirectSubsystem) apiCall(ctx context.Context, method, path string, body any) (map[string]any, error) { + if s.apiKey == "" { + return nil, fmt.Errorf("brain: no API key (set CORE_BRAIN_KEY or create ~/.claude/brain.key)") + } + + var reqBody io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("brain: marshal request: %w", err) + } + reqBody = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, s.apiURL+path, reqBody) + if err != nil { + return nil, fmt.Errorf("brain: create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Bearer "+s.apiKey) + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("brain: API call failed: %w", err) + } + defer resp.Body.Close() + + respData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("brain: read response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("brain: API returned %d: %s", resp.StatusCode, string(respData)) + } + + var result map[string]any + if err := json.Unmarshal(respData, &result); err != nil { + return nil, fmt.Errorf("brain: parse response: %w", err) + } + + return result, nil +} + +func (s *DirectSubsystem) remember(ctx context.Context, _ *mcp.CallToolRequest, input RememberInput) (*mcp.CallToolResult, RememberOutput, error) { + result, err := s.apiCall(ctx, "POST", "/v1/brain/remember", map[string]any{ + "content": input.Content, + "type": input.Type, + "tags": input.Tags, + "project": input.Project, + "agent_id": "cladius", + }) + if err != nil { + return nil, RememberOutput{}, err + } + + id, _ := result["id"].(string) + return nil, RememberOutput{ + Success: true, + MemoryID: id, + Timestamp: time.Now(), + }, nil +} + +func (s *DirectSubsystem) recall(ctx context.Context, _ *mcp.CallToolRequest, input RecallInput) (*mcp.CallToolResult, RecallOutput, error) { + body := map[string]any{ + "query": input.Query, + "top_k": input.TopK, + "agent_id": "cladius", + } + if input.Filter.Project != "" { + body["project"] = input.Filter.Project + } + if input.Filter.Type != nil { + body["type"] = input.Filter.Type + } + if input.TopK == 0 { + body["top_k"] = 10 + } + + result, err := s.apiCall(ctx, "POST", "/v1/brain/recall", body) + if err != nil { + return nil, RecallOutput{}, err + } + + var memories []Memory + if mems, ok := result["memories"].([]any); ok { + for _, m := range mems { + if mm, ok := m.(map[string]any); ok { + mem := Memory{ + Content: fmt.Sprintf("%v", mm["content"]), + Type: fmt.Sprintf("%v", mm["type"]), + Project: fmt.Sprintf("%v", mm["project"]), + AgentID: fmt.Sprintf("%v", mm["agent_id"]), + CreatedAt: fmt.Sprintf("%v", mm["created_at"]), + } + if id, ok := mm["id"].(string); ok { + mem.ID = id + } + memories = append(memories, mem) + } + } + } + + return nil, RecallOutput{ + Success: true, + Count: len(memories), + Memories: memories, + }, nil +} + +func (s *DirectSubsystem) forget(ctx context.Context, _ *mcp.CallToolRequest, input ForgetInput) (*mcp.CallToolResult, ForgetOutput, error) { + _, err := s.apiCall(ctx, "DELETE", "/v1/brain/forget/"+input.ID, nil) + if err != nil { + return nil, ForgetOutput{}, err + } + + return nil, ForgetOutput{ + Success: true, + Forgotten: input.ID, + Timestamp: time.Now(), + }, nil +}