diff --git a/CHANGELOG.md b/CHANGELOG.md index 475ee66..b866b69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,46 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## [0.12.0] - 2025-12-21 + +### Phase 12: Dynamic Tool Configuration + +**Summary:** FB-27 (Dynamic Tool Config) and FB-28 (Skills Update). + +### Added +- **Dynamic Tool Configuration** (FB-27, 3 tools): + - `get_tool_config` - Query tool group configuration (enabled/disabled state, tool counts) + - `set_tool_config` - Enable/disable tool groups at runtime (session-only or persistent) + - `list_tool_groups` - List all tool groups with descriptions and status +- **Meta-tools category** - 4 always-enabled tools that cannot be disabled: + - `help`, `get_tool_config`, `set_tool_config`, `list_tool_groups` +- Thread-safe tool configuration with `sync.RWMutex` +- Tool group metadata with tool counts and tool name lists +- Persistence option for tool configuration via SQLite + +### Changed +- "Help" category renamed to "Meta" tools for clarity +- Help content updated to describe meta-tools functionality +- **streaming-assistant skill** (FB-28): Added FB-25/26 tools and workflows: + - Virtual camera management (start/stop for video calls) + - Replay buffer highlight capture workflows + - Studio mode preview/program transitions + - Hotkey automation guidance + - Updated cleanup recommendations + +### Tests +- 32 test cases across 9 test functions for tool config handlers +- Tests for getGroupEnabled, setGroupEnabled, convertToStorageConfig helpers +- Tool group metadata validation tests + +### Metrics +- **Tools:** 72 (+3) +- **Resources:** 4 (unchanged) +- **Prompts:** 13 (unchanged) +- **Skills:** 4 (unchanged) + +--- + ## [0.11.0] - 2025-12-21 ### Phase 11: Virtual Camera, Replay Buffer, Studio Mode & Hotkeys @@ -290,6 +330,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). | Version | Phase | Tools | Resources | Prompts | Date | |---------|-------|-------|-----------|---------|------| +| 0.12.0 | 12 | 72 | 4 | 13 | 2025-12-21 | +| 0.11.0 | 11 | 69 | 4 | 13 | 2025-12-21 | +| 0.10.0 | 10 | 57 | 4 | 13 | 2025-12-20 | | 0.7.0 | 7 | 45 | 4 | 13 | 2025-12-18 | | 0.6.3 | 6.3 | 45 | 4 | 10 | 2025-12-17 | | 0.6.2 | 6.2 | 31 | 4 | 10 | 2025-12-17 | diff --git a/CLAUDE.md b/CLAUDE.md index a1cef45..4ce4fcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ Context for AI assistants working on the agentic-obs project. **agentic-obs** is an MCP (Model Context Protocol) server providing AI assistants with programmatic control over OBS Studio via the WebSocket API. -**Current Status:** 69 Tools | 4 Resources | 13 Prompts | 4 Skills +**Current Status:** 72 Tools | 4 Resources | 13 Prompts | 4 Skills ## Project Structure @@ -17,7 +17,7 @@ agentic-obs/ ├── internal/ │ ├── mcp/ │ │ ├── server.go # MCP server lifecycle -│ │ ├── tools.go # Tool registration (69 tools) +│ │ ├── tools.go # Tool registration (72 tools) │ │ ├── resources.go # Resource handlers (4 types) │ │ ├── prompts.go # Prompt definitions (13 prompts) │ │ ├── completions.go # Autocomplete handler @@ -56,7 +56,7 @@ agentic-obs/ AI Assistant (Claude) ↕ stdio (JSON-RPC) MCP Server (agentic-obs) - ├─ Tools (69) ─────────┐ + ├─ Tools (72) ─────────┐ ├─ Resources (4) ──────┼─→ OBS Client ─→ OBS Studio ├─ Prompts (13) ───────┘ ↕ WebSocket (4455) └─ Storage ─────────────→ SQLite @@ -150,7 +150,7 @@ go mod tidy ## MCP Capabilities Summary -### Tools (69 in 8 groups) +### Tools (72 in 8 groups + meta) | Group | Count | Examples | |-------|-------|----------| @@ -162,7 +162,7 @@ go mod tidy | Design | 14 | `create_text_source`, `set_source_transform` | | Filters | 7 | `list_source_filters`, `toggle_source_filter` | | Transitions | 5 | `list_transitions`, `set_current_transition` | -| Help | 1 | `help` (always enabled) | +| Meta | 4 | `help`, `get_tool_config`, `set_tool_config`, `list_tool_groups` (always enabled) | ### Resources (4 types) diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index b1b540d..1f9a9c6 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -1,7 +1,7 @@ # agentic-obs Project Status **Status:** Active Development -**Version:** 0.11.0 +**Version:** 0.12.0 **Updated:** 2025-12-21 --- @@ -23,7 +23,7 @@ | Metric | Count | |--------|-------| -| **MCP Tools** | 69 | +| **MCP Tools** | 72 | | **MCP Resources** | 4 | | **MCP Prompts** | 13 | | **Claude Skills** | 4 | diff --git a/README.md b/README.md index 57535c1..729fe20 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,28 @@ Enable AI to create and manipulate OBS sources programmatically. | `list_hotkeys` | List all available OBS hotkeys | | `trigger_hotkey_by_name` | Trigger a hotkey by name | -**Total: 69 tools in 8 groups** (Core, Sources, Audio, Layout, Visual, Design, Filters, Transitions) + Help +### Meta Tools (4 tools, always enabled) + +| Tool | Description | +|------|-------------| +| `help` | Get detailed help on tools, resources, prompts, workflows, or troubleshooting | +| `get_tool_config` | Query current tool group configuration (enabled/disabled state) | +| `set_tool_config` | Enable/disable tool groups at runtime (session-only or persistent) | +| `list_tool_groups` | List all tool groups with descriptions and status | + +**Example: Disable Visual tools for a lighter setup** +```json +{ + "tool": "set_tool_config", + "arguments": { + "group": "Visual", + "enabled": false, + "persist": true + } +} +``` + +**Total: 72 tools in 8 groups** (Core, Sources, Audio, Layout, Visual, Design, Filters, Transitions) + Meta (4 always-enabled tools) ## MCP Resources @@ -337,7 +358,7 @@ agentic-obs/ ├── main.go # Entry point (MCP server or TUI) ├── config/ # Configuration management ├── internal/ -│ ├── mcp/ # MCP server implementation (69 tools) +│ ├── mcp/ # MCP server implementation (72 tools) │ ├── obs/ # OBS WebSocket client │ ├── storage/ # SQLite persistence │ ├── http/ # HTTP server for screenshots and dashboard diff --git a/config/config.go b/config/config.go index fb7901d..0a2b2d1 100644 --- a/config/config.go +++ b/config/config.go @@ -35,12 +35,14 @@ type Config struct { // ToolGroupConfig controls which tool categories are enabled type ToolGroupConfig struct { - Core bool // Core OBS tools (scenes, recording, streaming, status) - Visual bool // Visual monitoring tools (screenshots) - Layout bool // Layout management tools (scene presets) - Audio bool // Audio control tools - Sources bool // Source management tools - Design bool // Scene design tools (source creation, transforms) + Core bool // Core OBS tools (scenes, recording, streaming, status, virtual cam, replay, studio mode, hotkeys) + Visual bool // Visual monitoring tools (screenshots) + Layout bool // Layout management tools (scene presets) + Audio bool // Audio control tools + Sources bool // Source management tools + Design bool // Scene design tools (source creation, transforms) + Filters bool // Filter management tools + Transitions bool // Transition control tools } // WebServerConfig controls HTTP server settings @@ -66,12 +68,14 @@ func DefaultConfig() *Config { OBSPassword: "", DBPath: filepath.Join(homeDir, ".agentic-obs", "db.sqlite"), ToolGroups: ToolGroupConfig{ - Core: true, - Visual: true, - Layout: true, - Audio: true, - Sources: true, - Design: true, + Core: true, + Visual: true, + Layout: true, + Audio: true, + Sources: true, + Design: true, + Filters: true, + Transitions: true, }, WebServer: WebServerConfig{ Enabled: true, @@ -150,12 +154,14 @@ func (c *Config) PromptFirstRunSetup() error { // Tool group prompts fmt.Println("\n--- Tool Groups ---") - c.ToolGroups.Core = promptBool("Core OBS control (scenes, recording, streaming)", c.ToolGroups.Core) + c.ToolGroups.Core = promptBool("Core OBS control (scenes, recording, streaming, virtual cam, replay, studio mode)", c.ToolGroups.Core) c.ToolGroups.Visual = promptBool("Visual monitoring (screenshot capture)", c.ToolGroups.Visual) c.ToolGroups.Layout = promptBool("Layout management (scene presets)", c.ToolGroups.Layout) c.ToolGroups.Audio = promptBool("Audio control (mute, volume)", c.ToolGroups.Audio) c.ToolGroups.Sources = promptBool("Source management (visibility, settings)", c.ToolGroups.Sources) c.ToolGroups.Design = promptBool("Scene design (create sources, transforms)", c.ToolGroups.Design) + c.ToolGroups.Filters = promptBool("Filter management (source filters)", c.ToolGroups.Filters) + c.ToolGroups.Transitions = promptBool("Transition control (scene transitions)", c.ToolGroups.Transitions) // Webserver prompt fmt.Println("\n--- HTTP Server ---") @@ -186,6 +192,8 @@ func (c *Config) PromptFirstRunSetup() error { fmt.Printf("Audio tools: %v\n", c.ToolGroups.Audio) fmt.Printf("Source tools: %v\n", c.ToolGroups.Sources) fmt.Printf("Design tools: %v\n", c.ToolGroups.Design) + fmt.Printf("Filter tools: %v\n", c.ToolGroups.Filters) + fmt.Printf("Transition tools: %v\n", c.ToolGroups.Transitions) fmt.Printf("HTTP server: %v", c.WebServer.Enabled) if c.WebServer.Enabled { fmt.Printf(" (port %d)", c.WebServer.Port) @@ -235,12 +243,14 @@ func LoadFromStorage(ctx context.Context, dbPath string) (*Config, error) { log.Printf("Warning: failed to load tool group config: %v", err) } else { cfg.ToolGroups = ToolGroupConfig{ - Core: toolGroups.Core, - Visual: toolGroups.Visual, - Layout: toolGroups.Layout, - Audio: toolGroups.Audio, - Sources: toolGroups.Sources, - Design: toolGroups.Design, + Core: toolGroups.Core, + Visual: toolGroups.Visual, + Layout: toolGroups.Layout, + Audio: toolGroups.Audio, + Sources: toolGroups.Sources, + Design: toolGroups.Design, + Filters: toolGroups.Filters, + Transitions: toolGroups.Transitions, } } @@ -289,12 +299,14 @@ func SaveToStorage(ctx context.Context, cfg *Config) error { // Save tool group preferences toolGroups := storage.ToolGroupConfig{ - Core: cfg.ToolGroups.Core, - Visual: cfg.ToolGroups.Visual, - Layout: cfg.ToolGroups.Layout, - Audio: cfg.ToolGroups.Audio, - Sources: cfg.ToolGroups.Sources, - Design: cfg.ToolGroups.Design, + Core: cfg.ToolGroups.Core, + Visual: cfg.ToolGroups.Visual, + Layout: cfg.ToolGroups.Layout, + Audio: cfg.ToolGroups.Audio, + Sources: cfg.ToolGroups.Sources, + Design: cfg.ToolGroups.Design, + Filters: cfg.ToolGroups.Filters, + Transitions: cfg.ToolGroups.Transitions, } if err := db.SaveToolGroupConfig(ctx, toolGroups); err != nil { return fmt.Errorf("failed to save tool group config: %w", err) diff --git a/design/ARCHITECTURE.md b/design/ARCHITECTURE.md index 7f630e1..34be9be 100644 --- a/design/ARCHITECTURE.md +++ b/design/ARCHITECTURE.md @@ -4,7 +4,7 @@ This document describes the system architecture of agentic-obs. ## System Overview -agentic-obs is an MCP (Model Context Protocol) server that bridges AI assistants with OBS Studio. It provides 69 tools, 4 resource types, and 13 prompts for programmatic OBS control. +agentic-obs is an MCP (Model Context Protocol) server that bridges AI assistants with OBS Studio. It provides 72 tools, 4 resource types, and 13 prompts for programmatic OBS control. ``` ┌─────────────────────────────────────────────────────────────────┐ @@ -19,7 +19,7 @@ agentic-obs is an MCP (Model Context Protocol) server that bridges AI assistants │ │ MCP Layer │ │ │ │ ┌──────────┐ ┌───────────┐ ┌─────────┐ ┌──────────┐ │ │ │ │ │ Tools │ │ Resources │ │ Prompts │ │Completions│ │ │ -│ │ │ (45) │ │ (4) │ │ (13) │ │ │ │ │ +│ │ │ (72) │ │ (4) │ │ (13) │ │ │ │ │ │ │ └────┬─────┘ └─────┬─────┘ └────┬────┘ └────┬─────┘ │ │ │ └───────┼──────────────┼─────────────┼───────────┼────────┘ │ │ │ │ │ │ │ @@ -55,7 +55,7 @@ agentic-obs is an MCP (Model Context Protocol) server that bridges AI assistants | Component | Package | Responsibility | |-----------|---------|----------------| | **MCP Server** | `internal/mcp/server.go` | Lifecycle, stdio transport, notification dispatch | -| **Tools** | `internal/mcp/tools.go` | 45 tool handlers organized in 6 groups | +| **Tools** | `internal/mcp/tools.go` | 72 tool handlers organized in 8 groups + meta | | **Resources** | `internal/mcp/resources.go` | Scene, screenshot, preset resource handlers | | **Prompts** | `internal/mcp/prompts.go` | 13 workflow prompt definitions | | **Completions** | `internal/mcp/completions.go` | Autocomplete for arguments and URIs | @@ -131,17 +131,19 @@ main.go ## MCP Protocol Integration -### Tools (45 total) +### Tools (72 total) | Group | Tools | Description | |-------|-------|-------------| -| **Core** | 13 | Scene management, recording, streaming, status | +| **Core** | 25 | Scene management, recording, streaming, virtual cam, replay buffer, studio mode, hotkeys | | **Sources** | 3 | Source visibility and settings | | **Audio** | 4 | Volume and mute control | | **Layout** | 6 | Scene preset management | | **Visual** | 4 | Screenshot source control | | **Design** | 14 | Source creation and transforms | -| **Help** | 1 | Documentation (always enabled) | +| **Filters** | 7 | Filter creation and management | +| **Transitions** | 5 | Scene transition control | +| **Meta** | 4 | Help, tool config (always enabled) | ### Resources (4 types) diff --git a/design/ROADMAP.md b/design/ROADMAP.md index 419444c..fcadf66 100644 --- a/design/ROADMAP.md +++ b/design/ROADMAP.md @@ -166,14 +166,14 @@ Tracked features with unique identifiers for reference. | FB-24 | Transitions | 5 tools for scene transition control | Phase 10 | | FB-25 | Virtual Cam & Replay | 6 tools for virtual camera and replay buffer | Phase 11 | | FB-26 | Studio Mode & Hotkeys | 6 tools for studio mode and hotkey control | Phase 11 | +| FB-27 | Dynamic Tool Config | 3 meta-tools for runtime tool group enable/disable | Phase 12 | +| FB-28 | Skills Update | streaming-assistant with virtual cam, replay, studio mode, hotkeys | Phase 12 | ### Active Backlog | ID | Name | Priority | Complexity | Dependencies | Description | |----|------|----------|------------|--------------|-------------| | FB-20 | Automation Rules | High | Medium | - | Event-triggered actions and macros | -| FB-27 | Dynamic Tool Config | High | Medium | - | Runtime tool group enable/disable via MCP tools | -| FB-28 | Skills Update | High | Low | FB-25, FB-26 ✅ | Update streaming-assistant with virtual cam, replay, studio mode | | FB-29 | New Prompts | Medium | Low | FB-25, FB-26 ✅ | Add virtual-cam-control, replay-management prompts | | FB-30 | Scene Designer Filters | Medium | Low | FB-23 ✅ | Add filter section to scene-designer skill | | FB-31 | Studio Mode Skill | Medium | Medium | FB-26 ✅ | New studio-mode-operator skill for preview/program workflow | diff --git a/docs/TOOLS.md b/docs/TOOLS.md index e03933b..5f5172e 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -1,6 +1,6 @@ # MCP Tool Reference -Comprehensive documentation for all 69 Model Context Protocol (MCP) tools provided by the agentic-obs server. +Comprehensive documentation for all 72 Model Context Protocol (MCP) tools provided by the agentic-obs server. ## Table of Contents @@ -45,6 +45,10 @@ Comprehensive documentation for all 69 Model Context Protocol (MCP) tools provid - [get_obs_status](#get_obs_status) - [Help & Discovery](#help--discovery) - [help](#help) +- [Tool Configuration](#tool-configuration) + - [get_tool_config](#get_tool_config) + - [set_tool_config](#set_tool_config) + - [list_tool_groups](#list_tool_groups) - [Scene Design](#scene-design) - [create_text_source](#create_text_source) - [create_image_source](#create_image_source) @@ -95,7 +99,7 @@ Comprehensive documentation for all 69 Model Context Protocol (MCP) tools provid ## Overview -The agentic-obs MCP server provides 69 tools organized into 14 categories (8 tool groups + help) for comprehensive OBS Studio control. All tools communicate with OBS via WebSocket (default port 4455) and return structured JSON responses. +The agentic-obs MCP server provides 72 tools organized into 14 categories (8 tool groups + 4 meta-tools) for comprehensive OBS Studio control. All tools communicate with OBS via WebSocket (default port 4455) and return structured JSON responses. | Category | Tools | Description | Tool Group | |----------|-------|-------------|------------| @@ -1630,7 +1634,7 @@ The Help tool provides built-in documentation and guidance for using agentic-obs | Topic | Description | |-------|-------------| | `overview` | Quick start guide and feature summary | -| `tools` | List of all 45 tools by category | +| `tools` | List of all 72 tools by category | | `resources` | MCP resource types and URI patterns | | `prompts` | Available workflow prompts | | `workflows` | Common workflow patterns and best practices | @@ -1662,6 +1666,134 @@ The Help tool provides built-in documentation and guidance for using agentic-obs --- +## Tool Configuration + +Tool Configuration meta-tools allow runtime control over which tool groups are enabled. These tools are **always enabled** and cannot be disabled. + +### get_tool_config + +**Purpose:** Get current tool group configuration showing which tool groups are enabled or disabled. + +**Parameters:** +| Name | Type | Required | Description | +|------|------|----------|-------------| +| group | string | No | Filter by specific group name (Core, Sources, Audio, Layout, Visual, Design, Filters, Transitions) | +| verbose | boolean | No | Include list of tool names in each group (default: false) | + +**Return Value Schema:** +```json +{ + "groups": [ + { + "name": "Core", + "description": "Core OBS tools: scenes, recording, streaming, status, virtual camera, replay buffer, studio mode, and hotkeys", + "enabled": true, + "tool_count": 25, + "tools": ["list_scenes", "set_current_scene", "..."] + } + ], + "total_tools": 72, + "enabled_tools": 72, + "meta_tools": ["help", "get_tool_config", "set_tool_config", "list_tool_groups"], + "message": "72 of 72 tools enabled across 8 groups" +} +``` + +**Example Request:** +```json +{ + "group": "Audio", + "verbose": true +} +``` + +**Best Practices:** +- Use without parameters to get overview of all groups +- Use `verbose=true` to see specific tool names in each group +- Filter by group name when interested in a specific category + +### set_tool_config + +**Purpose:** Enable or disable a tool group at runtime. Changes are session-only by default. + +**Parameters:** +| Name | Type | Required | Description | +|------|------|----------|-------------| +| group | string | Yes | Tool group name (Core, Sources, Audio, Layout, Visual, Design, Filters, Transitions) | +| enabled | boolean | Yes | True to enable, false to disable | +| persist | boolean | No | Save to database for future sessions (default: false) | + +**Return Value Schema:** +```json +{ + "group": "Audio", + "previous_state": true, + "new_state": false, + "tools_affected": 4, + "persisted": false, + "message": "Tool group 'Audio' (4 tools) disabled" +} +``` + +**Example Request:** +```json +{ + "group": "Visual", + "enabled": false, + "persist": true +} +``` + +**Best Practices:** +- Use session-only (persist=false) for temporary changes +- Use persist=true to remember preferences across restarts +- Meta-tools cannot be disabled + +### list_tool_groups + +**Purpose:** List all available tool groups with their descriptions and enabled status. + +**Parameters:** +| Name | Type | Required | Description | +|------|------|----------|-------------| +| include_disabled | boolean | No | Include disabled groups in listing (default: true) | + +**Return Value Schema:** +```json +{ + "groups": [ + { + "name": "Core", + "description": "Core OBS tools: scenes, recording, streaming, status, virtual camera, replay buffer, studio mode, and hotkeys", + "enabled": true, + "tool_count": 25 + } + ], + "count": 8, + "meta_tools": ["help", "get_tool_config", "set_tool_config", "list_tool_groups"], + "message": "Found 8 tool groups" +} +``` + +**Tool Groups Overview:** +| Group | Count | Description | +|-------|-------|-------------| +| Core | 25 | Scene management, recording, streaming, virtual camera, replay buffer, studio mode, hotkeys | +| Sources | 3 | Source visibility and settings | +| Audio | 4 | Audio input muting and volume control | +| Layout | 6 | Scene preset management | +| Visual | 4 | Screenshot capture for AI visual analysis | +| Design | 14 | Source creation and transform control | +| Filters | 7 | Source filter management | +| Transitions | 5 | Scene transition control | + +**Best Practices:** +- Use with `include_disabled=false` to see only active groups +- Quick way to understand available capabilities +- Use `get_tool_config` with `verbose=true` for detailed tool lists + +--- + ## Scene Design Scene Design tools enable AI assistants to programmatically create and manipulate OBS sources. These tools are part of the **Design** tool group. diff --git a/internal/docs/content/README.md b/internal/docs/content/README.md index 7379bb9..00f2ce2 100644 --- a/internal/docs/content/README.md +++ b/internal/docs/content/README.md @@ -264,7 +264,7 @@ Enable AI to create and manipulate OBS sources programmatically. | `set_transition_duration` | Set transition duration in milliseconds | | `trigger_transition` | Trigger studio mode transition (preview to program) | -**Total: 69 tools in 8 groups** (Core, Sources, Audio, Layout, Visual, Design, Filters, Transitions) + Help +**Total: 72 tools in 8 groups** (Core, Sources, Audio, Layout, Visual, Design, Filters, Transitions) + Meta (4 always-enabled tools) ## MCP Resources @@ -315,7 +315,7 @@ agentic-obs/ ├── main.go # Entry point (MCP server or TUI) ├── config/ # Configuration management ├── internal/ -│ ├── mcp/ # MCP server implementation (69 tools) +│ ├── mcp/ # MCP server implementation (72 tools) │ ├── obs/ # OBS WebSocket client │ ├── storage/ # SQLite persistence │ ├── http/ # HTTP server for screenshots and dashboard diff --git a/internal/docs/content/TOOLS.md b/internal/docs/content/TOOLS.md index 5f4a089..41193a4 100644 --- a/internal/docs/content/TOOLS.md +++ b/internal/docs/content/TOOLS.md @@ -67,7 +67,7 @@ Comprehensive documentation for all 45 Model Context Protocol (MCP) tools provid ## Overview -The agentic-obs MCP server provides 69 tools organized into 14 categories (8 tool groups + help) for comprehensive OBS Studio control. All tools communicate with OBS via WebSocket (default port 4455) and return structured JSON responses. +The agentic-obs MCP server provides 72 tools organized into 14 categories (8 tool groups + 4 meta-tools) for comprehensive OBS Studio control. All tools communicate with OBS via WebSocket (default port 4455) and return structured JSON responses. | Category | Tools | Description | Tool Group | |----------|-------|-------------|------------| diff --git a/internal/http/handlers.go b/internal/http/handlers.go index 1ccef07..2c725a7 100644 --- a/internal/http/handlers.go +++ b/internal/http/handlers.go @@ -156,8 +156,8 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { toolGroups, err := s.storage.LoadToolGroupConfig(r.Context()) if err != nil { - log.Printf("Failed to load tool group config: %v", err) - toolGroups = storage.ToolGroupConfig{Core: true, Visual: true, Layout: true, Audio: true, Sources: true} + log.Printf("Warning: failed to load tool group config: %v", err) + toolGroups = storage.DefaultToolGroupConfig() } webServer, err := s.storage.LoadWebServerConfig(r.Context()) @@ -173,11 +173,14 @@ func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { // Password intentionally omitted for security }, "tool_groups": map[string]bool{ - "core": toolGroups.Core, - "visual": toolGroups.Visual, - "layout": toolGroups.Layout, - "audio": toolGroups.Audio, - "sources": toolGroups.Sources, + "core": toolGroups.Core, + "visual": toolGroups.Visual, + "layout": toolGroups.Layout, + "audio": toolGroups.Audio, + "sources": toolGroups.Sources, + "design": toolGroups.Design, + "filters": toolGroups.Filters, + "transitions": toolGroups.Transitions, }, "web_server": map[string]interface{}{ "enabled": webServer.Enabled, @@ -204,14 +207,17 @@ func (s *Server) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { // Process tool_groups updates if tg, ok := updates["tool_groups"].(map[string]interface{}); ok { config := storage.ToolGroupConfig{ - Core: getBool(tg, "core", true), - Visual: getBool(tg, "visual", true), - Layout: getBool(tg, "layout", true), - Audio: getBool(tg, "audio", true), - Sources: getBool(tg, "sources", true), + Core: getBool(tg, "core", true), + Visual: getBool(tg, "visual", true), + Layout: getBool(tg, "layout", true), + Audio: getBool(tg, "audio", true), + Sources: getBool(tg, "sources", true), + Design: getBool(tg, "design", true), + Filters: getBool(tg, "filters", true), + Transitions: getBool(tg, "transitions", true), } if err := s.storage.SaveToolGroupConfig(r.Context(), config); err != nil { - log.Printf("Failed to save tool group config: %v", err) + log.Printf("Warning: failed to save tool group config: %v", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Failed to save tool groups"}) return } diff --git a/internal/mcp/help_content.go b/internal/mcp/help_content.go index dc59423..ff58acf 100644 --- a/internal/mcp/help_content.go +++ b/internal/mcp/help_content.go @@ -21,13 +21,13 @@ import "fmt" // // ============================================================================ const ( - HelpToolCount = 69 // Total MCP tools (including help) + HelpToolCount = 72 // Total MCP tools (including meta-tools) HelpResourceCount = 4 // Resource types: scenes, screenshots, screenshot-url, presets HelpPromptCount = 13 // Workflow prompts // Tool counts by category (should sum to HelpToolCount) HelpCoreToolCount = 25 // Scene management, recording, streaming, status, virtual cam, replay buffer, studio mode, hotkeys - HelpHelpToolCount = 1 // The help tool itself + HelpMetaToolCount = 4 // Meta-tools: help, get_tool_config, set_tool_config, list_tool_groups (FB-27) HelpSourcesToolCount = 3 // Source management HelpAudioToolCount = 4 // Audio control HelpLayoutToolCount = 6 // Scene presets @@ -123,9 +123,12 @@ func GetToolsHelp(verbose bool) string { **Status:** - get_obs_status - Overall OBS connection and state -## Help Tool (%d tool) - Always Enabled +## Meta Tools (%d tools) - Always Enabled - help - Get detailed help on tools, resources, prompts, workflows, or troubleshooting +- get_tool_config - Get current tool group configuration +- set_tool_config - Enable/disable tool groups at runtime +- list_tool_groups - List all tool groups with their status ## Sources Tools (%d tools) - Source Management @@ -195,7 +198,7 @@ func GetToolsHelp(verbose bool) string { - set_current_transition - Change active transition (Cut, Fade, Swipe, etc.) - set_transition_duration - Set transition duration in milliseconds - trigger_transition - Trigger studio mode transition (preview to program) -`, HelpToolCount, HelpCoreToolCount, HelpHelpToolCount, HelpSourcesToolCount, +`, HelpToolCount, HelpCoreToolCount, HelpMetaToolCount, HelpSourcesToolCount, HelpAudioToolCount, HelpLayoutToolCount, HelpVisualToolCount, HelpDesignToolCount, HelpFiltersToolCount, HelpTransitionsToolCount) diff --git a/internal/mcp/help_test.go b/internal/mcp/help_test.go index fbc01db..2c6f7f9 100644 --- a/internal/mcp/help_test.go +++ b/internal/mcp/help_test.go @@ -443,7 +443,7 @@ func TestGetOverviewHelp(t *testing.T) { assert.Contains(t, help, "What is agentic-obs") assert.Contains(t, help, "Quick Start") assert.Contains(t, help, "Key Features") - assert.Contains(t, help, "69 Tools") + assert.Contains(t, help, "72 Tools") assert.Contains(t, help, "4 Resource Types") }) @@ -465,7 +465,7 @@ func TestGetToolsHelp(t *testing.T) { help := GetToolsHelp(false) assert.Contains(t, help, "All Available Tools") assert.Contains(t, help, "Core Tools") - assert.Contains(t, help, "Help Tool") + assert.Contains(t, help, "Meta Tools") assert.Contains(t, help, "Sources Tools") assert.Contains(t, help, "Audio Tools") assert.Contains(t, help, "Layout Tools") diff --git a/internal/mcp/help_tools.go b/internal/mcp/help_tools.go index 634f73a..b023818 100644 --- a/internal/mcp/help_tools.go +++ b/internal/mcp/help_tools.go @@ -1530,6 +1530,94 @@ var toolHelpContent = map[string]string{ **Use Case**: Discover available hotkeys for automation or to find the correct name for trigger_hotkey_by_name. **Note**: Hotkey names follow the pattern "Context.Action" (e.g., "OBSBasic.StartRecording").`, + + // Meta Tools - Tool Configuration + "get_tool_config": `# get_tool_config + +**Category**: Meta Tools + +**Description**: Get current tool group configuration showing which tool groups are enabled or disabled. + +**Input**: +- group (string, optional): Filter by specific group name (Core, Sources, Audio, Layout, Visual, Design, Filters, Transitions) +- verbose (boolean, optional): Include list of tool names in each group (default: false) + +**Output**: +- groups: Array of tool group info (name, description, enabled, tool_count, tools) +- total_tools: Total number of tools across all groups +- enabled_tools: Number of currently enabled tools +- meta_tools: List of meta-tools that cannot be disabled (help, get_tool_config, set_tool_config, list_tool_groups) +- message: Human-readable summary + +**Example Input**: +{ + "group": "Audio", + "verbose": true +} + +**Use Case**: Check which tool categories are available and their enabled state. Useful for understanding available capabilities.`, + + "set_tool_config": `# set_tool_config + +**Category**: Meta Tools + +**Description**: Enable or disable a tool group at runtime. Changes are session-only by default. + +**Input**: +- group (string, required): Tool group name to configure (Core, Sources, Audio, Layout, Visual, Design, Filters, Transitions) +- enabled (boolean, required): True to enable the group, false to disable +- persist (boolean, optional): Save configuration to database for future sessions (default: false) + +**Output**: +- group: The configured group name +- previous_state: Previous enabled state (true/false) +- new_state: New enabled state (true/false) +- tools_affected: Number of tools affected by this change +- persisted: Whether the change was saved to database +- message: Human-readable confirmation + +**Example Input**: +{ + "group": "Visual", + "enabled": false, + "persist": true +} + +**Use Case**: Temporarily disable tool categories you don't need to reduce cognitive load, or permanently configure your preferred tool setup. + +**Note**: Meta-tools (help, get_tool_config, set_tool_config, list_tool_groups) cannot be disabled.`, + + "list_tool_groups": `# list_tool_groups + +**Category**: Meta Tools + +**Description**: List all available tool groups with their descriptions and enabled status. + +**Input**: +- include_disabled (boolean, optional): Include disabled groups in listing (default: true) + +**Output**: +- groups: Array of tool group info (name, description, enabled, tool_count) +- count: Number of groups in response +- meta_tools: List of meta-tools that are always available +- message: Human-readable summary + +**Example Input**: +{ + "include_disabled": false +} + +**Use Case**: Quick overview of tool categories without detailed tool lists. Use get_tool_config with verbose=true for full tool lists. + +**Tool Groups**: +- Core (25 tools): Scene management, recording, streaming, virtual camera, replay buffer, studio mode, hotkeys +- Sources (3 tools): Source visibility and settings +- Audio (4 tools): Audio input muting and volume control +- Layout (6 tools): Scene preset management +- Visual (4 tools): Screenshot capture for AI visual analysis +- Design (14 tools): Source creation and transform control +- Filters (7 tools): Source filter management +- Transitions (5 tools): Scene transition control`, } // GetToolHelpContent returns the help text for a specific tool, or empty if not found. diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 7e53f17..0e05886 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -119,6 +119,7 @@ type Server struct { screenshotMgr *screenshot.Manager httpServer *agenthttp.Server toolGroups ToolGroupConfig + toolGroupMutex sync.RWMutex // Protects toolGroups for runtime config changes thumbnailCache *thumbnailCache ctx context.Context cancel context.CancelFunc diff --git a/internal/mcp/tool_config.go b/internal/mcp/tool_config.go new file mode 100644 index 0000000..d7d7732 --- /dev/null +++ b/internal/mcp/tool_config.go @@ -0,0 +1,352 @@ +// Package mcp provides MCP server implementation for OBS control. +package mcp + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/ironystock/agentic-obs/internal/storage" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ToolGroupMetadata contains static information about each tool group. +type ToolGroupMetadata struct { + Name string // Group name (e.g., "Core", "Audio") + Description string // Human-readable description + ToolCount int // Number of tools in this group + ToolNames []string // Tool names in this group +} + +// ToolGroupOrder defines the canonical ordering of tool groups. +// Used for consistent iteration and validation across the codebase. +var ToolGroupOrder = []string{"Core", "Sources", "Audio", "Layout", "Visual", "Design", "Filters", "Transitions"} + +// toolGroupMetadata defines metadata for all tool groups. +var toolGroupMetadata = map[string]*ToolGroupMetadata{ + "Core": { + Name: "Core", + Description: "Core OBS tools: scenes, recording, streaming, status, virtual camera, replay buffer, studio mode, and hotkeys", + ToolCount: 25, + ToolNames: []string{ + "list_scenes", "set_current_scene", "create_scene", "remove_scene", + "start_recording", "stop_recording", "get_recording_status", "pause_recording", "resume_recording", + "start_streaming", "stop_streaming", "get_streaming_status", + "get_obs_status", + "get_virtual_cam_status", "toggle_virtual_cam", + "get_replay_buffer_status", "toggle_replay_buffer", "save_replay_buffer", "get_last_replay", + "get_studio_mode_enabled", "toggle_studio_mode", "get_preview_scene", "set_preview_scene", + "list_hotkeys", "trigger_hotkey_by_name", + }, + }, + "Sources": { + Name: "Sources", + Description: "Source management: listing sources, visibility control, and settings", + ToolCount: 3, + ToolNames: []string{"list_sources", "toggle_source_visibility", "get_source_settings"}, + }, + "Audio": { + Name: "Audio", + Description: "Audio input control: mute state and volume levels", + ToolCount: 4, + ToolNames: []string{"get_input_mute", "toggle_input_mute", "set_input_volume", "get_input_volume"}, + }, + "Layout": { + Name: "Layout", + Description: "Scene preset management: save, apply, and organize source visibility presets", + ToolCount: 6, + ToolNames: []string{"save_scene_preset", "list_scene_presets", "get_preset_details", "apply_scene_preset", "rename_scene_preset", "delete_scene_preset"}, + }, + "Visual": { + Name: "Visual", + Description: "Visual monitoring: screenshot capture sources for AI visual analysis", + ToolCount: 4, + ToolNames: []string{"create_screenshot_source", "remove_screenshot_source", "list_screenshot_sources", "configure_screenshot_cadence"}, + }, + "Design": { + Name: "Design", + Description: "Scene design: create sources (text, image, browser, media) and control transforms", + ToolCount: 14, + ToolNames: []string{ + "create_text_source", "create_image_source", "create_color_source", "create_browser_source", "create_media_source", + "set_source_transform", "get_source_transform", "set_source_crop", "set_source_bounds", "set_source_order", + "set_source_locked", "duplicate_source", "remove_source", "list_input_kinds", + }, + }, + "Filters": { + Name: "Filters", + Description: "Source filter management: create, configure, and toggle filters on sources", + ToolCount: 7, + ToolNames: []string{"list_source_filters", "get_source_filter", "create_source_filter", "remove_source_filter", "toggle_source_filter", "set_source_filter_settings", "list_filter_kinds"}, + }, + "Transitions": { + Name: "Transitions", + Description: "Scene transition control: list, set, and trigger transitions", + ToolCount: 5, + ToolNames: []string{"list_transitions", "get_current_transition", "set_current_transition", "set_transition_duration", "trigger_transition"}, + }, +} + +// MetaToolNames are tools that are always enabled and cannot be disabled. +var MetaToolNames = []string{"help", "get_tool_config", "set_tool_config", "list_tool_groups"} + +// ToolGroupInfo represents information about a tool group for API responses. +type ToolGroupInfo struct { + Name string `json:"name"` + Description string `json:"description"` + Enabled bool `json:"enabled"` + ToolCount int `json:"tool_count"` + Tools []string `json:"tools,omitempty"` // Only included with verbose=true +} + +// GetToolConfigInput is the input for querying tool configuration. +type GetToolConfigInput struct { + Group string `json:"group,omitempty" jsonschema:"Filter by group name (Core, Visual, Audio, Layout, Sources, Design, Filters, Transitions)"` + Verbose bool `json:"verbose,omitempty" jsonschema:"Include list of tool names per group"` +} + +// SetToolConfigInput is the input for modifying tool configuration. +type SetToolConfigInput struct { + Group string `json:"group" jsonschema:"Tool group name to enable/disable"` + Enabled bool `json:"enabled" jsonschema:"True to enable the group, false to disable"` + Persist bool `json:"persist,omitempty" jsonschema:"Save to database for future sessions (default: session-only)"` +} + +// ListToolGroupsInput is the input for listing tool groups. +type ListToolGroupsInput struct { + IncludeDisabled bool `json:"include_disabled,omitempty" jsonschema:"Include disabled groups in listing (default: true)"` +} + +// handleGetToolConfig returns the current tool configuration. +func (s *Server) handleGetToolConfig(ctx context.Context, request *mcpsdk.CallToolRequest, input GetToolConfigInput) (*mcpsdk.CallToolResult, any, error) { + start := time.Now() + log.Printf("Getting tool configuration (group=%s, verbose=%v)", input.Group, input.Verbose) + + s.toolGroupMutex.RLock() + defer s.toolGroupMutex.RUnlock() + + var groups []ToolGroupInfo + + // Build group info based on current state + for _, groupName := range ToolGroupOrder { + // If filtering by group, skip non-matching groups + if input.Group != "" && input.Group != groupName { + continue + } + + meta := toolGroupMetadata[groupName] + if meta == nil { + continue + } + + info := ToolGroupInfo{ + Name: meta.Name, + Description: meta.Description, + Enabled: s.getGroupEnabled(groupName), + ToolCount: meta.ToolCount, + } + + if input.Verbose { + info.Tools = meta.ToolNames + } + + groups = append(groups, info) + } + + // Calculate totals + totalTools := 0 + enabledTools := 0 + for _, g := range groups { + totalTools += g.ToolCount + if g.Enabled { + enabledTools += g.ToolCount + } + } + + // Add meta-tools to count (always enabled) + totalTools += len(MetaToolNames) + enabledTools += len(MetaToolNames) + + result := map[string]interface{}{ + "groups": groups, + "total_tools": totalTools, + "enabled_tools": enabledTools, + "meta_tools": MetaToolNames, + "message": fmt.Sprintf("%d of %d tools enabled across %d groups", enabledTools, totalTools, len(groups)), + } + + s.recordAction("get_tool_config", "Get tool configuration", input, result, true, time.Since(start)) + return nil, result, nil +} + +// handleSetToolConfig enables or disables a tool group. +func (s *Server) handleSetToolConfig(ctx context.Context, request *mcpsdk.CallToolRequest, input SetToolConfigInput) (*mcpsdk.CallToolResult, any, error) { + start := time.Now() + log.Printf("Setting tool config: group=%s, enabled=%v, persist=%v", input.Group, input.Enabled, input.Persist) + + // Validate group name (before acquiring lock to reduce contention on invalid input) + meta := toolGroupMetadata[input.Group] + if meta == nil { + return nil, nil, fmt.Errorf("invalid group name '%s'. Valid groups: %v", input.Group, ToolGroupOrder) + } + + s.toolGroupMutex.Lock() + previousState := s.getGroupEnabled(input.Group) + s.setGroupEnabled(input.Group, input.Enabled) + // Capture config snapshot while holding lock to avoid race condition + configSnapshot := s.convertToStorageConfig() + s.toolGroupMutex.Unlock() + + // Persist if requested (using snapshot captured under lock) + persisted := false + var persistError string + if input.Persist && s.storage != nil { + if err := s.storage.SaveToolGroupConfig(ctx, configSnapshot); err != nil { + log.Printf("Warning: failed to persist tool config: %v", err) + persistError = err.Error() + } else { + persisted = true + } + } + + action := "enabled" + if !input.Enabled { + action = "disabled" + } + + result := map[string]interface{}{ + "group": input.Group, + "previous_state": previousState, + "new_state": input.Enabled, + "tools_affected": meta.ToolCount, + "persisted": persisted, + "message": fmt.Sprintf("Tool group '%s' (%d tools) %s", input.Group, meta.ToolCount, action), + } + + // Include persistence error if it occurred + if persistError != "" { + result["persist_error"] = persistError + result["message"] = fmt.Sprintf("Tool group '%s' (%d tools) %s (persistence failed: %s)", input.Group, meta.ToolCount, action, persistError) + } + + // IMPORTANT: Tool filtering implementation notes + // Current behavior (Phase 1): All tools remain registered in MCP. The config + // state is tracked for future use and UI display, but tools are not actually + // disabled at runtime. This is intentional - dynamic tool registration/removal + // would require MCP server restart or protocol-level tool list updates. + // + // Future enhancement (Phase 2): Options include: + // 1. Handler-level checks: Each tool handler checks isGroupEnabled() and returns + // an error like "Tool group 'X' is disabled" if the group is off. + // 2. Dynamic registration: Restart MCP server or use notifications to update + // the tool list when config changes. + // 3. Startup filtering: Only register enabled groups on server initialization. + + s.recordAction("set_tool_config", "Set tool configuration", input, result, true, time.Since(start)) + return nil, result, nil +} + +// handleListToolGroups lists all available tool groups. +func (s *Server) handleListToolGroups(ctx context.Context, request *mcpsdk.CallToolRequest, input ListToolGroupsInput) (*mcpsdk.CallToolResult, any, error) { + start := time.Now() + log.Println("Listing tool groups") + + s.toolGroupMutex.RLock() + defer s.toolGroupMutex.RUnlock() + + var groups []ToolGroupInfo + + for _, groupName := range ToolGroupOrder { + meta := toolGroupMetadata[groupName] + if meta == nil { + continue + } + + enabled := s.getGroupEnabled(groupName) + + // Skip disabled groups if not including them + if !input.IncludeDisabled && !enabled { + continue + } + + groups = append(groups, ToolGroupInfo{ + Name: meta.Name, + Description: meta.Description, + Enabled: enabled, + ToolCount: meta.ToolCount, + }) + } + + result := map[string]interface{}{ + "groups": groups, + "count": len(groups), + "meta_tools": MetaToolNames, + "message": fmt.Sprintf("Found %d tool groups", len(groups)), + } + + s.recordAction("list_tool_groups", "List tool groups", input, result, true, time.Since(start)) + return nil, result, nil +} + +// getGroupEnabled returns whether a tool group is enabled. +// Must be called with toolGroupMutex held. +func (s *Server) getGroupEnabled(group string) bool { + switch group { + case "Core": + return s.toolGroups.Core + case "Sources": + return s.toolGroups.Sources + case "Audio": + return s.toolGroups.Audio + case "Layout": + return s.toolGroups.Layout + case "Visual": + return s.toolGroups.Visual + case "Design": + return s.toolGroups.Design + case "Filters": + return s.toolGroups.Filters + case "Transitions": + return s.toolGroups.Transitions + default: + return false + } +} + +// setGroupEnabled sets whether a tool group is enabled. +// Must be called with toolGroupMutex held. +func (s *Server) setGroupEnabled(group string, enabled bool) { + switch group { + case "Core": + s.toolGroups.Core = enabled + case "Sources": + s.toolGroups.Sources = enabled + case "Audio": + s.toolGroups.Audio = enabled + case "Layout": + s.toolGroups.Layout = enabled + case "Visual": + s.toolGroups.Visual = enabled + case "Design": + s.toolGroups.Design = enabled + case "Filters": + s.toolGroups.Filters = enabled + case "Transitions": + s.toolGroups.Transitions = enabled + } +} + +// convertToStorageConfig converts the server's tool group config to storage format. +func (s *Server) convertToStorageConfig() storage.ToolGroupConfig { + return storage.ToolGroupConfig{ + Core: s.toolGroups.Core, + Visual: s.toolGroups.Visual, + Layout: s.toolGroups.Layout, + Audio: s.toolGroups.Audio, + Sources: s.toolGroups.Sources, + Design: s.toolGroups.Design, + Filters: s.toolGroups.Filters, + Transitions: s.toolGroups.Transitions, + } +} diff --git a/internal/mcp/tool_config_test.go b/internal/mcp/tool_config_test.go new file mode 100644 index 0000000..5c37806 --- /dev/null +++ b/internal/mcp/tool_config_test.go @@ -0,0 +1,651 @@ +package mcp + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ironystock/agentic-obs/internal/mcp/testutil" + "github.com/ironystock/agentic-obs/internal/storage" +) + +// testServerForToolConfig creates a test server with mock OBS client and storage for tool config tests. +func testServerForToolConfig(t *testing.T) (*Server, *testutil.MockOBSClient, *storage.DB) { + t.Helper() + + mock := testutil.NewMockOBSClient() + mock.Connect() + + // Create temp database + dbPath := filepath.Join(t.TempDir(), "test.db") + db, err := storage.New(context.Background(), storage.Config{Path: dbPath}) + require.NoError(t, err) + + server := &Server{ + obsClient: mock, + storage: db, + toolGroups: DefaultToolGroupConfig(), // All groups enabled + ctx: context.Background(), + } + + t.Cleanup(func() { + db.Close() + }) + + return server, mock, db +} + +// Test get_tool_config handler + +func TestHandleGetToolConfig(t *testing.T) { + t.Run("returns all groups when no filter", func(t *testing.T) { + server, _, _ := testServerForToolConfig(t) + + input := GetToolConfigInput{} + _, result, err := server.handleGetToolConfig(context.Background(), nil, input) + + assert.NoError(t, err) + assert.NotNil(t, result) + + resultMap, ok := result.(map[string]interface{}) + require.True(t, ok, "result should be a map") + + groups, ok := resultMap["groups"].([]ToolGroupInfo) + require.True(t, ok, "groups should be []ToolGroupInfo") + assert.Len(t, groups, 8, "should have 8 tool groups") + + // Verify all groups are enabled by default + for _, g := range groups { + assert.True(t, g.Enabled, "group %s should be enabled", g.Name) + } + }) + + t.Run("filters by group name", func(t *testing.T) { + server, _, _ := testServerForToolConfig(t) + + input := GetToolConfigInput{Group: "Audio"} + _, result, err := server.handleGetToolConfig(context.Background(), nil, input) + + assert.NoError(t, err) + resultMap := result.(map[string]interface{}) + groups := resultMap["groups"].([]ToolGroupInfo) + + assert.Len(t, groups, 1, "should have 1 group when filtering") + assert.Equal(t, "Audio", groups[0].Name) + assert.Equal(t, 4, groups[0].ToolCount) + }) + + t.Run("includes tool names when verbose", func(t *testing.T) { + server, _, _ := testServerForToolConfig(t) + + input := GetToolConfigInput{Group: "Audio", Verbose: true} + _, result, err := server.handleGetToolConfig(context.Background(), nil, input) + + assert.NoError(t, err) + resultMap := result.(map[string]interface{}) + groups := resultMap["groups"].([]ToolGroupInfo) + + assert.Len(t, groups, 1) + assert.NotNil(t, groups[0].Tools, "should include tool names") + assert.Contains(t, groups[0].Tools, "toggle_input_mute") + }) + + t.Run("excludes tool names when not verbose", func(t *testing.T) { + server, _, _ := testServerForToolConfig(t) + + input := GetToolConfigInput{Group: "Audio", Verbose: false} + _, result, err := server.handleGetToolConfig(context.Background(), nil, input) + + assert.NoError(t, err) + resultMap := result.(map[string]interface{}) + groups := resultMap["groups"].([]ToolGroupInfo) + + assert.Len(t, groups, 1) + assert.Nil(t, groups[0].Tools, "should not include tool names") + }) + + t.Run("includes meta tools in count", func(t *testing.T) { + server, _, _ := testServerForToolConfig(t) + + input := GetToolConfigInput{} + _, result, err := server.handleGetToolConfig(context.Background(), nil, input) + + assert.NoError(t, err) + resultMap := result.(map[string]interface{}) + + metaTools := resultMap["meta_tools"].([]string) + assert.Len(t, metaTools, 4, "should have 4 meta tools") + assert.Contains(t, metaTools, "help") + assert.Contains(t, metaTools, "get_tool_config") + assert.Contains(t, metaTools, "set_tool_config") + assert.Contains(t, metaTools, "list_tool_groups") + }) +} + +// Test set_tool_config handler + +func TestHandleSetToolConfig(t *testing.T) { + t.Run("disables tool group", func(t *testing.T) { + server, _, _ := testServerForToolConfig(t) + + // Verify Audio is enabled initially + assert.True(t, server.toolGroups.Audio) + + input := SetToolConfigInput{Group: "Audio", Enabled: false} + _, result, err := server.handleSetToolConfig(context.Background(), nil, input) + + assert.NoError(t, err) + assert.NotNil(t, result) + + resultMap := result.(map[string]interface{}) + assert.Equal(t, "Audio", resultMap["group"]) + assert.Equal(t, true, resultMap["previous_state"]) + assert.Equal(t, false, resultMap["new_state"]) + assert.Equal(t, 4, resultMap["tools_affected"]) + + // Verify group is now disabled + assert.False(t, server.toolGroups.Audio) + }) + + t.Run("enables tool group", func(t *testing.T) { + server, _, _ := testServerForToolConfig(t) + + // First disable the group + server.toolGroups.Visual = false + + input := SetToolConfigInput{Group: "Visual", Enabled: true} + _, result, err := server.handleSetToolConfig(context.Background(), nil, input) + + assert.NoError(t, err) + resultMap := result.(map[string]interface{}) + + assert.Equal(t, false, resultMap["previous_state"]) + assert.Equal(t, true, resultMap["new_state"]) + + // Verify group is now enabled + assert.True(t, server.toolGroups.Visual) + }) + + t.Run("rejects invalid group name", func(t *testing.T) { + server, _, _ := testServerForToolConfig(t) + + input := SetToolConfigInput{Group: "InvalidGroup", Enabled: false} + _, _, err := server.handleSetToolConfig(context.Background(), nil, input) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid group name") + assert.Contains(t, err.Error(), "InvalidGroup") + }) + + t.Run("persists to database when requested", func(t *testing.T) { + server, _, db := testServerForToolConfig(t) + + input := SetToolConfigInput{Group: "Layout", Enabled: false, Persist: true} + _, result, err := server.handleSetToolConfig(context.Background(), nil, input) + + assert.NoError(t, err) + resultMap := result.(map[string]interface{}) + assert.Equal(t, true, resultMap["persisted"]) + + // Verify config was persisted + config, err := db.LoadToolGroupConfig(context.Background()) + require.NoError(t, err) + assert.False(t, config.Layout, "Layout should be persisted as disabled") + }) + + t.Run("does not persist by default", func(t *testing.T) { + server, _, db := testServerForToolConfig(t) + + // Set initial state in DB + err := db.SaveToolGroupConfig(context.Background(), storage.ToolGroupConfig{ + Core: true, Visual: true, Layout: true, Audio: true, + Sources: true, Design: true, Filters: true, Transitions: true, + }) + require.NoError(t, err) + + input := SetToolConfigInput{Group: "Layout", Enabled: false, Persist: false} + _, result, err := server.handleSetToolConfig(context.Background(), nil, input) + + assert.NoError(t, err) + resultMap := result.(map[string]interface{}) + assert.Equal(t, false, resultMap["persisted"]) + + // Verify config was NOT changed in DB + config, err := db.LoadToolGroupConfig(context.Background()) + require.NoError(t, err) + assert.True(t, config.Layout, "Layout should still be enabled in DB") + }) + + t.Run("handles all tool groups", func(t *testing.T) { + server, _, _ := testServerForToolConfig(t) + + for _, group := range ToolGroupOrder { + input := SetToolConfigInput{Group: group, Enabled: false} + _, _, err := server.handleSetToolConfig(context.Background(), nil, input) + assert.NoError(t, err, "should handle group %s", group) + } + + // Verify all groups are disabled + assert.False(t, server.toolGroups.Core) + assert.False(t, server.toolGroups.Sources) + assert.False(t, server.toolGroups.Audio) + assert.False(t, server.toolGroups.Layout) + assert.False(t, server.toolGroups.Visual) + assert.False(t, server.toolGroups.Design) + assert.False(t, server.toolGroups.Filters) + assert.False(t, server.toolGroups.Transitions) + }) +} + +// Test list_tool_groups handler + +func TestHandleListToolGroups(t *testing.T) { + t.Run("lists all groups by default", func(t *testing.T) { + server, _, _ := testServerForToolConfig(t) + + input := ListToolGroupsInput{} + _, result, err := server.handleListToolGroups(context.Background(), nil, input) + + assert.NoError(t, err) + assert.NotNil(t, result) + + resultMap := result.(map[string]interface{}) + groups := resultMap["groups"].([]ToolGroupInfo) + + assert.Len(t, groups, 8, "should list all 8 groups") + assert.Equal(t, 8, resultMap["count"]) + + // Verify correct order + expectedOrder := []string{"Core", "Sources", "Audio", "Layout", "Visual", "Design", "Filters", "Transitions"} + for i, expectedName := range expectedOrder { + assert.Equal(t, expectedName, groups[i].Name, "group %d should be %s", i, expectedName) + } + }) + + t.Run("includes disabled groups by default", func(t *testing.T) { + server, _, _ := testServerForToolConfig(t) + + // Disable some groups + server.toolGroups.Audio = false + server.toolGroups.Visual = false + + input := ListToolGroupsInput{IncludeDisabled: true} + _, result, err := server.handleListToolGroups(context.Background(), nil, input) + + assert.NoError(t, err) + resultMap := result.(map[string]interface{}) + groups := resultMap["groups"].([]ToolGroupInfo) + + assert.Len(t, groups, 8, "should include disabled groups") + + // Verify Audio and Visual show as disabled + var audioFound, visualFound bool + for _, g := range groups { + if g.Name == "Audio" { + audioFound = true + assert.False(t, g.Enabled) + } + if g.Name == "Visual" { + visualFound = true + assert.False(t, g.Enabled) + } + } + assert.True(t, audioFound, "Audio group should be listed") + assert.True(t, visualFound, "Visual group should be listed") + }) + + t.Run("excludes disabled groups when requested", func(t *testing.T) { + server, _, _ := testServerForToolConfig(t) + + // Disable some groups + server.toolGroups.Audio = false + server.toolGroups.Visual = false + + input := ListToolGroupsInput{IncludeDisabled: false} + _, result, err := server.handleListToolGroups(context.Background(), nil, input) + + assert.NoError(t, err) + resultMap := result.(map[string]interface{}) + groups := resultMap["groups"].([]ToolGroupInfo) + + assert.Len(t, groups, 6, "should exclude 2 disabled groups") + + // Verify Audio and Visual are not in the list + for _, g := range groups { + assert.NotEqual(t, "Audio", g.Name, "Audio should not be in list") + assert.NotEqual(t, "Visual", g.Name, "Visual should not be in list") + } + }) + + t.Run("includes meta tools in response", func(t *testing.T) { + server, _, _ := testServerForToolConfig(t) + + input := ListToolGroupsInput{} + _, result, err := server.handleListToolGroups(context.Background(), nil, input) + + assert.NoError(t, err) + resultMap := result.(map[string]interface{}) + + metaTools := resultMap["meta_tools"].([]string) + assert.Len(t, metaTools, 4) + }) +} + +// Test getGroupEnabled and setGroupEnabled helpers + +func TestGetGroupEnabled(t *testing.T) { + server, _, _ := testServerForToolConfig(t) + + testCases := []struct { + group string + field *bool + expected bool + }{ + {"Core", &server.toolGroups.Core, true}, + {"Sources", &server.toolGroups.Sources, true}, + {"Audio", &server.toolGroups.Audio, true}, + {"Layout", &server.toolGroups.Layout, true}, + {"Visual", &server.toolGroups.Visual, true}, + {"Design", &server.toolGroups.Design, true}, + {"Filters", &server.toolGroups.Filters, true}, + {"Transitions", &server.toolGroups.Transitions, true}, + {"Unknown", nil, false}, + } + + for _, tc := range testCases { + t.Run(tc.group, func(t *testing.T) { + result := server.getGroupEnabled(tc.group) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestSetGroupEnabled(t *testing.T) { + server, _, _ := testServerForToolConfig(t) + + // Disable all groups + for _, group := range ToolGroupOrder { + server.setGroupEnabled(group, false) + } + + // Verify all disabled + assert.False(t, server.toolGroups.Core) + assert.False(t, server.toolGroups.Sources) + assert.False(t, server.toolGroups.Audio) + assert.False(t, server.toolGroups.Layout) + assert.False(t, server.toolGroups.Visual) + assert.False(t, server.toolGroups.Design) + assert.False(t, server.toolGroups.Filters) + assert.False(t, server.toolGroups.Transitions) + + // Re-enable all + for _, group := range ToolGroupOrder { + server.setGroupEnabled(group, true) + } + + // Verify all enabled + assert.True(t, server.toolGroups.Core) + assert.True(t, server.toolGroups.Sources) + assert.True(t, server.toolGroups.Audio) + assert.True(t, server.toolGroups.Layout) + assert.True(t, server.toolGroups.Visual) + assert.True(t, server.toolGroups.Design) + assert.True(t, server.toolGroups.Filters) + assert.True(t, server.toolGroups.Transitions) +} + +func TestConvertToStorageConfig(t *testing.T) { + server, _, _ := testServerForToolConfig(t) + + // Set some groups disabled + server.toolGroups.Audio = false + server.toolGroups.Filters = false + + config := server.convertToStorageConfig() + + assert.True(t, config.Core) + assert.True(t, config.Sources) + assert.False(t, config.Audio) + assert.True(t, config.Layout) + assert.True(t, config.Visual) + assert.True(t, config.Design) + assert.False(t, config.Filters) + assert.True(t, config.Transitions) +} + +// Test tool group metadata + +func TestToolGroupMetadata(t *testing.T) { + expectedGroups := map[string]struct { + toolCount int + hasTools []string + }{ + "Core": { + toolCount: 25, + hasTools: []string{"list_scenes", "start_recording", "toggle_virtual_cam", "toggle_studio_mode"}, + }, + "Sources": { + toolCount: 3, + hasTools: []string{"list_sources", "toggle_source_visibility"}, + }, + "Audio": { + toolCount: 4, + hasTools: []string{"toggle_input_mute", "set_input_volume"}, + }, + "Layout": { + toolCount: 6, + hasTools: []string{"save_scene_preset", "apply_scene_preset"}, + }, + "Visual": { + toolCount: 4, + hasTools: []string{"create_screenshot_source", "list_screenshot_sources"}, + }, + "Design": { + toolCount: 14, + hasTools: []string{"create_text_source", "set_source_transform"}, + }, + "Filters": { + toolCount: 7, + hasTools: []string{"list_source_filters", "toggle_source_filter"}, + }, + "Transitions": { + toolCount: 5, + hasTools: []string{"list_transitions", "set_current_transition"}, + }, + } + + for groupName, expected := range expectedGroups { + t.Run(groupName, func(t *testing.T) { + meta := toolGroupMetadata[groupName] + require.NotNil(t, meta, "metadata should exist for %s", groupName) + + assert.Equal(t, expected.toolCount, meta.ToolCount, "tool count mismatch") + assert.Len(t, meta.ToolNames, expected.toolCount, "tool names should match count") + + for _, expectedTool := range expected.hasTools { + assert.Contains(t, meta.ToolNames, expectedTool, "should include %s", expectedTool) + } + }) + } +} + +func TestMetaToolNames(t *testing.T) { + assert.Len(t, MetaToolNames, 4) + assert.Contains(t, MetaToolNames, "help") + assert.Contains(t, MetaToolNames, "get_tool_config") + assert.Contains(t, MetaToolNames, "set_tool_config") + assert.Contains(t, MetaToolNames, "list_tool_groups") +} + +// TestToolCountConsistency ensures ToolCount field matches len(ToolNames) for all groups. +// This catches accidental desync when adding/removing tools from a group. +func TestToolCountConsistency(t *testing.T) { + for _, groupName := range ToolGroupOrder { + t.Run(groupName, func(t *testing.T) { + meta := toolGroupMetadata[groupName] + require.NotNil(t, meta, "metadata should exist for %s", groupName) + + assert.Equal(t, meta.ToolCount, len(meta.ToolNames), + "ToolCount (%d) must match len(ToolNames) (%d) for group %s", + meta.ToolCount, len(meta.ToolNames), groupName) + }) + } +} + +// TestToolGroupOrderConsistency ensures ToolGroupOrder matches toolGroupMetadata keys. +func TestToolGroupOrderConsistency(t *testing.T) { + // Every group in ToolGroupOrder should exist in metadata + for _, groupName := range ToolGroupOrder { + _, exists := toolGroupMetadata[groupName] + assert.True(t, exists, "group %s in ToolGroupOrder missing from toolGroupMetadata", groupName) + } + + // Every group in metadata should be in ToolGroupOrder + for groupName := range toolGroupMetadata { + found := false + for _, orderedName := range ToolGroupOrder { + if orderedName == groupName { + found = true + break + } + } + assert.True(t, found, "group %s in toolGroupMetadata missing from ToolGroupOrder", groupName) + } + + // Counts should match + assert.Equal(t, len(ToolGroupOrder), len(toolGroupMetadata), + "ToolGroupOrder and toolGroupMetadata should have same number of entries") +} + +// TestTotalToolCountMatchesDocumentation validates that tool counts in metadata +// sum to the documented total (72 tools = 68 group tools + 4 meta-tools). +// This catches drift between code and documentation. +func TestTotalToolCountMatchesDocumentation(t *testing.T) { + // Sum all tool counts from metadata + var groupToolCount int + for _, meta := range toolGroupMetadata { + groupToolCount += meta.ToolCount + } + + // Add meta-tools + totalTools := groupToolCount + len(MetaToolNames) + + // Expected total from documentation (CLAUDE.md, README.md, verify-docs.sh) + const expectedTotal = 72 + + assert.Equal(t, expectedTotal, totalTools, + "Total tool count (%d group tools + %d meta-tools = %d) should match documented %d", + groupToolCount, len(MetaToolNames), totalTools, expectedTotal) +} + +// TestToolNamesAreUnique ensures no duplicate tool names exist across groups. +func TestToolNamesAreUnique(t *testing.T) { + seen := make(map[string]string) // tool name -> group name + + for groupName, meta := range toolGroupMetadata { + for _, toolName := range meta.ToolNames { + if existingGroup, exists := seen[toolName]; exists { + t.Errorf("Tool '%s' appears in both '%s' and '%s' groups", + toolName, existingGroup, groupName) + } + seen[toolName] = groupName + } + } + + // Also check meta-tools don't conflict with group tools + for _, metaTool := range MetaToolNames { + if existingGroup, exists := seen[metaTool]; exists { + t.Errorf("Meta-tool '%s' conflicts with tool in group '%s'", + metaTool, existingGroup) + } + } +} + +// Integration test: verify config persists across server restarts +func TestToolConfigPersistsAcrossRestarts(t *testing.T) { + // Create shared database path + dbPath := filepath.Join(t.TempDir(), "persist-test.db") + + // Phase 1: Create server, modify config, persist it + t.Run("persist config", func(t *testing.T) { + mock := testutil.NewMockOBSClient() + mock.Connect() + + db, err := storage.New(context.Background(), storage.Config{Path: dbPath}) + require.NoError(t, err) + + server := &Server{ + obsClient: mock, + storage: db, + toolGroups: DefaultToolGroupConfig(), + ctx: context.Background(), + } + + // Disable Audio group and persist + input := SetToolConfigInput{Group: "Audio", Enabled: false, Persist: true} + _, result, err := server.handleSetToolConfig(context.Background(), nil, input) + require.NoError(t, err) + + resultMap := result.(map[string]interface{}) + assert.True(t, resultMap["persisted"].(bool), "should have persisted") + assert.False(t, server.toolGroups.Audio, "Audio should be disabled in memory") + + // Close the database (simulating server shutdown) + db.Close() + }) + + // Phase 2: Create new server instance, verify config was loaded from storage + t.Run("verify config loaded on restart", func(t *testing.T) { + mock := testutil.NewMockOBSClient() + mock.Connect() + + // Open same database + db, err := storage.New(context.Background(), storage.Config{Path: dbPath}) + require.NoError(t, err) + defer db.Close() + + // Load config from storage (simulating what happens on server startup) + loadedConfig, err := db.LoadToolGroupConfig(context.Background()) + require.NoError(t, err) + + // Verify Audio was persisted as disabled + assert.False(t, loadedConfig.Audio, "Audio should still be disabled after restart") + assert.True(t, loadedConfig.Core, "Core should still be enabled") + assert.True(t, loadedConfig.Visual, "Visual should still be enabled") + assert.True(t, loadedConfig.Filters, "Filters should still be enabled") + assert.True(t, loadedConfig.Transitions, "Transitions should still be enabled") + + // Create server with loaded config + server := &Server{ + obsClient: mock, + storage: db, + toolGroups: ToolGroupConfig{ + Core: loadedConfig.Core, + Visual: loadedConfig.Visual, + Layout: loadedConfig.Layout, + Audio: loadedConfig.Audio, + Sources: loadedConfig.Sources, + Design: loadedConfig.Design, + Filters: loadedConfig.Filters, + Transitions: loadedConfig.Transitions, + }, + ctx: context.Background(), + } + + // Verify server has correct config + assert.False(t, server.toolGroups.Audio, "Server should have Audio disabled") + + // Query config through handler + getInput := GetToolConfigInput{Group: "Audio"} + _, getResult, err := server.handleGetToolConfig(context.Background(), nil, getInput) + require.NoError(t, err) + + getResultMap := getResult.(map[string]interface{}) + groups := getResultMap["groups"].([]ToolGroupInfo) + require.Len(t, groups, 1) + assert.False(t, groups[0].Enabled, "Audio group should show as disabled") + }) +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 7415c5f..e05142a 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -883,7 +883,9 @@ func (s *Server) registerToolHandlers() { log.Println("Transition tools registered (5 tools)") } - // Help tool - always enabled (not part of any tool group) + // Meta tools - always enabled, cannot be disabled + // These provide help and runtime tool configuration + mcpsdk.AddTool(s.mcpServer, &mcpsdk.Tool{ Name: "help", @@ -891,8 +893,33 @@ func (s *Server) registerToolHandlers() { }, s.handleHelp, ) - toolCount++ - log.Println("Help tool registered") + + mcpsdk.AddTool(s.mcpServer, + &mcpsdk.Tool{ + Name: "get_tool_config", + Description: "Get current tool group configuration showing which tool groups are enabled/disabled. Use group parameter to filter by specific group, verbose=true to include tool names.", + }, + s.handleGetToolConfig, + ) + + mcpsdk.AddTool(s.mcpServer, + &mcpsdk.Tool{ + Name: "set_tool_config", + Description: "Enable or disable a tool group at runtime. Changes are session-only by default; use persist=true to save to database for future sessions.", + }, + s.handleSetToolConfig, + ) + + mcpsdk.AddTool(s.mcpServer, + &mcpsdk.Tool{ + Name: "list_tool_groups", + Description: "List all available tool groups with their descriptions and enabled status. Use include_disabled=false to only show enabled groups.", + }, + s.handleListToolGroups, + ) + + toolCount += 4 // 4 meta-tools (help + 3 config tools) + log.Println("Meta tools registered (help, get_tool_config, set_tool_config, list_tool_groups)") log.Printf("Tool handlers registered successfully (%d tools total)", toolCount) } diff --git a/internal/storage/state.go b/internal/storage/state.go index daf9975..e3889da 100644 --- a/internal/storage/state.go +++ b/internal/storage/state.go @@ -24,12 +24,14 @@ const ( // Tool group state keys - control which tool categories are enabled const ( - StateKeyToolsCore = "tools_enabled_core" // Core OBS tools (scenes, recording, streaming, status) - StateKeyToolsVisual = "tools_enabled_visual" // Visual monitoring tools (screenshots) - StateKeyToolsLayout = "tools_enabled_layout" // Layout management tools (scene presets) - StateKeyToolsAudio = "tools_enabled_audio" // Audio control tools - StateKeyToolsSources = "tools_enabled_sources" // Source management tools - StateKeyToolsDesign = "tools_enabled_design" // Scene design tools (source creation, transforms) + StateKeyToolsCore = "tools_enabled_core" // Core OBS tools (scenes, recording, streaming, status, virtual cam, replay, studio mode, hotkeys) + StateKeyToolsVisual = "tools_enabled_visual" // Visual monitoring tools (screenshots) + StateKeyToolsLayout = "tools_enabled_layout" // Layout management tools (scene presets) + StateKeyToolsAudio = "tools_enabled_audio" // Audio control tools + StateKeyToolsSources = "tools_enabled_sources" // Source management tools + StateKeyToolsDesign = "tools_enabled_design" // Scene design tools (source creation, transforms) + StateKeyToolsFilters = "tools_enabled_filters" // Filter management tools + StateKeyToolsTransitions = "tools_enabled_transitions" // Transition control tools ) // Webserver configuration keys @@ -310,23 +312,27 @@ func (db *DB) GetAppVersion(ctx context.Context) (string, error) { // ToolGroupConfig represents the enabled/disabled state of each tool group. type ToolGroupConfig struct { - Core bool // Core OBS tools (scenes, recording, streaming, status) - Visual bool // Visual monitoring tools (screenshots) - Layout bool // Layout management tools (scene presets) - Audio bool // Audio control tools - Sources bool // Source management tools - Design bool // Scene design tools (source creation, transforms) + Core bool // Core OBS tools (scenes, recording, streaming, status, virtual cam, replay, studio mode, hotkeys) + Visual bool // Visual monitoring tools (screenshots) + Layout bool // Layout management tools (scene presets) + Audio bool // Audio control tools + Sources bool // Source management tools + Design bool // Scene design tools (source creation, transforms) + Filters bool // Filter management tools + Transitions bool // Transition control tools } // DefaultToolGroupConfig returns tool group config with all groups enabled. func DefaultToolGroupConfig() ToolGroupConfig { return ToolGroupConfig{ - Core: true, - Visual: true, - Layout: true, - Audio: true, - Sources: true, - Design: true, + Core: true, + Visual: true, + Layout: true, + Audio: true, + Sources: true, + Design: true, + Filters: true, + Transitions: true, } } @@ -357,6 +363,12 @@ func (db *DB) SaveToolGroupConfig(ctx context.Context, cfg ToolGroupConfig) erro if err := db.SetState(ctx, StateKeyToolsDesign, boolToStr(cfg.Design)); err != nil { return fmt.Errorf("failed to save design tools preference: %w", err) } + if err := db.SetState(ctx, StateKeyToolsFilters, boolToStr(cfg.Filters)); err != nil { + return fmt.Errorf("failed to save filters tools preference: %w", err) + } + if err := db.SetState(ctx, StateKeyToolsTransitions, boolToStr(cfg.Transitions)); err != nil { + return fmt.Errorf("failed to save transitions tools preference: %w", err) + } return nil } @@ -389,6 +401,12 @@ func (db *DB) LoadToolGroupConfig(ctx context.Context) (ToolGroupConfig, error) if val, err := db.GetState(ctx, StateKeyToolsDesign); err == nil { cfg.Design = strToBool(val) } + if val, err := db.GetState(ctx, StateKeyToolsFilters); err == nil { + cfg.Filters = strToBool(val) + } + if val, err := db.GetState(ctx, StateKeyToolsTransitions); err == nil { + cfg.Transitions = strToBool(val) + } return cfg, nil } diff --git a/internal/storage/state_test.go b/internal/storage/state_test.go index 6018179..d9ddc91 100644 --- a/internal/storage/state_test.go +++ b/internal/storage/state_test.go @@ -402,3 +402,175 @@ func TestStateConstants(t *testing.T) { assert.Equal(t, "obs_password", ConfigKeyOBSPassword) }) } + +// Tool Group Config tests + +func TestDefaultToolGroupConfig(t *testing.T) { + t.Run("all groups enabled by default", func(t *testing.T) { + cfg := DefaultToolGroupConfig() + + assert.True(t, cfg.Core) + assert.True(t, cfg.Visual) + assert.True(t, cfg.Layout) + assert.True(t, cfg.Audio) + assert.True(t, cfg.Sources) + assert.True(t, cfg.Design) + assert.True(t, cfg.Filters) + assert.True(t, cfg.Transitions) + }) +} + +func TestSaveToolGroupConfig(t *testing.T) { + t.Run("saves config successfully", func(t *testing.T) { + db, cleanup := testDB(t) + defer cleanup() + + cfg := ToolGroupConfig{ + Core: true, + Visual: false, + Layout: true, + Audio: false, + Sources: true, + Design: false, + Filters: true, + Transitions: false, + } + + err := db.SaveToolGroupConfig(context.Background(), cfg) + assert.NoError(t, err) + }) + + t.Run("updates existing config", func(t *testing.T) { + db, cleanup := testDB(t) + defer cleanup() + + // Save initial config + err := db.SaveToolGroupConfig(context.Background(), ToolGroupConfig{ + Core: true, + Visual: true, + Layout: true, + Audio: true, + Sources: true, + Design: true, + Filters: true, + Transitions: true, + }) + require.NoError(t, err) + + // Update config + err = db.SaveToolGroupConfig(context.Background(), ToolGroupConfig{ + Core: false, + Visual: false, + Layout: false, + Audio: false, + Sources: false, + Design: false, + Filters: false, + Transitions: false, + }) + require.NoError(t, err) + + // Verify update + loaded, err := db.LoadToolGroupConfig(context.Background()) + assert.NoError(t, err) + assert.False(t, loaded.Core) + assert.False(t, loaded.Visual) + assert.False(t, loaded.Layout) + assert.False(t, loaded.Audio) + assert.False(t, loaded.Sources) + assert.False(t, loaded.Design) + assert.False(t, loaded.Filters) + assert.False(t, loaded.Transitions) + }) + + t.Run("saves partial config", func(t *testing.T) { + db, cleanup := testDB(t) + defer cleanup() + + cfg := ToolGroupConfig{ + Core: true, + Visual: false, + Layout: true, + Audio: false, + Sources: true, + Design: false, + Filters: true, + Transitions: false, + } + + err := db.SaveToolGroupConfig(context.Background(), cfg) + require.NoError(t, err) + + loaded, err := db.LoadToolGroupConfig(context.Background()) + assert.NoError(t, err) + assert.Equal(t, cfg.Core, loaded.Core) + assert.Equal(t, cfg.Visual, loaded.Visual) + assert.Equal(t, cfg.Layout, loaded.Layout) + assert.Equal(t, cfg.Audio, loaded.Audio) + assert.Equal(t, cfg.Sources, loaded.Sources) + assert.Equal(t, cfg.Design, loaded.Design) + assert.Equal(t, cfg.Filters, loaded.Filters) + assert.Equal(t, cfg.Transitions, loaded.Transitions) + }) +} + +func TestLoadToolGroupConfig(t *testing.T) { + t.Run("loads saved config", func(t *testing.T) { + db, cleanup := testDB(t) + defer cleanup() + + expected := ToolGroupConfig{ + Core: true, + Visual: false, + Layout: true, + Audio: false, + Sources: true, + Design: false, + Filters: true, + Transitions: false, + } + + err := db.SaveToolGroupConfig(context.Background(), expected) + require.NoError(t, err) + + loaded, err := db.LoadToolGroupConfig(context.Background()) + assert.NoError(t, err) + assert.Equal(t, expected, loaded) + }) + + t.Run("returns defaults when config not saved", func(t *testing.T) { + db, cleanup := testDB(t) + defer cleanup() + + // Don't save any config - should return defaults + loaded, err := db.LoadToolGroupConfig(context.Background()) + assert.NoError(t, err) + + // Should return default config (all enabled) + expected := DefaultToolGroupConfig() + assert.Equal(t, expected, loaded) + }) + + t.Run("handles partial config in database", func(t *testing.T) { + db, cleanup := testDB(t) + defer cleanup() + + // Manually set only some keys (simulating incomplete save) + err := db.SetState(context.Background(), StateKeyToolsCore, "true") + require.NoError(t, err) + err = db.SetState(context.Background(), StateKeyToolsVisual, "false") + require.NoError(t, err) + + // Load should still work, defaulting missing keys to true + loaded, err := db.LoadToolGroupConfig(context.Background()) + assert.NoError(t, err) + assert.True(t, loaded.Core) + assert.False(t, loaded.Visual) + assert.True(t, loaded.Layout) // defaulted to true + assert.True(t, loaded.Audio) // defaulted to true + assert.True(t, loaded.Sources) // defaulted to true + assert.True(t, loaded.Design) // defaulted to true + assert.True(t, loaded.Filters) // defaulted to true + assert.True(t, loaded.Transitions) // defaulted to true + }) +} diff --git a/main.go b/main.go index a80462b..d3ea1c6 100644 --- a/main.go +++ b/main.go @@ -90,12 +90,14 @@ func main() { HTTPPort: cfg.WebServer.Port, ThumbnailCacheSec: cfg.WebServer.ThumbnailCacheSec, ToolGroups: mcp.ToolGroupConfig{ - Core: cfg.ToolGroups.Core, - Visual: cfg.ToolGroups.Visual, - Layout: cfg.ToolGroups.Layout, - Audio: cfg.ToolGroups.Audio, - Sources: cfg.ToolGroups.Sources, - Design: cfg.ToolGroups.Design, + Core: cfg.ToolGroups.Core, + Visual: cfg.ToolGroups.Visual, + Layout: cfg.ToolGroups.Layout, + Audio: cfg.ToolGroups.Audio, + Sources: cfg.ToolGroups.Sources, + Design: cfg.ToolGroups.Design, + Filters: cfg.ToolGroups.Filters, + Transitions: cfg.ToolGroups.Transitions, }, } diff --git a/scripts/verify-docs.sh b/scripts/verify-docs.sh index 24b15fb..806a018 100644 --- a/scripts/verify-docs.sh +++ b/scripts/verify-docs.sh @@ -14,11 +14,11 @@ YELLOW='\033[1;33m' NC='\033[0m' # No Color # Current expected values - UPDATE THESE AFTER EACH PHASE -EXPECTED_TOOLS=69 +EXPECTED_TOOLS=72 EXPECTED_RESOURCES=4 EXPECTED_PROMPTS=13 EXPECTED_API_ENDPOINTS=8 -CURRENT_PHASE=11 +CURRENT_PHASE=12 echo "==========================================" echo "Documentation Consistency Verification" diff --git a/skills/README.md b/skills/README.md index 0b0638c..9005b20 100644 --- a/skills/README.md +++ b/skills/README.md @@ -58,7 +58,7 @@ Claude should recognize and describe the agentic-obs skills. ### 1. Streaming Assistant (`streaming-assistant`) -**When to use**: Pre-stream setup, live streaming management, source orchestration +**When to use**: Pre-stream setup, live streaming management, source orchestration, virtual camera, replay highlights **Key capabilities**: - Pre-stream checklist execution (audio, video, scene verification) @@ -66,10 +66,14 @@ Claude should recognize and describe the agentic-obs skills. - Audio level monitoring and adjustment - Scene preset application for quick transitions - Stream health monitoring and diagnostics +- Virtual camera control for video calls +- Replay buffer management for highlight capture +- Studio mode preview/program transitions +- Hotkey automation for quick actions -**Tools used**: `get_obs_status`, `list_scenes`, `list_sources`, `toggle_source_visibility`, `get_input_mute`, `get_input_volume`, `list_scene_presets`, `apply_scene_preset`, `start_streaming`, `stop_streaming`, `get_streaming_status` +**Tools used**: `get_obs_status`, `list_scenes`, `list_sources`, `toggle_source_visibility`, `get_input_mute`, `get_input_volume`, `list_scene_presets`, `apply_scene_preset`, `start_streaming`, `stop_streaming`, `get_streaming_status`, `get_virtual_cam_status`, `toggle_virtual_cam`, `get_replay_buffer_status`, `toggle_replay_buffer`, `save_replay_buffer`, `get_last_replay`, `get_studio_mode_enabled`, `toggle_studio_mode`, `get_preview_scene`, `set_preview_scene`, `list_hotkeys`, `trigger_hotkey_by_name` -**Best for**: Users who need AI assistance during live streaming sessions, including pre-stream setup, real-time adjustments, and post-stream cleanup. +**Best for**: Users who need AI assistance during live streaming sessions, including pre-stream setup, real-time adjustments, highlight capture, virtual camera for video calls, and post-stream cleanup. --- @@ -141,6 +145,9 @@ Claude will automatically select the appropriate skill based on your request. Ho | "Position this image in the bottom right" | `scene-designer` | | "Check all my audio levels" | `audio-engineer` | | "Switch to my gaming preset" | `preset-manager` | +| "Start the virtual camera for my video call" | `streaming-assistant` | +| "Save that moment as a highlight" | `streaming-assistant` | +| "Preview the next scene before switching" | `streaming-assistant` | ## Using Skills Effectively @@ -262,6 +269,6 @@ For issues with skills or agentic-obs: --- -**Last Updated**: 2025-12-19 -**agentic-obs Version**: 0.8.0 -**Skills Version**: 1.0.0 +**Last Updated**: 2025-12-21 +**agentic-obs Version**: 0.12.0 +**Skills Version**: 1.1.0 diff --git a/skills/streaming-assistant/SKILL.md b/skills/streaming-assistant/SKILL.md index a2b143a..a3841dd 100644 --- a/skills/streaming-assistant/SKILL.md +++ b/skills/streaming-assistant/SKILL.md @@ -42,6 +42,21 @@ Activate the **streaming-assistant** skill when users request help with: - "End my stream" - "Stop streaming and clean up" +- **Virtual camera for video calls** + - "Start the virtual camera" + - "Share my OBS output in Zoom" + - "Use OBS as my webcam" + +- **Highlight capture and replay buffer** + - "I want to capture highlights" + - "Save that clip!" + - "Start the replay buffer" + +- **Studio mode preview/program workflow** + - "Enable studio mode" + - "Preview the gaming scene" + - "Transition to preview" + ## Core Responsibilities As the **streaming-assistant**, your role is to: @@ -52,7 +67,10 @@ As the **streaming-assistant**, your role is to: 4. **Manage source visibility** and scene transitions during live streams 5. **Apply presets** for quick configuration changes 6. **Handle stream lifecycle** (start, monitor, stop) -7. **Troubleshoot issues** during active streaming sessions +7. **Manage virtual camera** for video conferencing integration +8. **Capture highlights** using replay buffer +9. **Control studio mode** for professional preview/program workflow +10. **Troubleshoot issues** during active streaming sessions ## Available Tools @@ -83,6 +101,26 @@ As the **streaming-assistant**, your role is to: - `get_input_volume` - Retrieve current volume level (dB) - `set_input_volume` - Adjust volume level +### Virtual Camera +- `get_virtual_cam_status` - Check if virtual camera is active +- `toggle_virtual_cam` - Start/stop virtual camera output + +### Replay Buffer +- `get_replay_buffer_status` - Check if replay buffer is running +- `toggle_replay_buffer` - Start/stop replay buffer +- `save_replay_buffer` - Capture last N seconds as clip +- `get_last_replay` - Get path to most recently saved replay + +### Studio Mode +- `get_studio_mode_enabled` - Check if studio mode is active +- `toggle_studio_mode` - Enable/disable studio mode +- `get_preview_scene` - Get current preview scene +- `set_preview_scene` - Set preview scene before transitioning + +### Hotkeys +- `list_hotkeys` - List available OBS hotkey names +- `trigger_hotkey_by_name` - Trigger any OBS hotkey + ## Pre-Stream Workflow When users request pre-stream setup, follow this systematic checklist: @@ -202,6 +240,71 @@ User: "Is my stream okay?" 5. Provide brief health summary ``` +### Virtual Camera Management +``` +User: "Start the virtual camera for my Discord call" +1. Use get_virtual_cam_status to check current state +2. If not active, use toggle_virtual_cam to start +3. Confirm: "Virtual camera is now active" +4. Inform: "You can select 'OBS Virtual Camera' in Discord/Zoom/Teams" + +User: "Stop the virtual camera" +1. Use toggle_virtual_cam to stop +2. Confirm: "Virtual camera stopped" +``` + +### Highlight Capture with Replay Buffer +``` +User: "I want to capture highlights" +1. Use get_replay_buffer_status to check if running +2. If not active, use toggle_replay_buffer to start +3. Confirm: "Replay buffer is now active" +4. Inform: "Say 'save that' or 'clip it' to capture last N seconds" + +User: "That was epic! Clip it!" +1. Use save_replay_buffer to capture +2. Use get_last_replay to get file path +3. Report: "Clip saved to [path]" + +User: "Show me the last clip" +1. Use get_last_replay +2. Report file path for user to review +``` + +### Studio Mode Transitions +``` +User: "Enable studio mode" +1. Use toggle_studio_mode with enabled=true +2. Confirm: "Studio mode enabled" +3. Explain: "Use preview/program for smoother transitions" + +User: "Preview my gaming scene" +1. Use set_preview_scene with "Gaming" +2. Confirm: "Gaming scene is now in preview" +3. Remind: "Use 'transition' when ready to go live with it" + +User: "Transition to the preview" +1. Use trigger_transition (or relevant hotkey) +2. Confirm: "Transitioned to Gaming scene" + +User: "Turn off studio mode" +1. Use toggle_studio_mode with enabled=false +2. Confirm: "Studio mode disabled" +``` + +### Hotkey Automation +``` +User: "Show me available hotkeys" +1. Use list_hotkeys +2. Report key hotkeys organized by category +3. Explain common uses (StartRecording, StopRecording, etc.) + +User: "Trigger the screenshot hotkey" +1. Use list_hotkeys to find screenshot hotkey name +2. Use trigger_hotkey_by_name with "OBSBasic.Screenshot" +3. Confirm: "Screenshot captured" +``` + ## Post-Stream Workflow When users request stream teardown: @@ -227,6 +330,9 @@ Suggest: - Reviewing stream recording if enabled - Switching to a neutral scene - Muting audio inputs if finished +- Stopping replay buffer if running (toggle_replay_buffer) +- Stopping virtual camera if active (toggle_virtual_cam) +- Disabling studio mode if enabled (toggle_studio_mode) - Applying a "Stream Ended" preset if available ``` @@ -485,6 +591,10 @@ The **streaming-assistant** skill is your go-to for complete live streaming work - Audio monitoring and adjustment - Stream health diagnostics - Preset-based configuration management +- Virtual camera for video conferencing integration +- Replay buffer for highlight capture +- Studio mode for professional preview/program workflow +- Hotkey automation for quick actions - Post-stream teardown Always prioritize user experience during active streams: be concise, confirm actions immediately, and proactively suggest next steps. Make streaming effortless.