diff --git a/cmd/core-ide/claude_bridge.go b/cmd/core-ide/claude_bridge.go new file mode 100644 index 00000000..dc00585c --- /dev/null +++ b/cmd/core-ide/claude_bridge.go @@ -0,0 +1,171 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +var wsUpgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +// ClaudeBridge forwards messages between GUI clients and the MCP core WebSocket. +// This is the CLIENT bridge — it connects to the MCP core process on port 9876 +// and relays messages bidirectionally with connected GUI WebSocket clients. +type ClaudeBridge struct { + mcpConn *websocket.Conn + mcpURL string + clients map[*websocket.Conn]bool + clientsMu sync.RWMutex + broadcast chan []byte + reconnectMu sync.Mutex + connected bool +} + +// NewClaudeBridge creates a new bridge to the MCP core WebSocket. +func NewClaudeBridge(mcpURL string) *ClaudeBridge { + return &ClaudeBridge{ + mcpURL: mcpURL, + clients: make(map[*websocket.Conn]bool), + broadcast: make(chan []byte, 256), + } +} + +// Connected reports whether the bridge is connected to MCP core. +func (cb *ClaudeBridge) Connected() bool { + cb.reconnectMu.Lock() + defer cb.reconnectMu.Unlock() + return cb.connected +} + +// Start connects to the MCP WebSocket and starts the bridge. +func (cb *ClaudeBridge) Start() { + go cb.connectToMCP() + go cb.broadcastLoop() +} + +// connectToMCP establishes connection to the MCP core WebSocket. +func (cb *ClaudeBridge) connectToMCP() { + for { + cb.reconnectMu.Lock() + if cb.mcpConn != nil { + cb.mcpConn.Close() + } + + log.Printf("ide bridge: connect to MCP at %s", cb.mcpURL) + conn, _, err := websocket.DefaultDialer.Dial(cb.mcpURL, nil) + if err != nil { + log.Printf("ide bridge: connect failed: %v", err) + cb.connected = false + cb.reconnectMu.Unlock() + time.Sleep(5 * time.Second) + continue + } + + cb.mcpConn = conn + cb.connected = true + cb.reconnectMu.Unlock() + log.Println("ide bridge: connected to MCP core") + + // Read messages from MCP and broadcast to GUI clients + for { + _, message, err := conn.ReadMessage() + if err != nil { + log.Printf("ide bridge: MCP read error: %v", err) + break + } + cb.broadcast <- message + } + + cb.reconnectMu.Lock() + cb.connected = false + cb.reconnectMu.Unlock() + + // Connection lost, retry after delay + time.Sleep(2 * time.Second) + } +} + +// broadcastLoop sends messages from MCP core to all connected GUI clients. +func (cb *ClaudeBridge) broadcastLoop() { + for message := range cb.broadcast { + cb.clientsMu.RLock() + for client := range cb.clients { + if err := client.WriteMessage(websocket.TextMessage, message); err != nil { + log.Printf("ide bridge: client write error: %v", err) + } + } + cb.clientsMu.RUnlock() + } +} + +// HandleWebSocket handles WebSocket connections from GUI clients. +func (cb *ClaudeBridge) HandleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := wsUpgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("ide bridge: upgrade error: %v", err) + return + } + + cb.clientsMu.Lock() + cb.clients[conn] = true + cb.clientsMu.Unlock() + + // Send connected message + connMsg, _ := json.Marshal(map[string]any{ + "type": "system", + "data": "Connected to Claude bridge", + "timestamp": time.Now(), + }) + conn.WriteMessage(websocket.TextMessage, connMsg) + + defer func() { + cb.clientsMu.Lock() + delete(cb.clients, conn) + cb.clientsMu.Unlock() + conn.Close() + }() + + // Read messages from GUI client and forward to MCP core + for { + _, message, err := conn.ReadMessage() + if err != nil { + break + } + + // Parse the message to check type + var msg map[string]any + if err := json.Unmarshal(message, &msg); err != nil { + continue + } + + // Forward claude_message to MCP core + if msgType, ok := msg["type"].(string); ok && msgType == "claude_message" { + cb.sendToMCP(message) + } + } +} + +// sendToMCP sends a message to the MCP WebSocket. +func (cb *ClaudeBridge) sendToMCP(message []byte) { + cb.reconnectMu.Lock() + defer cb.reconnectMu.Unlock() + + if cb.mcpConn == nil { + log.Println("ide bridge: MCP not connected, dropping message") + return + } + + if err := cb.mcpConn.WriteMessage(websocket.TextMessage, message); err != nil { + log.Printf("ide bridge: MCP write error: %v", err) + } +} diff --git a/cmd/core-ide/go.mod b/cmd/core-ide/go.mod index 626ea74d..cc76825a 100644 --- a/cmd/core-ide/go.mod +++ b/cmd/core-ide/go.mod @@ -3,6 +3,7 @@ module github.com/host-uk/core/cmd/core-ide go 1.25.5 require ( + github.com/gorilla/websocket v1.5.3 github.com/host-uk/core v0.0.0 github.com/wailsapp/wails/v3 v3.0.0-alpha.64 ) @@ -26,7 +27,6 @@ require ( github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect diff --git a/cmd/core-ide/ide_service.go b/cmd/core-ide/ide_service.go index fca137ca..157ec25e 100644 --- a/cmd/core-ide/ide_service.go +++ b/cmd/core-ide/ide_service.go @@ -3,7 +3,6 @@ package main import ( "context" "log" - "net/http" "github.com/host-uk/core/pkg/mcp/ide" "github.com/host-uk/core/pkg/ws" @@ -26,9 +25,7 @@ func NewIDEService(ideSub *ide.Subsystem, hub *ws.Hub) *IDEService { func (s *IDEService) ServiceName() string { return "IDEService" } // ServiceStartup is called when the Wails application starts. -func (s *IDEService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { - // Start WebSocket HTTP server for the Angular frontend - go s.startWSServer() +func (s *IDEService) ServiceStartup(_ context.Context, _ application.ServiceOptions) error { log.Println("IDEService started") return nil } @@ -85,18 +82,3 @@ func (s *IDEService) ShowWindow(name string) { } } -// startWSServer starts the WebSocket HTTP server for the Angular frontend. -func (s *IDEService) startWSServer() { - mux := http.NewServeMux() - mux.HandleFunc("/ws", s.hub.HandleWebSocket) - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"ok"}`)) - }) - - addr := "127.0.0.1:9877" - log.Printf("IDE WebSocket server listening on %s", addr) - if err := http.ListenAndServe(addr, mux); err != nil { - log.Printf("IDE WebSocket server error: %v", err) - } -} diff --git a/cmd/core-ide/main.go b/cmd/core-ide/main.go index 18bfa942..992e9c7f 100644 --- a/cmd/core-ide/main.go +++ b/cmd/core-ide/main.go @@ -43,6 +43,9 @@ func main() { chatService := NewChatService(ideSub) buildService := NewBuildService(ideSub) + // Create MCP bridge (SERVER: HTTP tool server + CLIENT: WebSocket relay) + mcpBridge := NewMCPBridge(hub, 9877) + app := application.New(application.Options{ Name: "Core IDE", Description: "Host UK Platform IDE - AI Agent Sessions, Build Monitoring & Dashboard", @@ -50,6 +53,7 @@ func main() { application.NewService(ideService), application.NewService(chatService), application.NewService(buildService), + application.NewService(mcpBridge), }, Assets: application.AssetOptions{ Handler: spaHandler(staticAssets), @@ -65,7 +69,8 @@ func main() { log.Println("Starting Core IDE...") log.Println(" - System tray active") - log.Println(" - Bridge connecting to Laravel core-agentic...") + log.Println(" - MCP bridge (SERVER) on :9877") + log.Println(" - Claude bridge (CLIENT) → MCP core on :9876") if err := app.Run(); err != nil { log.Fatal(err) diff --git a/cmd/core-ide/mcp_bridge.go b/cmd/core-ide/mcp_bridge.go new file mode 100644 index 00000000..9132acb2 --- /dev/null +++ b/cmd/core-ide/mcp_bridge.go @@ -0,0 +1,504 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + + "github.com/host-uk/core/pkg/ws" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// MCPBridge is the SERVER bridge that exposes MCP tools via HTTP. +// AI agents call these endpoints to control windows, execute JS in webviews, +// access the clipboard, show notifications, and query the app state. +type MCPBridge struct { + app *application.App + hub *ws.Hub + claudeBridge *ClaudeBridge + port int + running bool + mu sync.Mutex +} + +// NewMCPBridge creates a new MCP bridge server. +func NewMCPBridge(hub *ws.Hub, port int) *MCPBridge { + cb := NewClaudeBridge("ws://localhost:9876/ws") + return &MCPBridge{ + hub: hub, + claudeBridge: cb, + port: port, + } +} + +// ServiceName returns the Wails service name. +func (b *MCPBridge) ServiceName() string { return "MCPBridge" } + +// ServiceStartup is called by Wails when the app starts. +func (b *MCPBridge) ServiceStartup(_ context.Context, _ application.ServiceOptions) error { + b.app = application.Get() + go b.startHTTPServer() + log.Printf("MCP Bridge started on port %d", b.port) + return nil +} + +// ServiceShutdown is called when the app shuts down. +func (b *MCPBridge) ServiceShutdown() error { + b.mu.Lock() + defer b.mu.Unlock() + b.running = false + return nil +} + +// startHTTPServer starts the HTTP server for MCP tools and WebSocket. +func (b *MCPBridge) startHTTPServer() { + b.mu.Lock() + b.running = true + b.mu.Unlock() + + // Start the Claude bridge (CLIENT → MCP core on :9876) + b.claudeBridge.Start() + + mux := http.NewServeMux() + + // WebSocket endpoint for Angular frontend + mux.HandleFunc("/ws", b.hub.HandleWebSocket) + + // Claude bridge WebSocket relay (GUI clients ↔ MCP core) + mux.HandleFunc("/claude", b.claudeBridge.HandleWebSocket) + + // MCP server endpoints + mux.HandleFunc("/mcp", b.handleMCPInfo) + mux.HandleFunc("/mcp/tools", b.handleMCPTools) + mux.HandleFunc("/mcp/call", b.handleMCPCall) + + // Health check + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "mcp": true, + "claudeBridge": b.claudeBridge.Connected(), + }) + }) + + addr := fmt.Sprintf("127.0.0.1:%d", b.port) + log.Printf("MCP HTTP server listening on %s", addr) + + if err := http.ListenAndServe(addr, mux); err != nil { + log.Printf("MCP HTTP server error: %v", err) + } +} + +// handleMCPInfo returns MCP server information. +func (b *MCPBridge) handleMCPInfo(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + + json.NewEncoder(w).Encode(map[string]any{ + "name": "core-ide", + "version": "0.1.0", + "capabilities": map[string]any{ + "webview": true, + "windowControl": true, + "clipboard": true, + "notifications": true, + "websocket": fmt.Sprintf("ws://localhost:%d/ws", b.port), + "claude": fmt.Sprintf("ws://localhost:%d/claude", b.port), + }, + }) +} + +// handleMCPTools returns the list of available tools. +func (b *MCPBridge) handleMCPTools(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + + tools := []map[string]string{ + // Window management + {"name": "window_list", "description": "List all windows with positions and sizes"}, + {"name": "window_get", "description": "Get info about a specific window"}, + {"name": "window_position", "description": "Move a window to specific coordinates"}, + {"name": "window_size", "description": "Resize a window"}, + {"name": "window_bounds", "description": "Set position and size in one call"}, + {"name": "window_maximize", "description": "Maximize a window"}, + {"name": "window_minimize", "description": "Minimize a window"}, + {"name": "window_restore", "description": "Restore from maximized/minimized"}, + {"name": "window_focus", "description": "Bring window to front"}, + {"name": "window_visibility", "description": "Show or hide a window"}, + {"name": "window_title", "description": "Change window title"}, + {"name": "window_title_get", "description": "Get current window title"}, + {"name": "window_fullscreen", "description": "Toggle fullscreen mode"}, + {"name": "window_always_on_top", "description": "Pin window above others"}, + {"name": "window_create", "description": "Create a new window at specific position"}, + {"name": "window_close", "description": "Close a window by name"}, + {"name": "window_background_colour", "description": "Set window background colour with alpha"}, + // Webview interaction + {"name": "webview_eval", "description": "Execute JavaScript in a window's webview"}, + {"name": "webview_navigate", "description": "Navigate window to a URL"}, + {"name": "webview_list", "description": "List windows with webview info"}, + // System integration + {"name": "clipboard_read", "description": "Read text from system clipboard"}, + {"name": "clipboard_write", "description": "Write text to system clipboard"}, + // System tray + {"name": "tray_set_tooltip", "description": "Set system tray tooltip"}, + {"name": "tray_set_label", "description": "Set system tray label"}, + } + json.NewEncoder(w).Encode(map[string]any{"tools": tools}) +} + +// handleMCPCall handles tool calls via HTTP POST. +func (b *MCPBridge) handleMCPCall(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Tool string `json:"tool"` + Params map[string]any `json:"params"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var result map[string]any + if len(req.Tool) > 8 && req.Tool[:8] == "webview_" { + result = b.executeWebviewTool(req.Tool, req.Params) + } else { + result = b.executeWindowTool(req.Tool, req.Params) + } + json.NewEncoder(w).Encode(result) +} + +// executeWindowTool handles window, clipboard, tray, and notification tools. +func (b *MCPBridge) executeWindowTool(tool string, params map[string]any) map[string]any { + if b.app == nil { + return map[string]any{"error": "app not available"} + } + + switch tool { + case "window_list": + return b.windowList() + + case "window_get": + name := strParam(params, "name") + return b.windowGet(name) + + case "window_position": + name := strParam(params, "name") + x := intParam(params, "x") + y := intParam(params, "y") + w, ok := b.app.Window.Get(name) + if !ok { + return map[string]any{"error": "window not found", "name": name} + } + w.SetPosition(x, y) + return map[string]any{"success": true, "name": name, "x": x, "y": y} + + case "window_size": + name := strParam(params, "name") + width := intParam(params, "width") + height := intParam(params, "height") + w, ok := b.app.Window.Get(name) + if !ok { + return map[string]any{"error": "window not found", "name": name} + } + w.SetSize(width, height) + return map[string]any{"success": true, "name": name, "width": width, "height": height} + + case "window_bounds": + name := strParam(params, "name") + x := intParam(params, "x") + y := intParam(params, "y") + width := intParam(params, "width") + height := intParam(params, "height") + w, ok := b.app.Window.Get(name) + if !ok { + return map[string]any{"error": "window not found", "name": name} + } + w.SetPosition(x, y) + w.SetSize(width, height) + return map[string]any{"success": true, "name": name, "x": x, "y": y, "width": width, "height": height} + + case "window_maximize": + name := strParam(params, "name") + w, ok := b.app.Window.Get(name) + if !ok { + return map[string]any{"error": "window not found", "name": name} + } + w.Maximise() + return map[string]any{"success": true, "action": "maximize"} + + case "window_minimize": + name := strParam(params, "name") + w, ok := b.app.Window.Get(name) + if !ok { + return map[string]any{"error": "window not found", "name": name} + } + w.Minimise() + return map[string]any{"success": true, "action": "minimize"} + + case "window_restore": + name := strParam(params, "name") + w, ok := b.app.Window.Get(name) + if !ok { + return map[string]any{"error": "window not found", "name": name} + } + w.Restore() + return map[string]any{"success": true, "action": "restore"} + + case "window_focus": + name := strParam(params, "name") + w, ok := b.app.Window.Get(name) + if !ok { + return map[string]any{"error": "window not found", "name": name} + } + w.Show() + w.Focus() + return map[string]any{"success": true, "action": "focus"} + + case "window_visibility": + name := strParam(params, "name") + visible, _ := params["visible"].(bool) + w, ok := b.app.Window.Get(name) + if !ok { + return map[string]any{"error": "window not found", "name": name} + } + if visible { + w.Show() + } else { + w.Hide() + } + return map[string]any{"success": true, "visible": visible} + + case "window_title": + name := strParam(params, "name") + title := strParam(params, "title") + w, ok := b.app.Window.Get(name) + if !ok { + return map[string]any{"error": "window not found", "name": name} + } + w.SetTitle(title) + return map[string]any{"success": true, "title": title} + + case "window_title_get": + name := strParam(params, "name") + _, ok := b.app.Window.Get(name) + if !ok { + return map[string]any{"error": "window not found", "name": name} + } + // Wails v3 Window interface has SetTitle but no Title getter; + // return the window name as a fallback identifier. + return map[string]any{"name": name} + + case "window_fullscreen": + name := strParam(params, "name") + fullscreen, _ := params["fullscreen"].(bool) + w, ok := b.app.Window.Get(name) + if !ok { + return map[string]any{"error": "window not found", "name": name} + } + if fullscreen { + w.Fullscreen() + } else { + w.UnFullscreen() + } + return map[string]any{"success": true, "fullscreen": fullscreen} + + case "window_always_on_top": + name := strParam(params, "name") + onTop, _ := params["onTop"].(bool) + w, ok := b.app.Window.Get(name) + if !ok { + return map[string]any{"error": "window not found", "name": name} + } + w.SetAlwaysOnTop(onTop) + return map[string]any{"success": true, "alwaysOnTop": onTop} + + case "window_create": + name := strParam(params, "name") + title := strParam(params, "title") + url := strParam(params, "url") + x := intParam(params, "x") + y := intParam(params, "y") + width := intParam(params, "width") + height := intParam(params, "height") + if width == 0 { + width = 800 + } + if height == 0 { + height = 600 + } + opts := application.WebviewWindowOptions{ + Name: name, + Title: title, + URL: url, + Width: width, + Height: height, + Hidden: false, + BackgroundColour: application.NewRGB(22, 27, 34), + } + w := b.app.Window.NewWithOptions(opts) + if x != 0 || y != 0 { + w.SetPosition(x, y) + } + return map[string]any{"success": true, "name": name} + + case "window_close": + name := strParam(params, "name") + w, ok := b.app.Window.Get(name) + if !ok { + return map[string]any{"error": "window not found", "name": name} + } + w.Close() + return map[string]any{"success": true, "action": "close"} + + case "window_background_colour": + name := strParam(params, "name") + r := uint8(intParam(params, "r")) + g := uint8(intParam(params, "g")) + bv := uint8(intParam(params, "b")) + a := uint8(intParam(params, "a")) + if a == 0 { + a = 255 + } + w, ok := b.app.Window.Get(name) + if !ok { + return map[string]any{"error": "window not found", "name": name} + } + w.SetBackgroundColour(application.NewRGBA(r, g, bv, a)) + return map[string]any{"success": true} + + case "clipboard_read": + text, ok := b.app.Clipboard.Text() + if !ok { + return map[string]any{"error": "failed to read clipboard"} + } + return map[string]any{"text": text} + + case "clipboard_write": + text, _ := params["text"].(string) + ok := b.app.Clipboard.SetText(text) + if !ok { + return map[string]any{"error": "failed to write clipboard"} + } + return map[string]any{"success": true} + + case "tray_set_tooltip": + // System tray is managed at startup; this is informational + return map[string]any{"info": "tray tooltip can be set via system tray menu"} + + case "tray_set_label": + return map[string]any{"info": "tray label can be set via system tray menu"} + + default: + return map[string]any{"error": "unknown tool", "tool": tool} + } +} + +// executeWebviewTool handles webview/JS tools. +func (b *MCPBridge) executeWebviewTool(tool string, params map[string]any) map[string]any { + if b.app == nil { + return map[string]any{"error": "app not available"} + } + + switch tool { + case "webview_eval": + windowName := strParam(params, "window") + code := strParam(params, "code") + w, ok := b.app.Window.Get(windowName) + if !ok { + return map[string]any{"error": "window not found", "window": windowName} + } + w.ExecJS(code) + return map[string]any{"success": true, "window": windowName} + + case "webview_navigate": + windowName := strParam(params, "window") + url := strParam(params, "url") + w, ok := b.app.Window.Get(windowName) + if !ok { + return map[string]any{"error": "window not found", "window": windowName} + } + w.SetURL(url) + return map[string]any{"success": true, "url": url} + + case "webview_list": + return b.windowList() + + default: + return map[string]any{"error": "unknown webview tool", "tool": tool} + } +} + +// windowList returns info for all known windows. +func (b *MCPBridge) windowList() map[string]any { + knownNames := []string{"tray-panel", "main", "settings"} + var windows []map[string]any + for _, name := range knownNames { + w, ok := b.app.Window.Get(name) + if !ok { + continue + } + x, y := w.Position() + width, height := w.Size() + windows = append(windows, map[string]any{ + "name": name, + "title": w.Name(), + "x": x, + "y": y, + "width": width, + "height": height, + }) + } + return map[string]any{"windows": windows} +} + +// windowGet returns info for a specific window. +func (b *MCPBridge) windowGet(name string) map[string]any { + w, ok := b.app.Window.Get(name) + if !ok { + return map[string]any{"error": "window not found", "name": name} + } + x, y := w.Position() + width, height := w.Size() + return map[string]any{ + "window": map[string]any{ + "name": name, + "title": w.Name(), + "x": x, + "y": y, + "width": width, + "height": height, + }, + } +} + +// Parameter helpers +func strParam(params map[string]any, key string) string { + if v, ok := params[key].(string); ok { + return v + } + return "" +} + +func intParam(params map[string]any, key string) int { + if v, ok := params[key].(float64); ok { + return int(v) + } + return 0 +}