diff --git a/docs/framework/mcp-bridge.md b/docs/framework/mcp-bridge.md index d654a3e..b4b0e44 100644 --- a/docs/framework/mcp-bridge.md +++ b/docs/framework/mcp-bridge.md @@ -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 diff --git a/pkg/mcp/mcp_test.go b/pkg/mcp/mcp_test.go index 9a6d78d..9ac26df 100644 --- a/pkg/mcp/mcp_test.go +++ b/pkg/mcp/mcp_test.go @@ -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 diff --git a/pkg/mcp/tools_screen.go b/pkg/mcp/tools_screen.go index 8a276b9..d8a06f7 100644 --- a/pkg/mcp/tools_screen.go +++ b/pkg/mcp/tools_screen.go @@ -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) } diff --git a/pkg/mcp/tools_webview.go b/pkg/mcp/tools_webview.go index bea4d73..6da3dc3 100644 --- a/pkg/mcp/tools_webview.go +++ b/pkg/mcp/tools_webview.go @@ -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) diff --git a/pkg/mcp/tools_window.go b/pkg/mcp/tools_window.go index 678536c..a602a1f 100644 --- a/pkg/mcp/tools_window.go +++ b/pkg/mcp/tools_window.go @@ -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) diff --git a/pkg/webview/messages.go b/pkg/webview/messages.go index 9459057..55950bc 100644 --- a/pkg/webview/messages.go +++ b/pkg/webview/messages.go @@ -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) diff --git a/pkg/webview/service.go b/pkg/webview/service.go index b5f9c69..792f5fd 100644 --- a/pkg/webview/service.go +++ b/pkg/webview/service.go @@ -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) diff --git a/pkg/webview/service_test.go b/pkg/webview/service_test.go index 980c791..1da1d4c 100644 --- a/pkg/webview/service_test.go +++ b/pkg/webview/service_test.go @@ -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"})