From 19b949b8f3e91d00582d7ea6c63fd95983fdf061 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Mar 2026 16:10:06 +0000 Subject: [PATCH] test(mcp): add subsystem tests with IPC round-trip verification Fix cyclic type panic in contextmenu tools by using map[string]any for ContextMenuDef which contains self-referencing MenuItemDef. Co-Authored-By: Claude Opus 4.6 --- pkg/mcp/mcp_test.go | 60 ++++++++++++++++++++++++++++++++++++ pkg/mcp/tools_contextmenu.go | 33 +++++++++++++++----- 2 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 pkg/mcp/mcp_test.go diff --git a/pkg/mcp/mcp_test.go b/pkg/mcp/mcp_test.go new file mode 100644 index 0000000..86bb4d3 --- /dev/null +++ b/pkg/mcp/mcp_test.go @@ -0,0 +1,60 @@ +// pkg/mcp/mcp_test.go +package mcp + +import ( + "context" + "testing" + + "forge.lthn.ai/core/go/pkg/core" + "forge.lthn.ai/core/gui/pkg/clipboard" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSubsystem_Good_Name(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + sub := New(c) + assert.Equal(t, "display", sub.Name()) +} + +func TestSubsystem_Good_RegisterTools(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + sub := New(c) + // RegisterTools should not panic with a real mcp.Server + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) + assert.NotPanics(t, func() { sub.RegisterTools(server) }) +} + +// Integration test: verify the IPC round-trip that MCP tool handlers use. + +type mockClipPlatform struct { + text string + ok bool +} + +func (m *mockClipPlatform) Text() (string, bool) { return m.text, m.ok } +func (m *mockClipPlatform) SetText(t string) bool { m.text = t; m.ok = t != ""; return true } + +func TestMCP_Good_ClipboardRoundTrip(t *testing.T) { + c, err := core.New( + core.WithService(clipboard.Register(&mockClipPlatform{text: "hello", ok: true})), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + + // Verify the IPC path that clipboard_read tool handler uses + result, handled, err := c.QUERY(clipboard.QueryText{}) + require.NoError(t, err) + assert.True(t, handled) + content, _ := result.(clipboard.ClipboardContent) + assert.Equal(t, "hello", content.Text) +} + +func TestMCP_Bad_NoServices(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + // Without any services, QUERY should return handled=false + _, handled, _ := c.QUERY(clipboard.QueryText{}) + assert.False(t, handled) +} diff --git a/pkg/mcp/tools_contextmenu.go b/pkg/mcp/tools_contextmenu.go index 6c84aae..4db9685 100644 --- a/pkg/mcp/tools_contextmenu.go +++ b/pkg/mcp/tools_contextmenu.go @@ -3,6 +3,7 @@ package mcp import ( "context" + "encoding/json" "forge.lthn.ai/core/gui/pkg/contextmenu" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -10,16 +11,23 @@ import ( // --- contextmenu_add --- +// ContextMenuAddInput uses map[string]any for the menu definition because +// contextmenu.ContextMenuDef contains self-referencing MenuItemDef (Items []MenuItemDef) +// which the MCP SDK schema generator cannot handle (cycle detection panic). type ContextMenuAddInput struct { - Name string `json:"name"` - Menu contextmenu.ContextMenuDef `json:"menu"` + Name string `json:"name"` + Menu map[string]any `json:"menu"` } type ContextMenuAddOutput struct { Success bool `json:"success"` } func (s *Subsystem) contextMenuAdd(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuAddInput) (*mcp.CallToolResult, ContextMenuAddOutput, error) { - _, _, err := s.core.PERFORM(contextmenu.TaskAdd{Name: input.Name, Menu: input.Menu}) + // Convert map[string]any to ContextMenuDef via JSON round-trip + menuJSON, _ := json.Marshal(input.Menu) + var menuDef contextmenu.ContextMenuDef + _ = json.Unmarshal(menuJSON, &menuDef) + _, _, err := s.core.PERFORM(contextmenu.TaskAdd{Name: input.Name, Menu: menuDef}) if err != nil { return nil, ContextMenuAddOutput{}, err } @@ -49,7 +57,7 @@ type ContextMenuGetInput struct { Name string `json:"name"` } type ContextMenuGetOutput struct { - Menu *contextmenu.ContextMenuDef `json:"menu"` + Menu map[string]any `json:"menu"` } func (s *Subsystem) contextMenuGet(_ context.Context, _ *mcp.CallToolRequest, input ContextMenuGetInput) (*mcp.CallToolResult, ContextMenuGetOutput, error) { @@ -58,14 +66,21 @@ func (s *Subsystem) contextMenuGet(_ context.Context, _ *mcp.CallToolRequest, in return nil, ContextMenuGetOutput{}, err } menu, _ := result.(*contextmenu.ContextMenuDef) - return nil, ContextMenuGetOutput{Menu: menu}, nil + if menu == nil { + return nil, ContextMenuGetOutput{}, nil + } + // Convert to map[string]any via JSON round-trip to avoid cyclic type in schema + menuJSON, _ := json.Marshal(menu) + var menuMap map[string]any + _ = json.Unmarshal(menuJSON, &menuMap) + return nil, ContextMenuGetOutput{Menu: menuMap}, nil } // --- contextmenu_list --- type ContextMenuListInput struct{} type ContextMenuListOutput struct { - Menus map[string]contextmenu.ContextMenuDef `json:"menus"` + Menus map[string]any `json:"menus"` } func (s *Subsystem) contextMenuList(_ context.Context, _ *mcp.CallToolRequest, _ ContextMenuListInput) (*mcp.CallToolResult, ContextMenuListOutput, error) { @@ -74,7 +89,11 @@ func (s *Subsystem) contextMenuList(_ context.Context, _ *mcp.CallToolRequest, _ return nil, ContextMenuListOutput{}, err } menus, _ := result.(map[string]contextmenu.ContextMenuDef) - return nil, ContextMenuListOutput{Menus: menus}, nil + // Convert to map[string]any via JSON round-trip to avoid cyclic type in schema + menusJSON, _ := json.Marshal(menus) + var menusMap map[string]any + _ = json.Unmarshal(menusJSON, &menusMap) + return nil, ContextMenuListOutput{Menus: menusMap}, nil } // --- Registration ---