feat(gui): add missing MCP aliases and webview errors
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run

This commit is contained in:
Virgil 2026-04-02 19:09:10 +00:00
parent 483c408497
commit cad4e212c4
8 changed files with 272 additions and 0 deletions

View file

@ -73,6 +73,7 @@ Response:
| `window_maximize` | Maximize window | | `window_maximize` | Maximize window |
| `window_minimize` | Minimize window | | `window_minimize` | Minimize window |
| `window_focus` | Bring window to front | | `window_focus` | Bring window to front |
| `window_title_set` | Alias for `window_title` |
### WebView Interaction ### WebView Interaction
@ -84,6 +85,7 @@ Response:
| `webview_screenshot` | Capture page | | `webview_screenshot` | Capture page |
| `webview_navigate` | Navigate to URL | | `webview_navigate` | Navigate to URL |
| `webview_console` | Get console messages | | `webview_console` | Get console messages |
| `webview_errors` | Get structured JavaScript errors |
### Screen Management ### Screen Management
@ -93,6 +95,8 @@ Response:
| `screen_primary` | Get primary screen | | `screen_primary` | Get primary screen |
| `screen_at_point` | Get screen at coordinates | | `screen_at_point` | Get screen at coordinates |
| `screen_work_areas` | Get usable screen space | | `screen_work_areas` | Get usable screen space |
| `screen_work_area` | Alias for `screen_work_areas` |
| `screen_for_window` | Get the screen containing a window |
### Layout Management ### Layout Management

View file

