feat(core-ide): add MCP bridge (SERVER) and Claude bridge (CLIENT)
SERVER bridge (mcp_bridge.go): - HTTP server on :9877 exposing 24 MCP tools - Window management: list, get, position, size, bounds, maximize, minimize, restore, focus, visibility, title, fullscreen, create, close - Webview: eval JS, navigate, list - System: clipboard read/write, tray control - Endpoints: /mcp, /mcp/tools, /mcp/call, /health, /ws, /claude CLIENT bridge (claude_bridge.go): - WebSocket relay between GUI clients and MCP core on :9876 - Auto-reconnect with backoff - Bidirectional message forwarding (claude_message type) Moved HTTP server from IDEService to MCPBridge for unified endpoint. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
dd017288e7
commit
c494c7b998
5 changed files with 683 additions and 21 deletions
171
cmd/core-ide/claude_bridge.go
Normal file
171
cmd/core-ide/claude_bridge.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ module github.com/host-uk/core/cmd/core-ide
|
||||||
go 1.25.5
|
go 1.25.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/host-uk/core v0.0.0
|
github.com/host-uk/core v0.0.0
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.64
|
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/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||||
github.com/google/jsonschema-go v0.4.2 // indirect
|
github.com/google/jsonschema-go v0.4.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // 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/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||||
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
|
||||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package main
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/host-uk/core/pkg/mcp/ide"
|
"github.com/host-uk/core/pkg/mcp/ide"
|
||||||
"github.com/host-uk/core/pkg/ws"
|
"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" }
|
func (s *IDEService) ServiceName() string { return "IDEService" }
|
||||||
|
|
||||||
// ServiceStartup is called when the Wails application starts.
|
// ServiceStartup is called when the Wails application starts.
|
||||||
func (s *IDEService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
func (s *IDEService) ServiceStartup(_ context.Context, _ application.ServiceOptions) error {
|
||||||
// Start WebSocket HTTP server for the Angular frontend
|
|
||||||
go s.startWSServer()
|
|
||||||
log.Println("IDEService started")
|
log.Println("IDEService started")
|
||||||
return nil
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,9 @@ func main() {
|
||||||
chatService := NewChatService(ideSub)
|
chatService := NewChatService(ideSub)
|
||||||
buildService := NewBuildService(ideSub)
|
buildService := NewBuildService(ideSub)
|
||||||
|
|
||||||
|
// Create MCP bridge (SERVER: HTTP tool server + CLIENT: WebSocket relay)
|
||||||
|
mcpBridge := NewMCPBridge(hub, 9877)
|
||||||
|
|
||||||
app := application.New(application.Options{
|
app := application.New(application.Options{
|
||||||
Name: "Core IDE",
|
Name: "Core IDE",
|
||||||
Description: "Host UK Platform IDE - AI Agent Sessions, Build Monitoring & Dashboard",
|
Description: "Host UK Platform IDE - AI Agent Sessions, Build Monitoring & Dashboard",
|
||||||
|
|
@ -50,6 +53,7 @@ func main() {
|
||||||
application.NewService(ideService),
|
application.NewService(ideService),
|
||||||
application.NewService(chatService),
|
application.NewService(chatService),
|
||||||
application.NewService(buildService),
|
application.NewService(buildService),
|
||||||
|
application.NewService(mcpBridge),
|
||||||
},
|
},
|
||||||
Assets: application.AssetOptions{
|
Assets: application.AssetOptions{
|
||||||
Handler: spaHandler(staticAssets),
|
Handler: spaHandler(staticAssets),
|
||||||
|
|
@ -65,7 +69,8 @@ func main() {
|
||||||
|
|
||||||
log.Println("Starting Core IDE...")
|
log.Println("Starting Core IDE...")
|
||||||
log.Println(" - System tray active")
|
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 {
|
if err := app.Run(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
|
|
||||||
504
cmd/core-ide/mcp_bridge.go
Normal file
504
cmd/core-ide/mcp_bridge.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue