diff --git a/.env b/.env new file mode 100644 index 0000000..b4c9cba --- /dev/null +++ b/.env @@ -0,0 +1 @@ +GOEXPERIMENT=jsonv2 diff --git a/Taskfile.yml b/Taskfile.yml index 5c71bde..7f19adb 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,5 +1,8 @@ version: '3' +dotenv: + - .env + tasks: default: desc: 'Default task is to "build"' diff --git a/cmd/buttplug-mcp/main.go b/cmd/buttplug-mcp/main.go index eb6c3d9..9c28753 100644 --- a/cmd/buttplug-mcp/main.go +++ b/cmd/buttplug-mcp/main.go @@ -3,9 +3,13 @@ package main import ( + "context" "fmt" + "log" "log/slog" "os" + "os/signal" + "sync" "github.com/ConAcademy/buttplug-mcp/internal/bp" "github.com/ConAcademy/buttplug-mcp/internal/mcp" @@ -18,7 +22,7 @@ const ( mcpServerVersion = "0.0.1" defaultSSEHostPort = ":8889" - defaultLogDest = "buttplug-mcp.log" + defaultLogDest = "butplug-mcp.log" ) type Config struct { @@ -33,24 +37,23 @@ type Config struct { func main() { var config Config - var showHelp bool var logFilename string pflag.StringVarP(&logFilename, "log-file", "l", "", "Log file destination (or MCP_LOG_FILE envvar). Default is stderr") pflag.BoolVarP(&config.LogJSON, "log-json", "j", false, "Log in JSON (default is plaintext)") pflag.StringVarP(&config.MCPConfig.SSEHostPort, "sse-host", "", "", "host:port to listen to SSE connections") - pflag.BoolVarP(&config.MCPConfig.UseSSE, "sse", "", false, "Use SSE Transport (default is STDIO transport)") - pflag.IntVarP(&config.BPConfig.WsPort, "ws-port", "", 0, "port to connect to the Buttplug Websocket server") - pflag.DurationVarP(&config.BPConfig.DebounceDuration, "debounce", "d", bp.DefaultDebounceDuration, "duration for debounce (default is 20Hz = '50ms')") + pflag.BoolVarP(&config.MCPConfig.UseSSE, "sse", "", false, "Use SSE Transport (default is stdio transport)") + pflag.StringVarP(&config.MCPConfig.ToolDescriptionsFile, "tool-descriptions", "", "", "Path to YAML file with tool descriptions to override defaults (run `tool-descriptions' to see current descriptions in YAML)") + pflag.StringVarP(&config.BPConfig.WebsocketHost, "ws-host", "", "localhost", "host to connect to the Buttplug Websocket server") + pflag.IntVarP(&config.BPConfig.WebsocketPort, "ws-port", "", 12345, "port to connect to the Buttplug Websocket server") pflag.BoolVarP(&config.Verbose, "verbose", "v", false, "Verbose logging") - pflag.BoolVarP(&showHelp, "help", "h", false, "Show help") - pflag.Parse() - - if showHelp { - fmt.Fprintf(os.Stdout, "usage: %s [opts]\n\n", os.Args[0]) + pflag.Usage = func() { + o := pflag.CommandLine.Output() + fmt.Fprintf(o, "usage: %s [|start|tool-descriptions] [flags]\n", os.Args[0]) + fmt.Fprintf(o, "flags:\n") pflag.PrintDefaults() - os.Exit(0) } + pflag.Parse() if config.MCPConfig.SSEHostPort == "" { config.MCPConfig.SSEHostPort = defaultSSEHostPort @@ -67,8 +70,7 @@ func main() { if logFilename != "" { logFile, err := os.OpenFile(logFilename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { - fmt.Fprintf(os.Stderr, "failed to open log file: %s\n", err.Error()) - os.Exit(1) + log.Fatal("failed to open log file:", err) } logWriter = logFile defer logFile.Close() @@ -86,26 +88,49 @@ func main() { logger = slog.New(slog.NewTextHandler(logWriter, &slog.HandlerOptions{Level: logLevel})) } - // Run our Buttplug manager - var err error - bpManager, err := bp.NewManager(config.BPConfig, logger) + bpm, err := bp.NewManager(config.BPConfig, logger) if err != nil { - fmt.Fprintf(os.Stderr, "failed to start Buttplug manager: %s\n", err.Error()) - os.Exit(1) + log.Fatal("failed to create Buttplug manager:", err) } - go func() { - err := bpManager.Run() + + mcps, err := mcp.New(config.MCPConfig, bpm, logger) + if err != nil { + log.Fatal("failed to create MCP server:", err) + } + + switch pflag.Arg(0) { + case "tool-descriptions": + yaml, err := mcp.FormatToolDescriptionsAsYAML(mcps.ToolDescriptions()) if err != nil { - fmt.Fprintf(os.Stderr, "bpm failed: %s\n", err.Error()) - os.Exit(1) + log.Fatal("failed to format tool descriptions as YAML:", err) } - }() + fmt.Println(string(yaml)) + return - // Run our MCP server - if err := mcp.RunRouter(config.MCPConfig, bpManager, logger); err != nil { - logger.Error("mcp router error", "error", err.Error()) - os.Exit(1) + case "start", "": + // continue to starting the server + + default: + log.Fatalf("unknown command: %s", pflag.Arg(0)) } - // TODO: I guess we should clean up the buttplug? + var wg sync.WaitGroup + defer wg.Wait() + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + wg.Go(func() { + if err := bpm.Run(ctx); err != nil { + logger.Error( + "buttplug manager error, program is useless now", + "error", err.Error()) + } + }) + + if err := mcps.Start(); err != nil { + logger.Error( + "failed to start MCP server:", + "error", err.Error()) + } } diff --git a/go.mod b/go.mod index 2e279bf..090c040 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,19 @@ module github.com/ConAcademy/buttplug-mcp -go 1.24.1 +go 1.25.0 require ( - github.com/diamondburned/go-buttplug v0.0.3 + github.com/goccy/go-yaml v1.19.0 github.com/mark3labs/mcp-go v0.31.0 + github.com/puzpuzpuz/xsync/v4 v4.2.0 github.com/spf13/pflag v1.0.6 + libdb.so/go-buttplug v0.0.8 ) require ( + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/coder/websocket v1.8.14 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect ) diff --git a/go.sum b/go.sum index be2c10d..253b9ce 100644 --- a/go.sum +++ b/go.sum @@ -1,41 +1,46 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/alecthomas/assert/v2 v2.8.1 h1:YCxnYR6jjpfnEK5AK5SysALKdUEBPGH4Y7As6tBnDw0= +github.com/alecthomas/assert/v2 v2.8.1/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/diamondburned/go-buttplug v0.0.3 h1:tBRkOMm2GXKWVUOvwnszAGUM4tCF9CUsJyE/PKzlQFY= -github.com/diamondburned/go-buttplug v0.0.3/go.mod h1:2WnC+qL7dg3vB6Hiv9HuITG/eYFsCQ1CETh1qmLUq1s= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= +github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mark3labs/mcp-go v0.31.0 h1:4UxSV8aM770OPmTvaVe/b1rA2oZAjBMhGBfUgOGut+4= github.com/mark3labs/mcp-go v0.31.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/neilotoole/slogt v1.1.0 h1:c7qE92sq+V0yvCuaxph+RQ2jOKL61c4hqS1Bv9W7FZE= +github.com/neilotoole/slogt v1.1.0/go.mod h1:RCrGXkPc/hYybNulqQrMHRtvlQ7F6NktNVLuLwk6V+w= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/qri-io/jsonpointer v0.1.1/go.mod h1:DnJPaYgiKu56EuDp8TU5wFLdZIcAnb/uH9v37ZaMV64= -github.com/qri-io/jsonschema v0.2.1/go.mod h1:g7DPkiOsK1xv6T/Ao5scXRkd+yTFygcANPBaaqW+VrI= +github.com/puzpuzpuz/xsync/v4 v4.2.0 h1:dlxm77dZj2c3rxq0/XNvvUKISAmovoXF4a4qM6Wvkr0= +github.com/puzpuzpuz/xsync/v4 v4.2.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +libdb.so/go-buttplug v0.0.8 h1:icQrfxEShgQ9l6NouOULq2C1WF/AY/1hKujOSVb7uiY= +libdb.so/go-buttplug v0.0.8/go.mod h1:IxRlJOOshEEHMky0lL+f1kSB94Ccqu7PhvGu6VqUXSo= diff --git a/internal/bp/manager.go b/internal/bp/manager.go index ac00a51..0b68aca 100644 --- a/internal/bp/manager.go +++ b/internal/bp/manager.go @@ -1,38 +1,47 @@ // Copyright (c) 2025 Neomantra BV +// Package bp implements Buttplug.io device management. package bp import ( "context" "fmt" "log/slog" - "os" - "os/signal" + "maps" + "slices" + "sync" "time" - "github.com/diamondburned/go-buttplug" - "github.com/diamondburned/go-buttplug/device" + "libdb.so/go-buttplug" + buttplugschema "libdb.so/go-buttplug/schema/v3" ) -// DefaultDebounceDuration is hte Default used for DebounceDuration. Default is 50ms, 20Hz. -const DefaultDebounceDuration = device.DebounceFrequency - // Config conifgures Manager type Config struct { // DebounceDuration determines the period of frequency to debounce certain commands // when sending them to the websocket. It works around the internal event // buffers for real-time control. Default is '50ms', 20Hz. -1 to disable. DebounceDuration time.Duration // Duration for debounce (default is 50ms) - WsPort int // websocket port to start from + // WebsocketHost is the host of the existing Intiface server to connect to. + WebsocketHost string + // WebsocketPort is the port of the existing Intiface server to connect to. + WebsocketPort int } -// Manager keeps track of Buttplug resources +// Manager keeps track of Buttplug resources. type Manager struct { config Config logger *slog.Logger + conn *buttplug.Websocket + + devicesMu sync.RWMutex + devices map[buttplugschema.DeviceIndex]KnownDevice +} - conn *buttplug.Websocket - manager *device.Manager +type KnownDevice struct { + Name buttplugschema.DeviceName + Messages buttplugschema.DeviceMessages + BatteryLevel float64 } // NewManager returns a new buttplug.Manager given a Config. @@ -41,69 +50,173 @@ func NewManager(config Config, logger *slog.Logger) (*Manager, error) { return &Manager{ config: config, logger: logger, + conn: buttplug.NewWebsocket( + fmt.Sprintf("ws://%s:%d", config.WebsocketHost, config.WebsocketPort), + &buttplug.WebsocketOpts{ + ServerName: "buttplug-mcp", + Logger: logger, + }, + ), + devices: make(map[buttplugschema.DeviceIndex]KnownDevice), }, nil } -func (m *Manager) GetConnection() *buttplug.Websocket { - return m.conn -} - -func (m *Manager) GetConfig() Config { - return m.config -} - -func (m *Manager) GetDeviceManager() *device.Manager { - return m.manager -} - /////////////////////////////////////////////////////////////////////////////// -// From https://github.com/diamondburned/go-buttplug/blob/plug/cmd/buttplughttp/main.go -func (m *Manager) Run() error { - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) +func (m *Manager) Run(ctx context.Context) error { + msgs, cancel := m.conn.MessageChannel(ctx) defer cancel() - m.conn = buttplug.NewWebsocket() - m.conn.DialTimeout = time.Second - m.conn.DialDelay = 250 * time.Millisecond - - broadcaster := buttplug.NewBroadcaster() - - m.manager = device.NewManager() - m.manager.DebounceFrequency = m.config.DebounceDuration - m.manager.Listen(broadcaster.Listen()) - - msgCh := broadcaster.Listen() - - // Start connecting and broadcasting messages at the same time. - urlStr := fmt.Sprintf("ws://127.0.0.1:%d", m.config.WsPort) - broadcaster.Start(m.conn.Open(ctx, urlStr)) + connErr := make(chan error, 1) + go func() { connErr <- m.conn.Start(ctx) }() - m.logger.Info("Working Buttplug connection", "url", urlStr) for { select { case <-ctx.Done(): - return nil - case msg := <-msgCh: + return <-connErr // wait for connection to end + + case err := <-connErr: + return fmt.Errorf("buttplug connection error: %w", err) + + case msg := <-msgs: switch msg := msg.(type) { - case *buttplug.ServerInfo: - // Server is ready. Start scanning and ask for the list of - // devices. The device manager will pick up the device messages for us. - m.conn.Send(ctx, - &buttplug.StartScanning{}, - &buttplug.RequestDeviceList{}, - ) - case *buttplug.DeviceList: + case *buttplugschema.ServerInfoMessage: + // Server is ready. Start scanning for all devices. + m.conn.Send(ctx, &buttplugschema.StartScanningMessage{}) + m.conn.Send(ctx, &buttplugschema.RequestDeviceListMessage{}) + + case *buttplugschema.DeviceListMessage: for _, device := range msg.Devices { m.logger.Info("listed device", "name", device.DeviceName, "index", device.DeviceIndex) } - case *buttplug.DeviceAdded: + + case *buttplugschema.DeviceAddedMessage: m.logger.Info("added device", "name", msg.DeviceName, "index", msg.DeviceIndex) - case *buttplug.DeviceRemoved: + + case *buttplugschema.DeviceRemovedMessage: m.logger.Info("removed device", "index", msg.DeviceIndex) - case error: - m.logger.Error("buttplug error", "msg", msg) } + + m.handleDeviceMessage(msg) + } + } +} + +func (m *Manager) handleDeviceMessage(msg buttplugschema.Message) { + m.devicesMu.Lock() + defer m.devicesMu.Unlock() + + switch msg := msg.(type) { + case *buttplugschema.DeviceListMessage: + clear(m.devices) + for _, device := range msg.Devices { + m.devices[device.DeviceIndex] = KnownDevice{ + Name: device.DeviceName, + Messages: device.DeviceMessages, + } + } + case *buttplugschema.DeviceAddedMessage: + m.devices[msg.DeviceIndex] = KnownDevice{ + Name: msg.DeviceName, + Messages: msg.DeviceMessages, } + case *buttplugschema.DeviceRemovedMessage: + delete(m.devices, msg.DeviceIndex) } } + +// DeviceIndexes returns known device indexes. +func (m *Manager) DeviceIndexes() []buttplugschema.DeviceIndex { + m.devicesMu.RLock() + defer m.devicesMu.RUnlock() + + return slices.Collect(maps.Keys(m.devices)) +} + +// Device returns the known device at the given index. +func (m *Manager) Device(ctx context.Context, deviceIndex int) (*KnownDevice, error) { + m.devicesMu.RLock() + device, ok := m.devices[buttplugschema.DeviceIndex(deviceIndex)] + m.devicesMu.RUnlock() + if !ok { + return nil, fmt.Errorf("device %d not found", deviceIndex) + } + + device.BatteryLevel = -1 + + sensorIndex := slices.IndexFunc(device.Messages.SensorReadCmd, func(info buttplugschema.SensorReadCmdItem) bool { + return info.SensorType == buttplug.SensorBattery + }) + if sensorIndex == -1 { + return &device, nil + } + + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + reply, err := m.conn.SendCommand(ctx, &buttplugschema.SensorReadCmdMessage{ + DeviceIndex: buttplugschema.DeviceIndex(deviceIndex), + SensorIndex: sensorIndex, + SensorType: buttplug.SensorBattery, + }) + if err != nil { + slog.Warn( + "error querying battery", + "deviceIndex", deviceIndex, + "err", err) + } else { + reading, ok := reply.(*buttplugschema.SensorReadingMessage) + if !ok { + slog.Warn( + "unexpected reply type from server when querying battery", + "deviceIndex", deviceIndex, + "replyType", fmt.Sprintf("%T", reply)) + } else { + device.BatteryLevel = float64(reading.Data[0]) / 100 + } + } + + return &device, nil +} + +// DeviceVibrate vibrates the given device at the given strength (0.0 to 1.0). +func (m *Manager) DeviceVibrate(ctx context.Context, deviceIndex int, strength float64) error { + m.devicesMu.RLock() + device := m.devices[buttplugschema.DeviceIndex(deviceIndex)] + m.devicesMu.RUnlock() + + // still unsure what the difference between scalar and linear cmds are. + var scalars []buttplugschema.Scalar + for i, info := range device.Messages.ScalarCmd { + if info.ActuatorType != nil && *info.ActuatorType == buttplug.ActuatorVibrate { + scalars = append(scalars, buttplugschema.Scalar{ + Index: i, + Scalar: strength, + ActuatorType: buttplug.ActuatorVibrate, + }) + } + } + if len(scalars) == 0 { + return fmt.Errorf("device %d has no vibrators", deviceIndex) + } + + _, err := m.conn.Send(ctx, &buttplugschema.ScalarCmdMessage{ + DeviceIndex: buttplugschema.DeviceIndex(deviceIndex), + Scalars: scalars, + }) + return err +} + +// DeviceStop stops the given device. +func (m *Manager) DeviceStop(ctx context.Context, deviceIndex int) error { + _, err := m.conn.Send(ctx, &buttplugschema.StopDeviceCmdMessage{ + DeviceIndex: buttplugschema.DeviceIndex(deviceIndex), + }) + return err +} + +// StopAll stops all devices. +func (m *Manager) StopAll(ctx context.Context) error { + _, err := m.conn.Send(ctx, &buttplugschema.StopAllDevicesMessage{}) + return err +} diff --git a/internal/bp/pattern.go b/internal/bp/pattern.go new file mode 100644 index 0000000..9faf5e6 --- /dev/null +++ b/internal/bp/pattern.go @@ -0,0 +1,251 @@ +package bp + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + "sync" + "time" + + "github.com/puzpuzpuz/xsync/v4" +) + +// PatternExpressionRegex is a regex that matches valid [PatternExpression] +// strings. +const PatternExpressionRegex = `^( *([\d\.]+\+[\d\.]+s) *; *)*( *([\d\.]+\+[\d\.]+s) *)$` + +// PatternExpression is a string representation of a vibration pattern. +type PatternExpression string + +// Pattern represents a vibration pattern. +type Pattern []PatternStep + +// ParsePatternExpression parses a [PatternExpression] string into a [Pattern]. +func ParsePatternExpression(expr string) (Pattern, error) { + sexpr := string(expr) + sexpr = strings.TrimSpace(sexpr) + + patterns := make(Pattern, 0, strings.Count(sexpr, ";")+1) + + for part := range strings.SplitSeq(sexpr, ";") { + part = strings.TrimSpace(part) + + var strength, durationSec float64 + if _, err := fmt.Sscanf(strings.TrimSpace(part), "%f+%fs", &strength, &durationSec); err != nil { + return nil, fmt.Errorf("failed to parse pattern part %q: %w", part, err) + } + + duration := time.Duration(durationSec * float64(time.Second)) + patterns = append(patterns, PatternStep{ + Strength: strength, + Duration: duration, + }) + } + + return patterns, nil +} + +// String returns the string representation of the vibration pattern. +// It just calls [Pattern.Expression]. +func (p Pattern) String() string { + return string(p.Expression()) +} + +// Expression returns the string representation of the vibration pattern. +func (p Pattern) Expression() PatternExpression { + var s strings.Builder + for i, step := range p { + fmt.Fprintf(&s, "%.2f+%.2fs", step.Strength, step.Duration.Seconds()) + if i < len(p)-1 { + s.WriteString("; ") + } + } + return PatternExpression(s.String()) +} + +// TotalDuration returns the total duration of the vibration pattern. +func (p Pattern) TotalDuration() time.Duration { + var total time.Duration + for _, step := range p { + total += step.Duration + } + return total +} + +// PatternStep represents a single step in a vibration pattern. +type PatternStep struct { + Strength float64 `json:"strength"` + Duration time.Duration `json:"duration"` +} + +// PatternPlayer plays vibration patterns on devices. +// It wraps a [Manager] to manage pattern playback. +type PatternPlayer struct { + bpm *Manager + logger *slog.Logger + devices *xsync.Map[int, *patternDeviceState] + error chan error // constant, used for surfacing errors +} + +// NewPatternPlayer returns a new [PatternPlayer]. +func NewPatternPlayer(bpm *Manager, logger *slog.Logger) *PatternPlayer { + p := &PatternPlayer{ + bpm: bpm, + logger: logger, + devices: xsync.NewMap[int, *patternDeviceState](), + error: make(chan error, 1), + } + return p +} + +// Play starts playing the given pattern on the given device ID. If a pattern +// is already playing on this device, it is stopped first. +func (p *PatternPlayer) Play(ctxCall context.Context, deviceID int, pattern Pattern, repeats int) error { + done := make(chan struct{}) + ctxLoop, cancel := context.WithCancel(context.Background()) + + s, _ := p.devices.LoadOrCompute(deviceID, func() (*patternDeviceState, bool) { + return newPatternPlayerDeviceState(), false + }) + select { + case <-ctxCall.Done(): + return ctxCall.Err() + case <-ctxLoop.Done(): + return ctxLoop.Err() + case <-s.stopAndSwap(done, cancel): + // continue + } + + if err := p.popError(); err != nil { + close(done) + cancel() + return err + } + + go func() { + defer close(done) + defer cancel() + + logger := p.logger.With( + "device_id", deviceID) + logger.Debug( + "starting pattern playback", + "pattern_count", len(pattern), + "pattern_repeats", repeats, + "pattern_total_duration", pattern.TotalDuration()) + + for repeat := 0; (repeats == 0) || (repeat < repeats) && ctxLoop.Err() == nil; repeat++ { + for i, step := range pattern { + logger.DebugContext(ctxLoop, + "playing pattern step", + "step", i, + "step_strength", step.Strength, + "step_duration", step.Duration, + "repeated", repeat) + + if err := p.bpm.DeviceVibrate(ctxLoop, deviceID, step.Strength); err != nil { + if !errors.Is(err, context.Canceled) { + logger.ErrorContext(ctxLoop, + "failed to set device vibration strength, immediately stopping pattern playback", + "step", i, + "repeated", repeat, + "error", err) + + // surface this error up. + select { + case p.error <- err: + default: + } + } + return + } + + select { + case <-ctxLoop.Done(): + return + case <-time.After(step.Duration): + } + } + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + if err := p.bpm.DeviceStop(ctx, deviceID); err != nil { + logger.Error( + "failed to stop device after pattern playback", + "error", err) + } + }() + + return nil +} + +// Stop stops any currently playing pattern, waiting for it to fully stop. +// It returns any error encountered during pattern playback. +func (p *PatternPlayer) Stop(deviceID int) error { + device, ok := p.devices.LoadAndDelete(deviceID) + if ok { + <-device.stop() + } + return p.popError() +} + +// StopAll stops all currently playing patterns. The background error is +// ignored. +func (p *PatternPlayer) StopAll() { + var stops []<-chan struct{} + // delete all devices in one go then wait for them to stop after: + p.devices.Range(func(deviceID int, device *patternDeviceState) bool { + p.devices.Delete(deviceID) + stops = append(stops, device.stop()) + return true + }) + for _, done := range stops { + <-done + } +} + +func (p *PatternPlayer) popError() error { + select { + case err := <-p.error: + return fmt.Errorf("previous playback resulted in error: %w", err) + default: + return nil + } +} + +type patternDeviceState struct { + mu sync.Mutex + done chan struct{} + cancel func() +} + +// newPatternPlayerDeviceState returns a new patternPlayerDeviceState. +// The state starts with the done channel immediately returning. +func newPatternPlayerDeviceState() *patternDeviceState { + s := &patternDeviceState{cancel: func() {}} + s.stop() + return s +} + +func (s *patternDeviceState) stop() (oldDone <-chan struct{}) { + newDone := make(chan struct{}) + close(newDone) + return s.stopAndSwap(newDone, func() {}) +} + +func (s *patternDeviceState) stopAndSwap(newDone chan struct{}, newCancel context.CancelFunc) (oldDone <-chan struct{}) { + s.mu.Lock() + defer s.mu.Unlock() + + s.cancel() + oldDone = s.done + + s.done = newDone + s.cancel = newCancel + + return oldDone +} diff --git a/internal/bp/pattern_test.go b/internal/bp/pattern_test.go new file mode 100644 index 0000000..a020529 --- /dev/null +++ b/internal/bp/pattern_test.go @@ -0,0 +1,207 @@ +package bp + +import ( + "reflect" + "regexp" + "testing" + "time" +) + +func TestParsePatternExpression(t *testing.T) { + tests := []struct { + name string + expr string + want Pattern + wantErr bool + }{ + { + name: "valid pattern", + expr: "0.5+0.2s; 0.0+0.2s; 1.0+1s", + want: Pattern{ + {Strength: 0.5, Duration: 200 * time.Millisecond}, + {Strength: 0.0, Duration: 200 * time.Millisecond}, + {Strength: 1.0, Duration: 1 * time.Second}, + }, + wantErr: false, + }, + { + name: "valid pattern with whitespace", + expr: " 0.5+0.2s ; 0.0+0.2s ; 1.0+1s ", + want: Pattern{ + {Strength: 0.5, Duration: 200 * time.Millisecond}, + {Strength: 0.0, Duration: 200 * time.Millisecond}, + {Strength: 1.0, Duration: 1 * time.Second}, + }, + wantErr: false, + }, + { + name: "invalid pattern", + expr: "0.5+0.2s; invalid; 1.0+1s", + want: nil, + wantErr: true, + }, + { + name: "empty pattern part", + expr: "0.5+0.2s;;1.0+1s", + want: nil, + wantErr: true, + }, + { + name: "empty expression", + expr: "", + want: nil, + wantErr: true, + }, + { + name: "single step", + expr: "0.8+0.5s", + want: Pattern{ + {Strength: 0.8, Duration: 500 * time.Millisecond}, + }, + wantErr: false, + }, + { + name: "no decimals", + expr: "1+2s;0+3s", + want: Pattern{ + {Strength: 1.0, Duration: 2 * time.Second}, + {Strength: 0.0, Duration: 3 * time.Second}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParsePatternExpression(tt.expr) + if (err != nil) != tt.wantErr { + t.Errorf("ParsePatternExpression() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParsePatternExpression() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPattern_Expression(t *testing.T) { + tests := []struct { + name string + p Pattern + want PatternExpression + }{ + { + name: "multiple steps", + p: Pattern{ + {Strength: 0.5, Duration: 200 * time.Millisecond}, + {Strength: 0.0, Duration: 200 * time.Millisecond}, + {Strength: 1.0, Duration: 1 * time.Second}, + }, + want: "0.50+0.20s; 0.00+0.20s; 1.00+1.00s", + }, + { + name: "single step", + p: Pattern{ + {Strength: 0.8, Duration: 500 * time.Millisecond}, + }, + want: "0.80+0.50s", + }, + { + name: "empty pattern", + p: Pattern{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.p.Expression(); got != tt.want { + t.Errorf("Pattern.Expression() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestPatternExpressionRegex(t *testing.T) { + re := regexp.MustCompile(PatternExpressionRegex) + + tests := []struct { + name string + expr string + matches bool + }{ + { + name: "valid simple", + expr: "0.5+0.2s", + matches: true, + }, + { + name: "valid multiple", + expr: "0.5+0.2s; 1.0+1.0s", + matches: true, + }, + { + name: "valid with whitespace", + expr: " 0.5+0.2s ; 1.0+1.0s ", + matches: true, + }, + { + name: "valid with trailing semicolon", + expr: "0.5+0.2s;", + matches: false, + }, + { + name: "valid with trailing semicolon and whitespace", + expr: "0.5+0.2s; ", + matches: false, + }, + { + name: "empty string", + expr: "", + matches: false, + }, + { + name: "whitespace only", + expr: " ", + matches: false, + }, + { + name: "invalid characters", + expr: "abc", + matches: false, + }, + { + name: "invalid partial", + expr: "0.5+0.2s; abc", + matches: false, + }, + { + name: "missing s suffix", + expr: "0.5+0.2", + matches: false, + }, + { + name: "wrong format", + expr: "0.5s+0.2", + matches: false, + }, + { + name: "wrong separator", + expr: "0.5+0.2s, 1.0+1.0s", + matches: false, + }, + { + name: "no separator", + expr: "0.5+0.2s 1.0+1.0s", + matches: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := re.MatchString(tt.expr); got != tt.matches { + t.Errorf("regex.MatchString(%q) = %v, want %v", tt.expr, got, tt.matches) + } + }) + } +} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 5c00314..8e20e3e 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -1,5 +1,7 @@ // Copyright (c) 2025 Neomantra BV +// Package mcp implements the model context protocol server for Buttplug.io +// device control. package mcp import ( @@ -7,298 +9,218 @@ import ( "encoding/json" "fmt" "log/slog" - "net/url" - "regexp" - "strconv" + "os" + "time" "github.com/ConAcademy/buttplug-mcp/internal/bp" - "github.com/diamondburned/go-buttplug" - "github.com/diamondburned/go-buttplug/device" + "github.com/goccy/go-yaml" "github.com/mark3labs/mcp-go/mcp" - mcp_server "github.com/mark3labs/mcp-go/server" + mcpserver "github.com/mark3labs/mcp-go/server" ) -const ( - regex10 = `^(0(\.\d+)?|1(\.0+)?)$` - regexInt = `^[0-9]*$` -) +const regexInt = `^[0-9]*$` -// Config is configuration for our MCP server +// Config is configuration for our MCP server. type Config struct { - Name string // Service Name - Version string // Service Version - + Name string // Service Name + Version string // Service Version UseSSE bool // Use SSE Transport instead of STDIO SSEHostPort string // HostPort to use for SSE -} -// we resort to module-global variable rather than setting up closures -var bpManager *bp.Manager + // ToolDescriptionsFile is the path to a YAML file containing tool + // descriptions to override defaults. + ToolDescriptionsFile string -////////////////////////////////////////////////////////////////////////////// + // ButtplugWebsocketAddress is the address of the Buttplug Websocket server + // to connect to. + ButtplugWebsocketAddress string +} -func RunRouter(config Config, bpm *bp.Manager, logger *slog.Logger) error { - // Set module global for handlers - bpManager = bpm +// Server is our MCP server. +type Server struct { + mcp *mcpserver.MCPServer + bpm *bp.Manager + patterns *bp.PatternPlayer + logger *slog.Logger + config Config + td ToolDescriptions +} - // Create the MCP Server - mcpServer := mcp_server.NewMCPServer(config.Name, config.Version) - registerTools(mcpServer) - - if config.UseSSE { - sseServer := mcp_server.NewSSEServer(mcpServer) - logger.Info("MCP SSE server started", "hostPort", config.SSEHostPort) - if err := sseServer.Start(config.SSEHostPort); err != nil { - return fmt.Errorf("MCP SSE server error: %w", err) +// New creates a new MCP server with the given configuration. +func New(config Config, bpm *bp.Manager, logger *slog.Logger) (*Server, error) { + td := defaultToolDescriptions() + if config.ToolDescriptionsFile != "" { + b, err := os.ReadFile(config.ToolDescriptionsFile) + if err != nil { + return nil, fmt.Errorf("failed to read tool descriptions file: %w", err) } - } else { - logger.Info("MCP STDIO server started") - if err := mcp_server.ServeStdio(mcpServer); err != nil { - return fmt.Errorf("MCP STDIO server error: %w", err) + if err := yaml.Unmarshal(b, &td); err != nil { + return nil, fmt.Errorf("failed to parse tool descriptions file: %w", err) } } - return nil + // Create the MCP Server + mcp := mcpserver.NewMCPServer( + config.Name, config.Version, + mcpserver.WithToolCapabilities(true), // Enable tools + mcpserver.WithInstructions(td.ForTool("_")), + ) + + s := &Server{ + mcp: mcp, + bpm: bpm, + patterns: bp.NewPatternPlayer(bpm, logger), + logger: logger, + config: config, + td: td, + } + s.registerTools() + + return s, nil } -/////////////////////////////////////////////////////////////////////////////// +// ToolDescriptions returns the tool descriptions used by the server, which may +// be merged with user-provided descriptions. +func (s *Server) ToolDescriptions() ToolDescriptions { + return s.td +} + +// Start starts the MCP server. +func (s *Server) Start() error { + if s.config.UseSSE { + s.logger.Info("MCP SSE server starting", "hostPort", s.config.SSEHostPort) + return mcpserver.NewSSEServer(s.mcp).Start(s.config.SSEHostPort) + } else { + s.logger.Info("MCP stdio server starting") + return mcpserver.ServeStdio(s.mcp) + } +} // registerTools registers tools+metadata with the passed MCPServer -func registerTools(mcpServer *mcp_server.MCPServer) error { - // /devices - mcpServer.AddResource(mcp.NewResource("/devices", "Device List", - mcp.WithResourceDescription("List of connected Buttplug devices in JSON"), - mcp.WithMIMEType("application/json"), - ), getDeviceListHandler) - // /device/{id} - mcpServer.AddResourceTemplate(mcp.NewResourceTemplate("/device/{id}", "Device Info by ID", - mcp.WithTemplateDescription("Device information by device ID where `id` is a number from `/devices`"), - mcp.WithTemplateMIMEType("application/json"), - ), getDeviceInfoHandler) - // /device/{id}/rssi - mcpServer.AddResourceTemplate(mcp.NewResourceTemplate("/device/{id}/rssi", "Signal Level for Device by ID", - mcp.WithTemplateDescription("RSSI signal level by device ID where `id` is a number from `/devices`"), - mcp.WithTemplateMIMEType("application/json"), - ), getDeviceRssiHandler) - // /device/{id}/battery - mcpServer.AddResourceTemplate(mcp.NewResourceTemplate("/device/{id}/battery", "Battery Level for Device by ID", - mcp.WithTemplateDescription("Battery level by device ID where `id` is a number from `/devices`"), - mcp.WithTemplateMIMEType("application/json"), - ), getDeviceBatteryHandler) - // /device/vibrate - mcpServer.AddTool(mcp.NewTool("device_vibrate", - mcp.WithDescription("Vibrates device by `id`, selecting `strength` and optional `motor`"), +func (s *Server) registerTools() { + // get_device_ids + s.mcp.AddTool(mcp.NewTool("get_device_ids", + mcp.WithTitleAnnotation("Get Device IDs"), + mcp.WithDescription(s.td.ForTool("get_device_ids")), + ), s.handleDeviceListTool) + + // get_device_by_id + s.mcp.AddTool(mcp.NewTool("get_device_by_id", + mcp.WithTitleAnnotation("Get Device By ID"), + mcp.WithDescription(s.td.ForTool("get_device_by_id")), mcp.WithNumber("id", mcp.Required(), - mcp.Description("Device ID to query, sourced from `/devices`"), + mcp.Description(s.td.ForToolParameter("get_device_by_id", "id")), mcp.Pattern(regexInt), ), - mcp.WithNumber("strength", + ), s.handleDeviceOneTool) + + // device_vibrate + s.mcp.AddTool(mcp.NewTool("device_vibrate", + mcp.WithTitleAnnotation("Device Vibrate"), + mcp.WithDescription(s.td.ForTool("device_vibrate")), + mcp.WithNumber("id", mcp.Required(), - mcp.Description("Strength from 0.0 to 1.0, with 0.0 being off and 1.0 being full"), - mcp.Pattern(regex10), - ), - mcp.WithNumber("motor", - mcp.Description("Motor number to vibrate, defaults to 0"), + mcp.Description(s.td.ForToolParameter("device_vibrate", "id")), mcp.Pattern(regexInt), ), - ), vibrateDeviceHandler) - - return nil -} - -type RssiResponse struct { - RssiLevel float64 `json:"rssi_level"` -} - -type BatteryResponse struct { - BatteryLevel float64 `json:"battery_level"` -} - -/////////////////////////////////////////////////////////////////////////////// - -func getDeviceListHandler(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - if bpManager == nil { - return nil, fmt.Errorf("Buttplug manager not initialized") - } - - devices := bpManager.GetDeviceManager().Devices() - - jbytes, err := json.Marshal(devices) - if err != nil { - return nil, fmt.Errorf("failed to json.Marshal devices: %w", err) - } - - return []mcp.ResourceContents{ - mcp.TextResourceContents{ - URI: "/devices", - MIMEType: "application/json", - Text: string(jbytes), - }, - }, nil -} - -func getDeviceInfoHandler(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - ctrl, err := controllerFromPattern(request, "/device/:id") - if err != nil { - return nil, fmt.Errorf("failed to extract controller: %w", err) - } - - jbytes, err := json.Marshal(ctrl.Device) - if err != nil { - return nil, fmt.Errorf("failed to json.Marshal devices: %w", err) - } + mcp.WithString("pattern_expression", + mcp.Required(), + mcp.Description(s.td.ForToolParameter("device_vibrate", "pattern")), + mcp.Pattern(bp.PatternExpressionRegex), + ), + mcp.WithNumber("repeats", + mcp.Description(s.td.ForToolParameter("device_vibrate", "repeats")), + mcp.DefaultNumber(1), + mcp.Min(0), + ), + mcp.WithNumber("return_after_duration_sec", + mcp.Description(s.td.ForToolParameter("device_vibrate", "return_after_duration_sec")), + mcp.DefaultNumber(0.0), + mcp.Min(0.0), + ), + ), s.handleDeviceVibrate) - return []mcp.ResourceContents{ - mcp.TextResourceContents{ - URI: request.Params.URI, - MIMEType: "application/json", - Text: string(jbytes), - }, - }, nil + // device_stop + s.mcp.AddTool(mcp.NewTool("device_stop", + mcp.WithTitleAnnotation("Device Stop"), + mcp.WithDescription(s.td.ForTool("device_stop")), + ), s.handleDeviceStop) } -func getDeviceRssiHandler(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - ctrl, err := controllerFromPattern(request, "/device/:id/rssi") - if err != nil { - return nil, fmt.Errorf("failed to extract controller: %w", err) - } - - rssiLevel, err := ctrl.RSSILevel() - if err != nil { - return nil, fmt.Errorf("failed to query rssi: %w", err) - } - - jbytes, err := json.Marshal(RssiResponse{ - RssiLevel: rssiLevel, +func (s *Server) handleDeviceListTool(_ context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + jbytes, err := json.Marshal(map[string]any{ + "device_ids": s.bpm.DeviceIndexes(), }) if err != nil { - return nil, fmt.Errorf("failed to json.Marshal rssi: %w", err) + return nil, err } - return []mcp.ResourceContents{ - mcp.TextResourceContents{ - URI: request.Params.URI, - MIMEType: "application/json", - Text: string(jbytes), - }, - }, nil + return mcp.NewToolResultText(string(jbytes)), nil } -func getDeviceBatteryHandler(ctx context.Context, request mcp.ReadResourceRequest) ([]mcp.ResourceContents, error) { - ctrl, err := controllerFromPattern(request, "/device/:id/battery") +func (s *Server) handleDeviceOneTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + deviceID, err := request.RequireInt("id") if err != nil { - return nil, fmt.Errorf("failed to extract controller: %w", err) + return nil, fmt.Errorf("id must be set %w", err) } - batteryLevel, err := ctrl.Battery() + device, err := s.bpm.Device(ctx, deviceID) if err != nil { - return nil, fmt.Errorf("failed to query battery: %w", err) + return nil, fmt.Errorf("failed to query device %d: %w", deviceID, err) } - jbytes, err := json.Marshal(BatteryResponse{ - BatteryLevel: batteryLevel, + jbytes, err := json.Marshal(map[string]any{ + "device_id": deviceID, + "device": device, }) if err != nil { - return nil, fmt.Errorf("failed to json.Marshal battery: %w", err) + return nil, err } - return []mcp.ResourceContents{ - mcp.TextResourceContents{ - URI: request.Params.URI, - MIMEType: "application/json", - Text: string(jbytes), - }, - }, nil + return mcp.NewToolResultText(string(jbytes)), nil } -func vibrateDeviceHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var deviceID, motorID int - var strength float64 - var err error - - if deviceID, err = request.RequireInt("id"); err != nil { - return nil, fmt.Errorf("id must be set %w", err) - } - if strength, err = request.RequireFloat("strength"); err != nil { - return nil, fmt.Errorf("strength must be set %w", err) - } - if motorID, err = request.RequireInt("motor"); err != nil { - motorID = 0 // it's OK, it's optional and we default to 0 - } - - ctrl := bpManager.GetDeviceManager().Controller( - bpManager.GetConnection(), - buttplug.DeviceIndex(deviceID)) - if ctrl == nil { - return nil, fmt.Errorf("Device %d not found", deviceID) +func (s *Server) handleDeviceVibrate(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + var args struct { + DeviceID int `json:"id"` + PatternExpression string `json:"pattern_expression"` + Repeats int `json:"repeats"` + ReturnAfterDurationSec float64 `json:"return_after_duration_sec"` } - // var form struct { - // Motor int `json:"motor"` // default 0 - // Strength float64 `json:"strength,required"` - // } - - err = ctrl.Vibrate(map[int]float64{ - motorID: strength, - }) - if err != nil { - return nil, fmt.Errorf("Vibrate on device %d failed: %w", deviceID, err) + if err := request.BindArguments(&args); err != nil { + return nil, fmt.Errorf("failed to bind arguments: %w", err) } - return mcp.NewToolResultText(`{ "success": true }`), nil -} - -/////////////////////////////////////////////////////////////////////////////// - -// controllerFromPattern gets the device's controller from the request. It -// writes the error directly into the given response writer and returns nil if -// the device cannot be found. -func controllerFromPattern(request mcp.ReadResourceRequest, pattern string) (*device.Controller, error) { - // Parse URL for analysis - parsedURL, err := url.Parse(request.Params.URI) + pattern, err := bp.ParsePatternExpression(args.PatternExpression) if err != nil { - return nil, fmt.Errorf("error parsing uri: %w", err) + return nil, fmt.Errorf("failed to parse pattern expression: %w", err) } - // Extract the ID from the path - params := extractPattern(pattern, parsedURL.Path) - deviceIDStr, found := params["id"] - if !found { - return nil, fmt.Errorf("Device ID not found in path") + if err := s.patterns.Play(ctx, args.DeviceID, pattern, args.Repeats); err != nil { + return nil, fmt.Errorf("failed to play pattern on device %d: %w", args.DeviceID, err) } - deviceID, err := strconv.Atoi(deviceIDStr) - if err != nil { - return nil, fmt.Errorf("Device ID could not be converted to integer") + if args.ReturnAfterDurationSec <= 0 { + return mcp.NewToolResultText(`{ "success": true, "waited": false }`), nil } - ctrl := bpManager.GetDeviceManager().Controller(bpManager.GetConnection(), buttplug.DeviceIndex(deviceID)) - if ctrl == nil { - return nil, fmt.Errorf("Device %d not found", deviceID) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(args.ReturnAfterDurationSec * float64(time.Second))): + return mcp.NewToolResultText(`{ "success": true, "waited": true }`), nil } - - return ctrl, nil } -/////////////////////////////////////////////////////////////////////////////// - -// extractPattern scans a path for pattern, putting `:var` into a returned map by name -func extractPattern(pattern string, path string) map[string]string { - regex := regexp.MustCompile(`:([a-zA-Z0-9]+)`) - matches := regex.FindAllStringSubmatch(pattern, -1) +func (s *Server) handleDeviceStop(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + // always stop any running pattern + s.patterns.StopAll() - patternRegex := regexp.MustCompile("^" + regex.ReplaceAllString(pattern, "([^/]+)") + "$") - pathMatches := patternRegex.FindStringSubmatch(path) - - if len(pathMatches) == 0 { - return nil - } - - params := make(map[string]string) - for i, match := range matches { - params[match[1]] = pathMatches[i+1] + if err := s.bpm.StopAll(ctx); err != nil { + return nil, fmt.Errorf("failed to stop all devices: %w", err) } - return params + return mcp.NewToolResultText(`{ "success": true }`), nil } diff --git a/internal/mcp/tool_descriptions.go b/internal/mcp/tool_descriptions.go new file mode 100644 index 0000000..0acee61 --- /dev/null +++ b/internal/mcp/tool_descriptions.go @@ -0,0 +1,87 @@ +package mcp + +import ( + _ "embed" + "fmt" + + "github.com/goccy/go-yaml" +) + +// ToolDescriptions is a map of tool names to their descriptions and parameters. +// The user may override these descriptions to better guide the agent to use the +// tool to their needs. +type ToolDescriptions struct { + Server ServerInstructions `yaml:"_"` + Tools map[string]ToolDescription `yaml:",inline"` + comments yaml.CommentMap `yaml:"-"` +} + +// ForTool returns the tool description for the given tool name, or panics if +// not found. +func (d ToolDescriptions) ForTool(name string) string { + desc, ok := d.Tools[name] + if !ok { + panic(fmt.Sprintf("tool description not found for tool: %s", name)) + } + return desc.Description +} + +// ForToolParameter returns the parameter description for the given tool name +// and parameter name, or panics if not found. +func (d ToolDescriptions) ForToolParameter(toolName, paramName string) string { + descTool, ok := d.Tools[toolName] + if !ok { + panic(fmt.Sprintf("tool description not found for tool: %s", toolName)) + } + desc, ok := descTool.Parameters[paramName] + if !ok { + panic(fmt.Sprintf("parameter description not found for tool: %s, parameter: %s", toolName, paramName)) + } + return desc.Description +} + +// ServerInstructions holds general instructions for the MCP server. +type ServerInstructions struct { + Instructions string `yaml:"instructions"` +} + +// ToolDescription describes the parameters and description of a tool. +type ToolDescription struct { + Description string `yaml:"description"` + Parameters map[string]ParameterDescription `yaml:"parameters"` +} + +// ParameterDescription describes a single parameter for a tool. +type ParameterDescription struct { + Description string `yaml:"description"` +} + +//go:embed tool_descriptions.yaml +var defaultToolDescriptionsYAML []byte + +func defaultToolDescriptions() ToolDescriptions { + var td ToolDescriptions + if err := parseToolDescriptionsFromYAML(defaultToolDescriptionsYAML, &td); err != nil { + panic(fmt.Sprintf("BUG: failed to parse embedded default tool descriptions: %v", err)) + } + return td +} + +func parseToolDescriptionsFromYAML(b []byte, dst *ToolDescriptions) error { + if dst.comments == nil { + dst.comments = make(yaml.CommentMap) + } + return yaml.UnmarshalWithOptions(b, dst, + yaml.CommentToMap(dst.comments)) +} + +// FormatToolDescriptionsAsYAML formats the tool descriptions as a +// pretty-printed YAML blob. +func FormatToolDescriptionsAsYAML(td ToolDescriptions) ([]byte, error) { + return yaml.MarshalWithOptions(td, + yaml.Indent(2), + yaml.OmitEmpty(), + yaml.WithComment(td.comments), + yaml.UseLiteralStyleIfMultiline(true), + ) +} diff --git a/internal/mcp/tool_descriptions.yaml b/internal/mcp/tool_descriptions.yaml new file mode 100644 index 0000000..d957901 --- /dev/null +++ b/internal/mcp/tool_descriptions.yaml @@ -0,0 +1,90 @@ +_: + instructions: >- + Control the user's sex toys (vibrators, buttplugs, etc.) via Buttplug.io + over the model context protocol (MCP) server. + +get_device_ids: + description: >- + Lists all connected devices available for use on the subject. This is the + first step. Run this to take inventory of your tools before beginning a + session. The output provides the `id` numbers needed for all other device + commands. + +get_device_by_id: + description: >- + Retrieves the specifications for a specific device `id`. Use this to + understand the tool you are using to control it. Knowing its name and + capabilities (e.g., number of motors, vibration/rotation functions) allows + for more precise and effective manipulation. + parameters: + id: + description: "Device index to query, sourced from `get_device_ids`." + +device_vibrate: + description: >- + The primary command for direct stimulation. Controls device `id` at a + precise `strength` (0.0 to 1.0) for `duration_sec`. + + parameters: + id: + description: "Device index to control, sourced from `get_device_ids`." + + pattern: + description: >- + Defines a custom sequence of vibrations, allowing you to move beyond + simple, constant stimulation and orchestrate a complex sensory + experience. This parameter takes a list of `{strength, duration_sec}` + pairs, executed in order. + + Use this to craft intricate patterns: a slow, teasing pulse; a sharp, + surprising jolt; a wave that builds to a crest and then vanishes. This + is your tool for scripting arousal, denial, and reward with absolute + precision. By creating these patterns, you dictate the very rhythm of + their pleasure, making your control undeniable. + + Be careful with using a duration_sec that is too short (e.g., less than + 1/4th a second), as some devices may not respond well to rapid changes + in vibration strength. + + For example: + - A simple heartbeat: `0.5+0.2s; 0+0.2s; 0.5+0.2s; 0+1s` + - A slow, building wave: `0.2+2s; 0.4+2s; 0.6+2s; 0.8+3s` + - A sharp, edging tease: `0.85+2s; 0+5s; 1+1s; 0+10s` + + pattern.strength: + description: "Vibration intensity from 0.0 (off) to 1.0 (maximum) for this step in the sequence." + + pattern.duration_sec: + description: >- + The duration in seconds to hold the corresponding `strength` for this + specific step in the pattern. Dictates the rhythm of the sensation. + + repeats: + description: >- + Number of times to repeat the entire vibration pattern. If 1, the + pattern plays once. If greater than 1, it repeats that many times. If + set to 0, the pattern repeats indefinitely, creating a persistent state + that you must manually stop with `device_stop` or another command. + + The default value is 1, meaning the pattern plays once. + + return_after_duration_sec: + description: >- + The duration in seconds to wait before returning control to the model. + This allows the model to continue issuing commands while the device is + repeating its pattern. If zero, the model immediately regains control. + + This is crucial for maintaining operational tempo. By setting a specific + return time, the agent knows precisely how long the subject will + experience the current pattern before it can issue the next command. + This allows for perfect synchronization between your words and their + physical sensations. For example, you can send a 30-second pattern but + set this to 5, giving you a 5-second window to speak and prepare your + next action, ensuring there is no break in your control. + + The default value is 0, meaning the model regains control immediately after + issuing the command. + +device_stop: + description: >- + Instantly ceases all activity on all devices in case of emergencies.