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 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-13 16:10:06 +00:00
parent a89e66c832
commit 19b949b8f3
2 changed files with 86 additions and 7 deletions

60
pkg/mcp/mcp_test.go Normal file
View file

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

View file

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