gui/pkg/mcp/subsystem.go
Snider 2c59364250
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Implement chat, preload shims, and smart layouts
2026-04-15 13:39:13 +01:00

292 lines
7.1 KiB
Go

// pkg/mcp/subsystem.go
package mcp
import (
"context"
"reflect"
"sort"
"strings"
"sync"
"time"
core "dappco.re/go/core"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Subsystem translates MCP tool calls to Core IPC messages for GUI operations.
type Subsystem struct {
core *core.Core
mu sync.RWMutex
tools map[string]toolRecord
}
// ToolDescriptor is the chat-facing manifest entry for a GUI MCP tool.
type ToolDescriptor struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema map[string]any `json:"input_schema,omitempty"`
}
type toolRecord struct {
descriptor ToolDescriptor
call func(context.Context, map[string]any) (string, error)
}
// New(c) creates a display MCP subsystem backed by a Core instance.
// sub := mcp.New(c); sub.RegisterTools(server)
func New(c *core.Core) *Subsystem {
return &Subsystem{
core: c,
tools: make(map[string]toolRecord),
}
}
func (s *Subsystem) Name() string { return "display" }
func (s *Subsystem) RegisterTools(server *mcp.Server) {
s.registerWebviewTools(server)
s.registerWindowTools(server)
s.registerLayoutTools(server)
s.registerScreenTools(server)
s.registerClipboardTools(server)
s.registerDialogTools(server)
s.registerNotificationTools(server)
s.registerTrayTools(server)
s.registerEnvironmentTools(server)
s.registerBrowserTools(server)
s.registerContextMenuTools(server)
s.registerKeybindingTools(server)
s.registerDockTools(server)
s.registerLifecycleTools(server)
s.registerEventsTools(server)
s.registerMenuTools(server)
}
// Manifest returns the recorded MCP tool metadata in stable name order.
func (s *Subsystem) Manifest() []ToolDescriptor {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]ToolDescriptor, 0, len(s.tools))
for _, record := range s.tools {
result = append(result, record.descriptor)
}
sort.Slice(result, func(i, j int) bool {
return result[i].Name < result[j].Name
})
return result
}
// ManifestText renders the recorded MCP tool metadata as a system-prompt block.
func (s *Subsystem) ManifestText() string {
manifest := s.Manifest()
if len(manifest) == 0 {
return ""
}
var builder strings.Builder
builder.WriteString("Available MCP tools:\n")
for _, tool := range manifest {
builder.WriteString("- ")
builder.WriteString(tool.Name)
if tool.Description != "" {
builder.WriteString(": ")
builder.WriteString(tool.Description)
}
schema := tool.InputSchema
if schema == nil {
schema = map[string]any{"type": "object"}
}
builder.WriteString("\n input_schema: ")
builder.WriteString(core.JSONMarshalString(schema))
builder.WriteString("\n")
}
return strings.TrimSpace(builder.String())
}
// CallTool executes a recorded GUI MCP tool directly by name.
func (s *Subsystem) CallTool(ctx context.Context, name string, arguments map[string]any) (string, error) {
s.mu.RLock()
record, ok := s.tools[name]
s.mu.RUnlock()
if !ok {
return "", core.E("mcp.CallTool", "tool not found: "+name, nil)
}
if arguments == nil {
arguments = map[string]any{}
}
return record.call(ctx, arguments)
}
func addTool[In, Out any](s *Subsystem, server *mcp.Server, tool *mcp.Tool, handler mcp.ToolHandlerFor[In, Out]) {
if tool.InputSchema == nil {
tool.InputSchema = schemaForValue(new(In))
if tool.InputSchema == nil {
tool.InputSchema = map[string]any{"type": "object"}
}
}
mcp.AddTool(server, tool, handler)
s.recordTool(tool, func(ctx context.Context, arguments map[string]any) (string, error) {
var input In
if len(arguments) > 0 {
result := core.JSONUnmarshalString(core.JSONMarshalString(arguments), &input)
if !result.OK {
if err, ok := result.Value.(error); ok {
return "", err
}
return "", core.E("mcp.addTool", "failed to decode tool input", nil)
}
}
callResult, output, err := handler(ctx, nil, input)
if err != nil {
return "", err
}
if callResult != nil {
return renderCallToolResult(callResult), nil
}
return core.JSONMarshalString(output), nil
})
}
func (s *Subsystem) recordTool(tool *mcp.Tool, call func(context.Context, map[string]any) (string, error)) {
s.mu.Lock()
defer s.mu.Unlock()
s.tools[tool.Name] = toolRecord{
descriptor: ToolDescriptor{
Name: tool.Name,
Description: tool.Description,
InputSchema: normalizeSchema(tool.InputSchema),
},
call: call,
}
}
func renderCallToolResult(result *mcp.CallToolResult) string {
if result == nil {
return ""
}
parts := make([]string, 0, len(result.Content))
for _, content := range result.Content {
switch value := content.(type) {
case *mcp.TextContent:
parts = append(parts, value.Text)
default:
parts = append(parts, core.JSONMarshalString(value))
}
}
if len(parts) == 0 {
return core.JSONMarshalString(result)
}
return strings.Join(parts, "\n")
}
func normalizeSchema(schema any) map[string]any {
switch value := schema.(type) {
case map[string]any:
return value
case nil:
return nil
default:
var result map[string]any
unmarshal := core.JSONUnmarshalString(core.JSONMarshalString(value), &result)
if !unmarshal.OK {
return nil
}
return result
}
}
func schemaForValue(value any) map[string]any {
return schemaForType(reflect.TypeOf(value))
}
func schemaForType(t reflect.Type) map[string]any {
if t == nil {
return nil
}
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t == nil {
return nil
}
if t == reflect.TypeOf(time.Time{}) {
return map[string]any{
"type": "string",
"format": "date-time",
}
}
switch t.Kind() {
case reflect.Struct:
properties := make(map[string]any)
required := make([]string, 0, t.NumField())
for i := range t.NumField() {
field := t.Field(i)
if !field.IsExported() {
continue
}
tag := field.Tag.Get("json")
if tag == "-" {
continue
}
name, optional := schemaFieldName(field.Name, tag)
properties[name] = schemaForType(field.Type)
if !optional {
required = append(required, name)
}
}
schema := map[string]any{
"type": "object",
"properties": properties,
}
if len(required) > 0 {
schema["required"] = required
}
return schema
case reflect.Slice, reflect.Array:
return map[string]any{
"type": "array",
"items": schemaForType(t.Elem()),
}
case reflect.Map:
return map[string]any{
"type": "object",
}
case reflect.Bool:
return map[string]any{"type": "boolean"}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return map[string]any{"type": "integer"}
case reflect.Float32, reflect.Float64:
return map[string]any{"type": "number"}
case reflect.String:
return map[string]any{"type": "string"}
case reflect.Interface:
return map[string]any{}
default:
return map[string]any{"type": "string"}
}
}
func schemaFieldName(fallback, tag string) (string, bool) {
if tag == "" {
return fallback, false
}
parts := strings.Split(tag, ",")
name := parts[0]
if name == "" {
name = fallback
}
optional := false
for _, part := range parts[1:] {
if part == "omitempty" {
optional = true
}
}
return name, optional
}