feat(gui): add missing MCP aliases and webview errors
This commit is contained in:
parent
483c408497
commit
cad4e212c4
8 changed files with 272 additions and 0 deletions
|
|
@ -73,6 +73,7 @@ Response:
|
|||
| `window_maximize` | Maximize window |
|
||||
| `window_minimize` | Minimize window |
|
||||
| `window_focus` | Bring window to front |
|
||||
| `window_title_set` | Alias for `window_title` |
|
||||
|
||||
### WebView Interaction
|
||||
|
||||
|
|
@ -84,6 +85,7 @@ Response:
|
|||
| `webview_screenshot` | Capture page |
|
||||
| `webview_navigate` | Navigate to URL |
|
||||
| `webview_console` | Get console messages |
|
||||
| `webview_errors` | Get structured JavaScript errors |
|
||||
|
||||
### Screen Management
|
||||
|
||||
|
|
@ -93,6 +95,8 @@ Response:
|
|||
| `screen_primary` | Get primary screen |
|
||||
| `screen_at_point` | Get screen at coordinates |
|
||||
| `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
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ import (
|
|||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"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/screen"
|
||||
"forge.lthn.ai/core/gui/pkg/webview"
|
||||
"forge.lthn.ai/core/gui/pkg/window"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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) 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) {
|
||||
c, err := core.New(
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
c, _ := core.New(core.WithServiceLock())
|
||||
// Without any services, QUERY should return handled=false
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"forge.lthn.ai/core/gui/pkg/display"
|
||||
"forge.lthn.ai/core/gui/pkg/screen"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
|
@ -109,6 +111,33 @@ func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _
|
|||
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 ---
|
||||
|
||||
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_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_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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -295,6 +295,29 @@ func (s *Subsystem) webviewConsoleClear(_ context.Context, _ *mcp.CallToolReques
|
|||
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 ---
|
||||
|
||||
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_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_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_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)
|
||||
|
|
|
|||
|
|
@ -276,6 +276,12 @@ func (s *Subsystem) windowTitle(_ context.Context, _ *mcp.CallToolRequest, input
|
|||
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 ---
|
||||
|
||||
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: "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_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_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)
|
||||
|
|
|
|||
|
|
@ -62,6 +62,12 @@ type QueryNetwork struct {
|
|||
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) ---
|
||||
|
||||
// TaskEvaluate executes JavaScript. Result: any (JS return value)
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ type Service struct {
|
|||
*core.ServiceRuntime[Options]
|
||||
opts Options
|
||||
connections map[string]connector
|
||||
exceptions map[string][]ExceptionInfo
|
||||
mu sync.RWMutex
|
||||
newConn func(debugURL, windowName string) (connector, error) // injectable for tests
|
||||
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),
|
||||
opts: o,
|
||||
connections: make(map[string]connector),
|
||||
exceptions: make(map[string][]ExceptionInfo),
|
||||
newConn: defaultNewConn(o),
|
||||
}
|
||||
svc.watcherSetup = svc.defaultWatcherSetup
|
||||
|
|
@ -192,7 +194,10 @@ func (s *Service) HandleIPCEvents(_ *core.Core, msg core.Message) error {
|
|||
conn.Close()
|
||||
delete(s.connections, m.Name)
|
||||
}
|
||||
delete(s.exceptions, m.Name)
|
||||
s.mu.Unlock()
|
||||
case ActionException:
|
||||
s.recordException(m.Window, m.Exception)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -341,6 +346,8 @@ func (s *Service) handleQuery(_ *core.Core, q core.Query) (any, bool, error) {
|
|||
return nil, true, err
|
||||
}
|
||||
return entries, true, nil
|
||||
case QueryExceptions:
|
||||
return s.queryExceptions(q.Window, q.Limit), true, nil
|
||||
default:
|
||||
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) {
|
||||
var out T
|
||||
raw, err := json.Marshal(v)
|
||||
|
|
|
|||
|
|
@ -174,6 +174,29 @@ func TestQueryConsole_Good_Limit(t *testing.T) {
|
|||
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) {
|
||||
_, c := newTestService(t, &mockConnector{evalResult: 42})
|
||||
result, handled, err := c.PERFORM(TaskEvaluate{Window: "main", Script: "21*2"})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue