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:
Snider 2026-02-08 23:12:51 +00:00
parent dd017288e7
commit c494c7b998
5 changed files with 683 additions and 21 deletions

View 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)
}
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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
View 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
}