@ -7,7 +7,11 @@ import (
"forge.lthn.ai/core/go/pkg/core" "forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/clipboard" "forge.lthn.ai/core/gui/pkg/clipboard"
"forge.lthn.ai/core/gui/pkg/display"
"forge.lthn.ai/core/gui/pkg/notification" "forge.lthn.ai/core/gui/pkg/notification"
"forge.lthn.ai/core/gui/pkg/screen"
"forge.lthn.ai/core/gui/pkg/webview"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@ -50,6 +54,23 @@ func (m *mockNotificationPlatform) Send(opts notification.NotificationOptions) e
func (m *mockNotificationPlatform) RequestPermission() (bool, error) { return true, nil } func (m *mockNotificationPlatform) RequestPermission() (bool, error) { return true, nil }
func (m *mockNotificationPlatform) CheckPermission() (bool, error) { return true, nil } func (m *mockNotificationPlatform) CheckPermission() (bool, error) { return true, nil }
type mockScreenPlatform struct {
screens []screen.Screen
}
func (m *mockScreenPlatform) GetAll() []screen.Screen { return m.screens }
func (m *mockScreenPlatform) GetPrimary() *screen.Screen {
for i := range m.screens {
if m.screens[i].IsPrimary {
return &m.screens[i]
}
}
if len(m.screens) == 0 {
return nil
}
return &m.screens[0]
}
func TestMCP_Good_ClipboardRoundTrip(t *testing.T) { func TestMCP_Good_ClipboardRoundTrip(t *testing.T) {
c, err := core.New( c, err := core.New(
core.WithService(clipboard.Register(&mockClipPlatform{text: "hello", ok: true})), core.WithService(clipboard.Register(&mockClipPlatform{text: "hello", ok: true})),
@ -88,6 +109,133 @@ func TestMCP_Good_DialogMessage(t *testing.T) {
assert.Equal(t, notification.SeverityError, mock.lastOpts.Severity) assert.Equal(t, notification.SeverityError, mock.lastOpts.Severity)
} }
func TestMCP_Good_WindowTitleSetAlias(t *testing.T) {
c, err := core.New(
core.WithService(window.Register(window.NewMockPlatform())),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
_, handled, err := c.PERFORM(window.TaskOpenWindow{
Window: &window.Window{Name: "alias-win", Title: "Original", URL: "/"},
})
require.NoError(t, err)
assert.True(t, handled)
sub := New(c)
_, result, err := sub.windowTitleSet(context.Background(), nil, WindowTitleInput{
Name: "alias-win",
Title: "Updated",
})
require.NoError(t, err)
assert.True(t, result.Success)
queried, handled, err := c.QUERY(window.QueryWindowByName{Name: "alias-win"})
require.NoError(t, err)
assert.True(t, handled)
info, ok := queried.(*window.WindowInfo)
require.True(t, ok)
require.NotNil(t, info)
assert.Equal(t, "Updated", info.Title)
}
func TestMCP_Good_ScreenWorkAreaAlias(t *testing.T) {
c, err := core.New(
core.WithService(screen.Register(&mockScreenPlatform{
screens: []screen.Screen{
{
ID: "1",
Name: "Primary",
IsPrimary: true,
WorkArea: screen.Rect{X: 0, Y: 24, Width: 1920, Height: 1056},
Bounds: screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
Size: screen.Size{Width: 1920, Height: 1080},
},
},
})),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
sub := New(c)
_, plural, err := sub.screenWorkAreas(context.Background(), nil, ScreenWorkAreasInput{})
require.NoError(t, err)
_, alias, err := sub.screenWorkArea(context.Background(), nil, ScreenWorkAreasInput{})
require.NoError(t, err)
assert.Equal(t, plural, alias)
assert.Len(t, alias.WorkAreas, 1)
assert.Equal(t, 24, alias.WorkAreas[0].Y)
}
func TestMCP_Good_ScreenForWindow(t *testing.T) {
c, err := core.New(
core.WithService(display.Register(nil)),
core.WithService(screen.Register(&mockScreenPlatform{
screens: []screen.Screen{
{
ID: "1",
Name: "Primary",
IsPrimary: true,
WorkArea: screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
Bounds: screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
Size: screen.Size{Width: 1920, Height: 1080},
},
{
ID: "2",
Name: "Secondary",
WorkArea: screen.Rect{X: 1920, Y: 0, Width: 1280, Height: 1024},
Bounds: screen.Rect{X: 1920, Y: 0, Width: 1280, Height: 1024},
Size: screen.Size{Width: 1280, Height: 1024},
},
},
})),
core.WithService(window.Register(window.NewMockPlatform())),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
_, handled, err := c.PERFORM(window.TaskOpenWindow{
Window: &window.Window{Name: "editor", Title: "Editor", X: 100, Y: 100, Width: 800, Height: 600},
})
require.NoError(t, err)
assert.True(t, handled)
sub := New(c)
_, out, err := sub.screenForWindow(context.Background(), nil, ScreenForWindowInput{Window: "editor"})
require.NoError(t, err)
require.NotNil(t, out.Screen)
assert.Equal(t, "Primary", out.Screen.Name)
}
func TestMCP_Good_WebviewErrors(t *testing.T) {
c, err := core.New(
core.WithService(webview.Register()),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
require.NoError(t, c.ACTION(webview.ActionException{
Window: "main",
Exception: webview.ExceptionInfo{
Text: "boom",
URL: "https://example.com/app.js",
Line: 12,
Column: 4,
StackTrace: "Error: boom",
},
}))
sub := New(c)
_, out, err := sub.webviewErrors(context.Background(), nil, WebviewErrorsInput{Window: "main"})
require.NoError(t, err)
require.Len(t, out.Errors, 1)
assert.Equal(t, "boom", out.Errors[0].Text)
}
func TestMCP_Bad_NoServices(t *testing.T) { func TestMCP_Bad_NoServices(t *testing.T) {
c, _ := core.New(core.WithServiceLock()) c, _ := core.New(core.WithServiceLock())
// Without any services, QUERY should return handled=false // Without any services, QUERY should return handled=false

View file

@ -5,6 +5,8 @@ import (
"context" "context"
"fmt" "fmt"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/display"
"forge.lthn.ai/core/gui/pkg/screen" "forge.lthn.ai/core/gui/pkg/screen"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@ -109,6 +111,33 @@ func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _
return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil
} }
// --- screen_work_area ---
func (s *Subsystem) screenWorkArea(ctx context.Context, req *mcp.CallToolRequest, input ScreenWorkAreasInput) (*mcp.CallToolResult, ScreenWorkAreasOutput, error) {
return s.screenWorkAreas(ctx, req, input)
}
// --- screen_for_window ---
type ScreenForWindowInput struct {
Window string `json:"window"`
}
type ScreenForWindowOutput struct {
Screen *screen.Screen `json:"screen"`
}
func (s *Subsystem) screenForWindow(_ context.Context, _ *mcp.CallToolRequest, input ScreenForWindowInput) (*mcp.CallToolResult, ScreenForWindowOutput, error) {
svc, err := core.ServiceFor[*display.Service](s.core, "display")
if err != nil {
return nil, ScreenForWindowOutput{}, err
}
scr, err := svc.GetScreenForWindow(input.Window)
if err != nil {
return nil, ScreenForWindowOutput{}, err
}
return nil, ScreenForWindowOutput{Screen: scr}, nil
}
// --- Registration --- // --- Registration ---
func (s *Subsystem) registerScreenTools(server *mcp.Server) { func (s *Subsystem) registerScreenTools(server *mcp.Server) {
@ -117,4 +146,6 @@ func (s *Subsystem) registerScreenTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "screen_primary", Description: "Get the primary screen"}, s.screenPrimary) mcp.AddTool(server, &mcp.Tool{Name: "screen_primary", Description: "Get the primary screen"}, s.screenPrimary)
mcp.AddTool(server, &mcp.Tool{Name: "screen_at_point", Description: "Get the screen at a specific point"}, s.screenAtPoint) mcp.AddTool(server, &mcp.Tool{Name: "screen_at_point", Description: "Get the screen at a specific point"}, s.screenAtPoint)
mcp.AddTool(server, &mcp.Tool{Name: "screen_work_areas", Description: "Get work areas for all screens"}, s.screenWorkAreas) mcp.AddTool(server, &mcp.Tool{Name: "screen_work_areas", Description: "Get work areas for all screens"}, s.screenWorkAreas)
mcp.AddTool(server, &mcp.Tool{Name: "screen_work_area", Description: "Alias for screen_work_areas"}, s.screenWorkArea)
mcp.AddTool(server, &mcp.Tool{Name: "screen_for_window", Description: "Get the screen containing a window"}, s.screenForWindow)
} }

View file

@ -295,6 +295,29 @@ func (s *Subsystem) webviewConsoleClear(_ context.Context, _ *mcp.CallToolReques
return nil, WebviewConsoleClearOutput{Success: true}, nil return nil, WebviewConsoleClearOutput{Success: true}, nil
} }
// --- webview_errors ---
type WebviewErrorsInput struct {
Window string `json:"window"`
Limit int `json:"limit,omitempty"`
}
type WebviewErrorsOutput struct {
Errors []webview.ExceptionInfo `json:"errors"`
}
func (s *Subsystem) webviewErrors(_ context.Context, _ *mcp.CallToolRequest, input WebviewErrorsInput) (*mcp.CallToolResult, WebviewErrorsOutput, error) {
result, _, err := s.core.QUERY(webview.QueryExceptions{Window: input.Window, Limit: input.Limit})
if err != nil {
return nil, WebviewErrorsOutput{}, err
}
errors, ok := result.([]webview.ExceptionInfo)
if !ok {
return nil, WebviewErrorsOutput{}, fmt.Errorf("unexpected result type from webview errors query")
}
return nil, WebviewErrorsOutput{Errors: errors}, nil
}
// --- webview_devtools_open --- // --- webview_devtools_open ---
type WebviewDevToolsOpenInput struct { type WebviewDevToolsOpenInput struct {
@ -659,6 +682,7 @@ func (s *Subsystem) registerWebviewTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "webview_console", Description: "Get captured console messages from a webview"}, s.webviewConsole) mcp.AddTool(server, &mcp.Tool{Name: "webview_console", Description: "Get captured console messages from a webview"}, s.webviewConsole)
mcp.AddTool(server, &mcp.Tool{Name: "webview_console_clear", Description: "Clear captured console messages"}, s.webviewConsoleClear) mcp.AddTool(server, &mcp.Tool{Name: "webview_console_clear", Description: "Clear captured console messages"}, s.webviewConsoleClear)
mcp.AddTool(server, &mcp.Tool{Name: "webview_clear_console", Description: "Alias for webview_console_clear"}, s.webviewConsoleClear) mcp.AddTool(server, &mcp.Tool{Name: "webview_clear_console", Description: "Alias for webview_console_clear"}, s.webviewConsoleClear)
mcp.AddTool(server, &mcp.Tool{Name: "webview_errors", Description: "Get captured JavaScript exceptions from a webview"}, s.webviewErrors)
mcp.AddTool(server, &mcp.Tool{Name: "webview_query", Description: "Find a single DOM element by CSS selector"}, s.webviewQuery) mcp.AddTool(server, &mcp.Tool{Name: "webview_query", Description: "Find a single DOM element by CSS selector"}, s.webviewQuery)
mcp.AddTool(server, &mcp.Tool{Name: "webview_element_info", Description: "Get detailed information about a DOM element"}, s.webviewElementInfo) mcp.AddTool(server, &mcp.Tool{Name: "webview_element_info", Description: "Get detailed information about a DOM element"}, s.webviewElementInfo)
mcp.AddTool(server, &mcp.Tool{Name: "webview_query_all", Description: "Find all DOM elements matching a CSS selector"}, s.webviewQueryAll) mcp.AddTool(server, &mcp.Tool{Name: "webview_query_all", Description: "Find all DOM elements matching a CSS selector"}, s.webviewQueryAll)

View file

@ -276,6 +276,12 @@ func (s *Subsystem) windowTitle(_ context.Context, _ *mcp.CallToolRequest, input
return nil, WindowTitleOutput{Success: true}, nil return nil, WindowTitleOutput{Success: true}, nil
} }
// --- window_title_set ---
func (s *Subsystem) windowTitleSet(ctx context.Context, req *mcp.CallToolRequest, input WindowTitleInput) (*mcp.CallToolResult, WindowTitleOutput, error) {
return s.windowTitle(ctx, req, input)
}
// --- window_title_get --- // --- window_title_get ---
type WindowTitleGetInput struct { type WindowTitleGetInput struct {
@ -409,6 +415,7 @@ func (s *Subsystem) registerWindowTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "window_focus", Description: "Bring a window to the front"}, s.windowFocus) mcp.AddTool(server, &mcp.Tool{Name: "window_focus", Description: "Bring a window to the front"}, s.windowFocus)
mcp.AddTool(server, &mcp.Tool{Name: "focus_set", Description: "Alias for window_focus"}, s.windowFocus) mcp.AddTool(server, &mcp.Tool{Name: "focus_set", Description: "Alias for window_focus"}, s.windowFocus)
mcp.AddTool(server, &mcp.Tool{Name: "window_title", Description: "Set the title of a window"}, s.windowTitle) mcp.AddTool(server, &mcp.Tool{Name: "window_title", Description: "Set the title of a window"}, s.windowTitle)
mcp.AddTool(server, &mcp.Tool{Name: "window_title_set", Description: "Alias for window_title"}, s.windowTitleSet)
mcp.AddTool(server, &mcp.Tool{Name: "window_title_get", Description: "Get the title of a window"}, s.windowTitleGet) mcp.AddTool(server, &mcp.Tool{Name: "window_title_get", Description: "Get the title of a window"}, s.windowTitleGet)
mcp.AddTool(server, &mcp.Tool{Name: "window_visibility", Description: "Show or hide a window"}, s.windowVisibility) mcp.AddTool(server, &mcp.Tool{Name: "window_visibility", Description: "Show or hide a window"}, s.windowVisibility)
mcp.AddTool(server, &mcp.Tool{Name: "window_always_on_top", Description: "Pin a window above others"}, s.windowAlwaysOnTop) mcp.AddTool(server, &mcp.Tool{Name: "window_always_on_top", Description: "Pin a window above others"}, s.windowAlwaysOnTop)

View file

@ -62,6 +62,12 @@ type QueryNetwork struct {
Limit int `json:"limit,omitempty"` Limit int `json:"limit,omitempty"`
} }
// QueryExceptions returns captured JavaScript exceptions.
type QueryExceptions struct {
Window string `json:"window"`
Limit int `json:"limit,omitempty"`
}
// --- Tasks (side-effects) --- // --- Tasks (side-effects) ---
// TaskEvaluate executes JavaScript. Result: any (JS return value) // TaskEvaluate executes JavaScript. Result: any (JS return value)

View file

@ -60,6 +60,7 @@ type Service struct {
*core.ServiceRuntime[Options] *core.ServiceRuntime[Options]
opts Options opts Options
connections map[string]connector connections map[string]connector
exceptions map[string][]ExceptionInfo
mu sync.RWMutex mu sync.RWMutex
newConn func(debugURL, windowName string) (connector, error) // injectable for tests newConn func(debugURL, windowName string) (connector, error) // injectable for tests
watcherSetup func(conn connector, windowName string) // called after connection creation watcherSetup func(conn connector, windowName string) // called after connection creation
@ -80,6 +81,7 @@ func Register(opts ...func(*Options)) func(*core.Core) (any, error) {
ServiceRuntime: core.NewServiceRuntime[Options](c, o), ServiceRuntime: core.NewServiceRuntime[Options](c, o),
opts: o, opts: o,
connections: make(map[string]connector), connections: make(map[string]connector),
exceptions: make(map[string][]ExceptionInfo),
newConn: defaultNewConn(o), newConn: defaultNewConn(o),
} }
svc.watcherSetup = svc.defaultWatcherSetup svc.watcherSetup = svc.defaultWatcherSetup
@ -192,7 +194,10 @@ func (s *Service) HandleIPCEvents(_ *core.Core, msg core.Message) error {
conn.Close() conn.Close()
delete(s.connections, m.Name) delete(s.connections, m.Name)
} }
delete(s.exceptions, m.Name)
s.mu.Unlock() s.mu.Unlock()
case ActionException:
s.recordException(m.Window, m.Exception)
} }
return nil return nil
} }
@ -341,6 +346,8 @@ func (s *Service) handleQuery(_ *core.Core, q core.Query) (any, bool, error) {
return nil, true, err return nil, true, err
} }
return entries, true, nil return entries, true, nil
case QueryExceptions:
return s.queryExceptions(q.Window, q.Limit), true, nil
default: default:
return nil, false, nil return nil, false, nil
} }
@ -510,6 +517,28 @@ func (s *Service) handleTask(_ *core.Core, t core.Task) (any, bool, error) {
} }
} }
func (s *Service) recordException(windowName string, exc ExceptionInfo) {
s.mu.Lock()
defer s.mu.Unlock()
exceptions := append(s.exceptions[windowName], exc)
if limit := s.opts.ConsoleLimit; limit > 0 && len(exceptions) > limit {
exceptions = exceptions[len(exceptions)-limit:]
}
s.exceptions[windowName] = exceptions
}
func (s *Service) queryExceptions(windowName string, limit int) []ExceptionInfo {
s.mu.RLock()
defer s.mu.RUnlock()
exceptions := append([]ExceptionInfo(nil), s.exceptions[windowName]...)
if limit > 0 && len(exceptions) > limit {
exceptions = exceptions[len(exceptions)-limit:]
}
return exceptions
}
func coerceJSON[T any](v any) (T, error) { func coerceJSON[T any](v any) (T, error) {
var out T var out T
raw, err := json.Marshal(v) raw, err := json.Marshal(v)

View file

@ -174,6 +174,29 @@ func TestQueryConsole_Good_Limit(t *testing.T) {
assert.Equal(t, "b", msgs[0].Text) // last 2 assert.Equal(t, "b", msgs[0].Text) // last 2
} }
func TestQueryExceptions_Good(t *testing.T) {
_, c := newTestService(t, &mockConnector{})
require.NoError(t, c.ACTION(ActionException{
Window: "main",
Exception: ExceptionInfo{
Text: "boom",
URL: "https://example.com/app.js",
Line: 12,
Column: 4,
StackTrace: "Error: boom",
},
}))
result, handled, err := c.QUERY(QueryExceptions{Window: "main"})
require.NoError(t, err)
assert.True(t, handled)
exceptions, _ := result.([]ExceptionInfo)
require.Len(t, exceptions, 1)
assert.Equal(t, "boom", exceptions[0].Text)
assert.Equal(t, 12, exceptions[0].Line)
}
func TestTaskEvaluate_Good(t *testing.T) { func TestTaskEvaluate_Good(t *testing.T) {
_, c := newTestService(t, &mockConnector{evalResult: 42}) _, c := newTestService(t, &mockConnector{evalResult: 42})
result, handled, err := c.PERFORM(TaskEvaluate{Window: "main", Script: "21*2"}) result, handled, err := c.PERFORM(TaskEvaluate{Window: "main", Script: "21*2"})