From 897d5b78f2ff328ee4d868689ad89295406826e9 Mon Sep 17 00:00:00 2001 From: Motoki KAMIMURA <19676305+usabarashi@users.noreply.github.com> Date: Sun, 31 Aug 2025 23:12:09 +0900 Subject: [PATCH 01/14] XDG instructions support (#31) * Add setup instructions to release workflow for voicevox-cli * Enhance instruction loading with XDG Base Directory support * Refactor instruction loading methods for clarity and consistency --- CLAUDE.md | 26 ++++++++++--- docs/mcp-usage.md | 60 ++++++++++++++++++++++++++--- src/mcp/server.rs | 96 +++++++++++++++++++++++++++++++++-------------- 3 files changed, 143 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8614cfe..b1fe321 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,16 +57,32 @@ voicevox-mcp-server # MCP protocol server for AI assistant integ - `list_voice_styles`: Query available voice styles with optional filtering by speaker or style name ### Instruction System -The MCP server dynamically loads behavior instructions to guide AI assistant interactions: +The MCP server dynamically loads behavior instructions to guide AI assistant interactions. -1. **Environment variable**: `VOICEVOX_MCP_INSTRUCTIONS` pointing to custom file -2. **Executable directory**: `INSTRUCTIONS.md` bundled with binary -3. **Current directory**: `INSTRUCTIONS.md` for development +**Loading Priority (XDG Base Directory compliant):** + +1. **Environment variable**: `VOICEVOX_MCP_INSTRUCTIONS` (highest priority) +2. **XDG user config**: `$XDG_CONFIG_HOME/voicevox/INSTRUCTIONS.md` (user-specific settings) +3. **Config fallback**: `~/.config/voicevox/INSTRUCTIONS.md` (when XDG_CONFIG_HOME is not set) +4. **Executable directory**: `INSTRUCTIONS.md` bundled with the binary (distribution default) +5. **Current directory**: `INSTRUCTIONS.md` in working directory (development use) + +**Configuration examples:** -**Configuration example:** ```bash +# Method 1: Environment variable (highest priority) export VOICEVOX_MCP_INSTRUCTIONS=/path/to/custom/instructions.md voicevox-mcp-server + +# Method 2: XDG_CONFIG_HOME (if set) +mkdir -p $XDG_CONFIG_HOME/voicevox +cp custom-instructions.md $XDG_CONFIG_HOME/voicevox/INSTRUCTIONS.md +voicevox-mcp-server + +# Method 3: XDG user configuration +mkdir -p ~/.config/voicevox +cp custom-instructions.md ~/.config/voicevox/INSTRUCTIONS.md +voicevox-mcp-server ``` Server operates normally without instruction files. Default behavior defined in [INSTRUCTIONS.md](INSTRUCTIONS.md). \ No newline at end of file diff --git a/docs/mcp-usage.md b/docs/mcp-usage.md index 8c103de..3b60f07 100644 --- a/docs/mcp-usage.md +++ b/docs/mcp-usage.md @@ -51,20 +51,40 @@ The MCP server automatically loads behavioral instructions for AI assistants fro ### Default Instructions -By default, the server loads instructions from: -1. File specified by `VOICEVOX_MCP_INSTRUCTIONS` environment variable -2. `INSTRUCTIONS.md` in the executable directory -3. `INSTRUCTIONS.md` in the current working directory +The server loads instructions using XDG Base Directory specification with the following priority order: + +1. **Environment variable**: File specified by `VOICEVOX_MCP_INSTRUCTIONS` (highest priority) +2. **XDG user config**: `$XDG_CONFIG_HOME/voicevox/INSTRUCTIONS.md` (user-specific settings) +3. **Config fallback**: `~/.config/voicevox/INSTRUCTIONS.md` (when XDG_CONFIG_HOME is not set) +4. **Executable directory**: `INSTRUCTIONS.md` bundled with the binary (distribution default) +5. **Current directory**: `INSTRUCTIONS.md` in working directory (development use) ### Custom Instructions -To use custom instructions for specific workflows: +You can customize the AI assistant behavior using several methods: +#### Method 1: Environment Variable (Highest Priority) ```bash export VOICEVOX_MCP_INSTRUCTIONS=/path/to/custom/instructions.md voicevox-mcp-server ``` +#### Method 2: XDG_CONFIG_HOME (If Set) +```bash +# When XDG_CONFIG_HOME is configured (higher priority) +mkdir -p $XDG_CONFIG_HOME/voicevox +cp custom-instructions.md $XDG_CONFIG_HOME/voicevox/INSTRUCTIONS.md +voicevox-mcp-server +``` + +#### Method 3: Config Fallback (Recommended for most users) +```bash +# Create user-specific configuration (XDG default location) +mkdir -p ~/.config/voicevox +cp custom-instructions.md ~/.config/voicevox/INSTRUCTIONS.md +voicevox-mcp-server +``` + Example custom instructions structure: ```markdown # Custom VOICEVOX Instructions @@ -79,6 +99,36 @@ Example custom instructions structure: - ID: 76 - Error situations ``` +### XDG Base Directory Support + +The VOICEVOX MCP server follows the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html), providing clean separation between: + +- **User configurations**: Personal settings that persist across updates +- **Distribution defaults**: Settings bundled with the application +- **Development settings**: Project-specific configurations for development + +#### Benefits + +1. **User-specific customization**: Settings in `~/.config/voicevox/` persist across application updates +2. **Multi-environment support**: Different configurations for different projects using XDG_CONFIG_HOME +3. **Clean separation**: User settings don't interfere with distribution defaults +4. **Standard compliance**: Follows Unix/Linux configuration management conventions + +#### Debugging Configuration Loading + +The MCP server logs which configuration file it loads: + +```bash +# Enable debug output to see configuration loading +voicevox-mcp-server 2>&1 | grep "instructions" +``` + +Example output: +``` +Trying instructions from XDG_CONFIG_HOME: /home/user/.config/voicevox/INSTRUCTIONS.md +Loaded instructions from: /home/user/.config/voicevox/INSTRUCTIONS.md +``` + ## Available Tools ### 1. `text_to_speech` diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 15d86a4..9394b1d 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -12,49 +12,87 @@ const INSTRUCTIONS_ENV_VAR: &str = "VOICEVOX_MCP_INSTRUCTIONS"; const INSTRUCTIONS_FILE: &str = "INSTRUCTIONS.md"; fn load_instructions() -> Option { - // 1. Try environment variable first (highest priority) + use std::path::{Path, PathBuf}; + + fn try_load(path: &Path, description: &str) -> Option { + eprintln!( + "Trying instructions from {}: {}", + description, + path.display() + ); + match fs::read_to_string(path) { + Ok(content) => { + eprintln!("Loaded instructions from: {}", path.display()); + Some(content) + } + Err(e) if e.kind() != std::io::ErrorKind::NotFound => { + eprintln!("Error loading instructions from {}: {}", path.display(), e); + None + } + _ => None, + } + } + + // 1. Environment variable: VOICEVOX_MCP_INSTRUCTIONS (highest priority) if let Ok(custom_path) = std::env::var(INSTRUCTIONS_ENV_VAR) { - let path = std::path::Path::new(&custom_path); + let path = Path::new(&custom_path); + eprintln!( + "Trying instructions from environment variable: {}", + path.display() + ); match fs::read_to_string(path) { - Ok(content) => return Some(content), + Ok(content) => { + eprintln!("Loaded instructions from: {}", path.display()); + return Some(content); + } Err(e) => { - eprintln!( - "Could not load instructions from environment variable {:?}: {}", - path, e - ); + eprintln!("Could not load instructions from {}: {}", path.display(), e); + } + } + } + + // 2. XDG user config: $XDG_CONFIG_HOME/voicevox/INSTRUCTIONS.md (user-specific settings) + let xdg_config_var = std::env::var("XDG_CONFIG_HOME"); + if let Ok(ref xdg_config) = xdg_config_var { + let path = PathBuf::from(xdg_config) + .join("voicevox") + .join(INSTRUCTIONS_FILE); + if let Some(content) = try_load(&path, "XDG_CONFIG_HOME") { + return Some(content); + } + } + + // 3. Config fallback: ~/.config/voicevox/INSTRUCTIONS.md (only when XDG_CONFIG_HOME is not set) + if xdg_config_var.is_err() { + if let Ok(home) = std::env::var("HOME") { + let path = PathBuf::from(home) + .join(".config") + .join("voicevox") + .join(INSTRUCTIONS_FILE); + if let Some(content) = try_load(&path, "~/.config") { + return Some(content); } } } - // 2. Try executable directory (for distributed binaries) + // 4. Executable directory: INSTRUCTIONS.md bundled with the binary (distribution default) if let Ok(exe_path) = std::env::current_exe() { if let Some(exe_dir) = exe_path.parent() { - let instructions_path = exe_dir.join(INSTRUCTIONS_FILE); - match fs::read_to_string(&instructions_path) { - Ok(content) => return Some(content), - Err(e) if e.kind() != std::io::ErrorKind::NotFound => { - eprintln!( - "Error loading instructions from {:?}: {}", - instructions_path, e - ); - } - _ => {} + let path = exe_dir.join(INSTRUCTIONS_FILE); + if let Some(content) = try_load(&path, "executable directory") { + return Some(content); } } } - // 3. Fallback: current directory (for development) - match fs::read_to_string(INSTRUCTIONS_FILE) { - Ok(content) => Some(content), - Err(e) if e.kind() != std::io::ErrorKind::NotFound => { - eprintln!( - "Error loading instructions from current directory {}: {}", - INSTRUCTIONS_FILE, e - ); - None - } - _ => None, + // 5. Current directory: INSTRUCTIONS.md in working directory (development use) + let path = PathBuf::from(INSTRUCTIONS_FILE); + if let Some(content) = try_load(&path, "current directory") { + return Some(content); } + + eprintln!("No INSTRUCTIONS.md found in any location"); + None } pub async fn run_mcp_server() -> Result<()> { From ac0a87f43f4447db4e7c2eb4748f1053ca781c39 Mon Sep 17 00:00:00 2001 From: usabarashi <19676305+usabarashi@users.noreply.github.com> Date: Mon, 1 Sep 2025 00:35:33 +0900 Subject: [PATCH 02/14] Refactor configuration handling and remove unused setup code --- Cargo.lock | 63 ++----------- Cargo.toml | 3 - flake.nix | 189 +++++-------------------------------- nix/serena.nix | 144 ++++++++++++++++++++++++++++ scripts/ci.sh | 57 ++++++----- src/config.rs | 53 ----------- src/lib.rs | 1 - src/paths.rs | 5 - src/setup.rs | 115 ---------------------- src/synthesis/streaming.rs | 2 +- 10 files changed, 204 insertions(+), 428 deletions(-) create mode 100644 nix/serena.nix delete mode 100644 src/setup.rs diff --git a/Cargo.lock b/Cargo.lock index 6dc189b..3f7d6c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2233,15 +2233,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_spanned" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" -dependencies = [ - "serde", -] - [[package]] name = "serde_with" version = "3.13.0" @@ -2776,26 +2767,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" dependencies = [ "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", + "serde_spanned", + "toml_datetime", "toml_edit 0.19.15", ] -[[package]] -name = "toml" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" -dependencies = [ - "indexmap 2.9.0", - "serde", - "serde_spanned 1.0.0", - "toml_datetime 0.7.0", - "toml_parser", - "toml_writer", - "winnow 0.7.11", -] - [[package]] name = "toml_datetime" version = "0.6.11" @@ -2805,15 +2781,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" -dependencies = [ - "serde", -] - [[package]] name = "toml_edit" version = "0.19.15" @@ -2822,8 +2789,8 @@ checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.9.0", "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", + "serde_spanned", + "toml_datetime", "winnow 0.5.40", ] @@ -2834,25 +2801,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.9.0", - "toml_datetime 0.6.11", + "toml_datetime", "winnow 0.7.11", ] -[[package]] -name = "toml_parser" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" -dependencies = [ - "winnow 0.7.11", -] - -[[package]] -name = "toml_writer" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" - [[package]] name = "tracing" version = "0.1.41" @@ -2995,7 +2947,6 @@ version = "0.1.0" dependencies = [ "anyhow", "bincode", - "bytes", "clap", "compact_str", "dirs", @@ -3009,11 +2960,9 @@ dependencies = [ "serde", "serde_json", "smallvec", - "tempfile", "thiserror 1.0.69", "tokio", "tokio-util", - "toml 0.9.5", "voicevox_core", ] @@ -3104,7 +3053,7 @@ dependencies = [ "quote", "serde", "syn 2.0.104", - "toml 0.7.8", + "toml", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ddd04ab..1af5099 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,9 +31,6 @@ clap = { version = "4.5", features = ["derive", "env", "unicode", "wrap_help"] } serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" bincode = { version = "2.0", features = ["serde"] } -bytes = "1.5" -tempfile = "3.8" -toml = "0.9" dirs = "6.0" # MCP Server dependencies diff --git a/flake.nix b/flake.nix index c639614..d096fe2 100644 --- a/flake.nix +++ b/flake.nix @@ -1,22 +1,5 @@ { - description = '' - VOICEVOX CLI for Apple Silicon Macs - Dynamic voice detection system - - Zero-configuration Japanese text-to-speech with automatic voice model discovery. - Supports 26+ voice characters with dynamic detection and daemon-client architecture. - - Platform: Apple Silicon (aarch64-darwin) only - - License Information: - - CLI Tool: MIT License + Apache License 2.0 - - VOICEVOX Core: MIT License (https://github.com/VOICEVOX/voicevox_core/blob/main/LICENSE) - - ONNX Runtime: MIT License (https://github.com/microsoft/onnxruntime/blob/main/LICENSE) - - Usage Requirements: - - Credit VOICEVOX when using generated audio - - Follow individual voice library terms - - See official repositories for complete license details - ''; + description = "VOICEVOX CLI"; nixConfig = { substituters = [ @@ -70,7 +53,6 @@ sha256 = "sha256-OL5Hpyd0Mc+77PzUhtIIFmHjRQqLVaiITuHICg1QBJU="; }; - # Simple resources for voicevox-download binary voicevoxResources = pkgs.stdenv.mkDerivation { name = "voicevox-resources"; @@ -139,6 +121,7 @@ nativeBuildInputs = with pkgs; [ # Use fenix-provided rust toolchain that matches rust-toolchain.toml rustToolchain.defaultToolchain + rustToolchain.rust-analyzer # Build tools pkg-config @@ -155,7 +138,7 @@ cacert ]; - buildInputs = []; + buildInputs = [ ]; # Build-time environment variables preBuild = '' @@ -173,143 +156,21 @@ postInstall = '' # Install download utility cp ${voicevoxResources}/bin/voicevox-download $out/bin/ - - # Install setup script (renamed from voicevox-setup-models.sh) + + # Install setup script install -m755 ${./scripts/voicevox-setup.sh} $out/bin/voicevox-setup - + # Install INSTRUCTIONS.md for MCP server install -m644 ${./INSTRUCTIONS.md} $out/bin/INSTRUCTIONS.md - + # Note: All resources (ONNX, dict, models) will be downloaded at runtime ''; meta = packageMeta; }; - licenseAcceptor = pkgs.runCommand "voicevox-auto-setup" { } '' - mkdir -p $out/bin - substitute ${./scripts/voicevox-auto-setup.sh} $out/bin/voicevox-auto-setup \ - --replace "@@BASH_PATH@@" "${pkgs.bash}/bin/bash" \ - --replace "@@EXPECT_PATH@@" "${pkgs.expect}/bin/expect" \ - --replace "@@DOWNLOADER_PATH@@" "${voicevoxResources}/bin/voicevox-download" - chmod +x $out/bin/voicevox-auto-setup - ''; - - # Common Serena environment setup script - serenaEnvSetup = '' - # Get the directory where this script is invoked from - PROJECT_DIR="$(pwd)" - - # Create fake home directory structure in project - export HOME="$PROJECT_DIR/.project-home" - export XDG_DATA_HOME="$HOME/.local/share" - export XDG_CACHE_HOME="$HOME/.cache" - export UV_CACHE_DIR="$HOME/.cache/uv" - export UV_TOOL_DIR="$HOME/.local/uv/tools" - export CARGO_HOME="$PROJECT_DIR/.project-home/.cargo" - - # Create necessary directories - mkdir -p "$HOME/.serena/logs" - mkdir -p "$XDG_DATA_HOME/uv" - mkdir -p "$XDG_CACHE_HOME" - ''; - - # Serena index creation wrapper - serenaIndexWrapper = pkgs.writeShellScriptBin "serena-index" '' - ${serenaEnvSetup} - - echo "Creating Serena index for project..." - echo "HOME: $HOME" - echo "Project: $PROJECT_DIR" - - # Run serena index command with all paths pointing to project directory - exec ${pkgs.uv}/bin/uvx \ - --cache-dir "$UV_CACHE_DIR" \ - --from git+https://github.com/oraios/serena \ - serena project index - ''; - - # Serena MCP server wrapper with project-local paths - serenaMcpWrapper = pkgs.writeShellScriptBin "serena-mcp-wrapper" '' - ${serenaEnvSetup} - - echo "Starting Serena MCP server with project-local paths..." - echo "HOME: $HOME" - echo "Project: $PROJECT_DIR" - - # Run serena with all paths pointing to project directory - exec ${pkgs.uv}/bin/uvx \ - --cache-dir "$UV_CACHE_DIR" \ - --from git+https://github.com/oraios/serena \ - serena start-mcp-server \ - --context ide-assistant \ - --enable-web-dashboard false \ - --project "$PROJECT_DIR" - ''; - - # Helper function to run uvx with Serena - runSerenaCommand = '' - exec ${pkgs.uv}/bin/uvx \ - --cache-dir "$UV_CACHE_DIR" \ - --from git+https://github.com/oraios/serena \ - serena "$@" - ''; - - # Serena memory management wrapper - serenaMemoryWrapper = pkgs.writeShellScriptBin "serena-memory" '' - set -euo pipefail - - ${serenaEnvSetup} - - # Handle memory commands - case "''${1:-}" in - write) - if [ "$#" -lt 3 ]; then - echo "Error: write command requires at least 2 arguments" >&2 - echo "Usage: serena-memory write " >&2 - exit 1 - fi - MEMORY_NAME="$2" - echo "Writing memory: $MEMORY_NAME" - # Shift twice to get all remaining args as content - shift 2 - ${runSerenaCommand} memory write "$MEMORY_NAME" "$*" - ;; - read) - if [ "$#" -lt 2 ]; then - echo "Error: read command requires 1 argument" >&2 - echo "Usage: serena-memory read " >&2 - exit 1 - fi - ${runSerenaCommand} memory read "$2" - ;; - list) - ${runSerenaCommand} memory list - ;; - delete) - if [ "$#" -lt 2 ]; then - echo "Error: delete command requires 1 argument" >&2 - echo "Usage: serena-memory delete " >&2 - exit 1 - fi - echo "Deleting memory: $2" - ${runSerenaCommand} memory delete "$2" - ;; - *) - echo "Serena Memory Management" - echo "" - echo "Usage:" - echo " serena-memory write - Save a memory" - echo " serena-memory read - Read a memory" - echo " serena-memory list - List all memories" - echo " serena-memory delete - Delete a memory" - echo "" - echo "Example:" - echo " serena-memory write architecture 'This project uses daemon-client model'" - exit 1 - ;; - esac - ''; + # Import Serena configuration + serenaConfig = import ./nix/serena.nix { inherit pkgs rustToolchain; }; in { @@ -354,21 +215,23 @@ devShells.default = pkgs.mkShell { CARGO_HOME = "./.project-home/.cargo"; - buildInputs = with pkgs; [ - # Use fenix-provided rust toolchain that matches rust-toolchain.toml - rustToolchain.defaultToolchain - cargo-audit - - # Build tools - pkg-config - cmake - - # MCP - uv - serenaIndexWrapper - serenaMcpWrapper - serenaMemoryWrapper - ]; + buildInputs = + with pkgs; + [ + # Use fenix-provided rust toolchain that matches rust-toolchain.toml + rustToolchain.defaultToolchain + rustToolchain.rust-analyzer + cargo-audit + + # Build tools + pkg-config + cmake + + # MCP - Serena packages imported from config + ] + ++ serenaConfig.packages + ++ [ + ]; shellHook = '' # Create project-home directory for CARGO_HOME diff --git a/nix/serena.nix b/nix/serena.nix new file mode 100644 index 0000000..75c91af --- /dev/null +++ b/nix/serena.nix @@ -0,0 +1,144 @@ +# Serena MCP server configuration for VOICEVOX CLI +{ + pkgs, + rustToolchain, +}: +let + # Common Serena environment setup script + serenaEnvSetup = '' + # Get the directory where this script is invoked from + PROJECT_DIR="$(pwd)" + + # Create fake home directory structure in project + export HOME="$PROJECT_DIR/.project-home" + export XDG_DATA_HOME="$HOME/.local/share" + export XDG_CACHE_HOME="$HOME/.cache" + export UV_CACHE_DIR="$HOME/.cache/uv" + export UV_TOOL_DIR="$HOME/.local/uv/tools" + export CARGO_HOME="$PROJECT_DIR/.project-home/.cargo" + + # Create necessary directories + mkdir -p "$HOME/.serena/logs" + mkdir -p "$XDG_DATA_HOME/uv" + mkdir -p "$XDG_CACHE_HOME" + ''; + + # Helper function to run uvx with Serena + runSerenaCommand = '' + exec ${pkgs.uv}/bin/uvx \ + --cache-dir "$UV_CACHE_DIR" \ + --from git+https://github.com/oraios/serena \ + serena "$@" + ''; + + # Serena index creation wrapper + serenaIndexWrapper = pkgs.writeShellScriptBin "serena-index" '' + ${serenaEnvSetup} + + # Add rust-analyzer to PATH for Serena + export PATH="${rustToolchain.rust-analyzer}/bin:$PATH" + + echo "Creating Serena index for project..." + echo "HOME: $HOME" + echo "Project: $PROJECT_DIR" + echo "rust-analyzer: $(which rust-analyzer || echo 'not found')" + + # Run serena index command with all paths pointing to project directory + exec ${pkgs.uv}/bin/uvx \ + --cache-dir "$UV_CACHE_DIR" \ + --from git+https://github.com/oraios/serena \ + serena project index + ''; + + # Serena MCP server wrapper with project-local paths + serenaMcpWrapper = pkgs.writeShellScriptBin "serena-mcp-wrapper" '' + ${serenaEnvSetup} + + # Add rust-analyzer to PATH for Serena + export PATH="${rustToolchain.rust-analyzer}/bin:$PATH" + + echo "Starting Serena MCP server with project-local paths..." + echo "HOME: $HOME" + echo "Project: $PROJECT_DIR" + echo "rust-analyzer: $(which rust-analyzer || echo 'not found')" + + # Run serena with all paths pointing to project directory + exec ${pkgs.uv}/bin/uvx \ + --cache-dir "$UV_CACHE_DIR" \ + --from git+https://github.com/oraios/serena \ + serena start-mcp-server \ + --context ide-assistant \ + --enable-web-dashboard false \ + --project "$PROJECT_DIR" + ''; + + # Serena memory management wrapper + serenaMemoryWrapper = pkgs.writeShellScriptBin "serena-memory" '' + set -euo pipefail + + ${serenaEnvSetup} + + # Handle memory commands + case "''${1:-}" in + write) + if [ "$#" -lt 3 ]; then + echo "Error: write command requires at least 2 arguments" >&2 + echo "Usage: serena-memory write " >&2 + exit 1 + fi + MEMORY_NAME="$2" + echo "Writing memory: $MEMORY_NAME" + # Shift twice to get all remaining args as content + shift 2 + ${runSerenaCommand} memory write "$MEMORY_NAME" "$*" + ;; + read) + if [ "$#" -lt 2 ]; then + echo "Error: read command requires 1 argument" >&2 + echo "Usage: serena-memory read " >&2 + exit 1 + fi + ${runSerenaCommand} memory read "$2" + ;; + list) + ${runSerenaCommand} memory list + ;; + delete) + if [ "$#" -lt 2 ]; then + echo "Error: delete command requires 1 argument" >&2 + echo "Usage: serena-memory delete " >&2 + exit 1 + fi + echo "Deleting memory: $2" + ${runSerenaCommand} memory delete "$2" + ;; + *) + echo "Serena Memory Management" + echo "" + echo "Usage:" + echo " serena-memory write - Save a memory" + echo " serena-memory read - Read a memory" + echo " serena-memory list - List all memories" + echo " serena-memory delete - Delete a memory" + echo "" + echo "Example:" + echo " serena-memory write architecture 'This project uses daemon-client model'" + exit 1 + ;; + esac + ''; +in +{ + wrappers = { + serenaIndexWrapper = serenaIndexWrapper; + serenaMcpWrapper = serenaMcpWrapper; + serenaMemoryWrapper = serenaMemoryWrapper; + }; + + packages = [ + pkgs.uv + serenaIndexWrapper + serenaMcpWrapper + serenaMemoryWrapper + ]; +} \ No newline at end of file diff --git a/scripts/ci.sh b/scripts/ci.sh index 1d66ecb..24a3195 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -7,6 +7,11 @@ if [[ "${1:-}" == "--build-phase" ]]; then BUILD_PHASE=true fi +# Helper function to run commands in nix develop environment +run_in_nix() { + nix develop --accept-flake-config --command "$@" +} + echo "Running Complete CI Pipeline..." echo "==================================" @@ -26,8 +31,8 @@ if [[ "$BUILD_PHASE" == "true" ]]; then cargo --version else # Outside build, use nix develop - nix develop --accept-flake-config --command rustc --version - nix develop --accept-flake-config --command cargo --version + run_in_nix rustc --version + run_in_nix cargo --version fi echo "" @@ -36,21 +41,18 @@ if [[ "$BUILD_PHASE" == "true" ]]; then # Check formatting and show diff if needed if ! cargo fmt --check; then echo "Code formatting errors detected. Run 'cargo fmt' to fix." - echo "" - echo "Hint: The most common issue is missing newline at end of file." - echo "You can fix this by running: cargo fmt" exit 1 fi else - nix develop --accept-flake-config --command cargo fmt --check + run_in_nix cargo fmt --check fi echo "" echo "Running clippy analysis..." if [[ "$BUILD_PHASE" == "true" ]]; then - cargo clippy --all-targets --all-features -- -D warnings || (echo "Clippy warnings detected. Fix them before building." && exit 1) + cargo clippy --all-targets --all-features -- -D warnings else - nix develop --accept-flake-config --command cargo clippy --all-targets --all-features -- -D warnings + run_in_nix cargo clippy --all-targets --all-features -- -D warnings fi echo "" @@ -63,7 +65,7 @@ if [[ "$BUILD_PHASE" == "true" ]]; then SCRIPT_DIR="scripts" else # Use PROJECT_DIR if set by Nix, otherwise get from ci.sh location - if [[ -n "$PROJECT_DIR" ]]; then + if [[ -n "${PROJECT_DIR:-}" ]]; then SCRIPT_DIR="$PROJECT_DIR/scripts" else SCRIPT_DIR="$(dirname "$0")" @@ -101,11 +103,11 @@ if [[ "$BUILD_PHASE" == "true" ]]; then # Skip during build phase - cargo-audit might not be available echo "Skipping security audit during build phase" else - if ! nix develop --accept-flake-config --command cargo audit --version >/dev/null 2>&1; then + if ! run_in_nix cargo audit --version >/dev/null 2>&1; then echo "Installing cargo-audit..." - nix develop --accept-flake-config --command cargo install cargo-audit + run_in_nix cargo install cargo-audit fi - nix develop --accept-flake-config --command cargo audit + run_in_nix cargo audit fi # Build verification - skip during build phase to avoid circular dependency @@ -119,36 +121,31 @@ fi if [[ "$BUILD_PHASE" == "false" ]]; then echo "" echo "Verifying build artifacts..." - if [[ -d result/bin ]]; then - ls -la result/bin/ - echo "Build artifacts verified" - else + if [[ ! -d result/bin ]]; then echo "Build artifacts not found" exit 1 fi + echo "Build artifact contents:" + ls -lah result/bin/ + echo "" - echo "Verifying build artifacts..." - ls -la result/bin/ + echo "Binary verification:" file result/bin/voicevox-say file result/bin/voicevox-daemon - test -x result/bin/voicevox-setup-models - echo "All binaries built successfully" - + file result/bin/voicevox-mcp-server + echo "" echo "Testing functionality..." - result/bin/voicevox-say --help || echo "Help command test" - result/bin/voicevox-daemon --help || echo "Help command test" - result/bin/voicevox-say --version || echo "Version command not available" - + result/bin/voicevox-say --help >/dev/null + result/bin/voicevox-daemon --help >/dev/null + echo "Help commands work correctly" + echo "" - echo "Package verification..." - echo "Binary sizes:" - ls -lah result/bin/ + echo "Package verification:" echo "Static linking verification:" otool -L result/bin/voicevox-say | grep -E "(voicevox|onnx)" || echo "Static linking verified" - echo "Total package size:" - du -sh result/ + echo "Total package size: $(du -sh result/ | cut -f1)" fi echo "" diff --git a/src/config.rs b/src/config.rs index b9bf94c..d31be1d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,4 @@ -use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Config { @@ -39,53 +36,3 @@ fn default_delimiters() -> Vec { fn default_max_length() -> usize { 100 } - -impl Config { - pub fn load() -> Result { - if let Some(config_path) = Self::config_path()? { - if config_path.exists() { - let content = fs::read_to_string(&config_path) - .with_context(|| format!("Failed to read config from {:?}", config_path))?; - let config: Config = toml::from_str(&content) - .with_context(|| format!("Failed to parse config from {:?}", config_path))?; - Ok(config) - } else { - Ok(Self::default()) - } - } else { - Ok(Self::default()) - } - } - - pub fn save(&self) -> Result<()> { - if let Some(config_path) = Self::config_path()? { - if let Some(parent) = config_path.parent() { - fs::create_dir_all(parent)?; - } - let content = toml::to_string_pretty(self)?; - fs::write(&config_path, content) - .with_context(|| format!("Failed to write config to {:?}", config_path))?; - } - Ok(()) - } - - fn config_path() -> Result> { - if let Some(config_dir) = dirs::config_dir() { - let app_config_dir = config_dir.join("voicevox-cli"); - Ok(Some(app_config_dir.join("config.toml"))) - } else { - Ok(None) - } - } - - pub fn create_default_config_if_not_exists() -> Result<()> { - if let Some(config_path) = Self::config_path()? { - if !config_path.exists() { - let default_config = Self::default(); - default_config.save()?; - println!("Created default config at: {:?}", config_path); - } - } - Ok(()) - } -} diff --git a/src/lib.rs b/src/lib.rs index 3517342..cbed990 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,6 @@ pub mod daemon; pub mod ipc; pub mod mcp; pub mod paths; -pub mod setup; pub mod synthesis; pub mod voice; diff --git a/src/paths.rs b/src/paths.rs index 3d88e3b..25ca50f 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -25,11 +25,6 @@ pub fn get_default_voicevox_dir() -> PathBuf { }) } -/// Get the default models directory path -pub fn get_default_models_dir() -> PathBuf { - get_default_voicevox_dir().join(MODELS_SUBDIR) -} - pub fn get_socket_path() -> PathBuf { let env_socket_paths = [ ("VOICEVOX_SOCKET_PATH", ""), diff --git a/src/setup.rs b/src/setup.rs deleted file mode 100644 index eda2108..0000000 --- a/src/setup.rs +++ /dev/null @@ -1,115 +0,0 @@ -use anyhow::{anyhow, Result}; -use std::path::{Path, PathBuf}; - -use crate::paths::get_default_models_dir; - -pub fn attempt_first_run_setup() -> Result { - println!("VOICEVOX CLI - User Setup"); - println!("Setting up voice models for current user..."); - println!(); - - let target_dir = get_default_models_dir(); - - println!( - " Installing models to: {} (user-specific)", - target_dir.display() - ); - println!(" No sudo privileges required"); - - let exe_path = match std::env::current_exe() { - Ok(path) => path, - Err(_) => { - return show_manual_setup_instructions(&target_dir); - } - }; - - let pkg_root = match exe_path.parent().and_then(|p| p.parent()) { - Some(root) => root, - None => return show_manual_setup_instructions(&target_dir), - }; - - let auto_setup = pkg_root.join("bin/voicevox-auto-setup"); - if !auto_setup.exists() { - return show_manual_setup_instructions(&target_dir); - } - - println!("Running automatic setup..."); - - let status = std::process::Command::new(&auto_setup) - .arg(&target_dir) - .status(); - - match status { - Ok(status) if status.success() => { - if is_valid_models_directory(&target_dir) { - return Ok(target_dir); - } - - if let Ok(entries) = std::fs::read_dir(&target_dir) { - for entry in entries.filter_map(|e| e.ok()) { - if !entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) { - continue; - } - - let subdir = entry.path(); - if is_valid_models_directory(&subdir) { - return Ok(subdir); - } - } - } - - println!("Setup completed but no models found"); - } - Ok(_) => { - println!("Automatic setup failed"); - } - Err(e) => { - println!("Could not run automatic setup: {e}"); - } - } - - show_manual_setup_instructions(&target_dir) -} - -fn show_manual_setup_instructions(target_dir: &Path) -> Result { - println!(); - println!("Manual Setup Required:"); - println!( - "1. Run: voicevox-download --output {}", - target_dir.display() - ); - println!("2. Accept the VOICEVOX license terms"); - println!("3. Try running voicevox-say again"); - println!(); - println!("License Summary:"); - println!("- VOICEVOX voice models are free for commercial/non-commercial use"); - println!("- Credit required: 'VOICEVOX:[Character Name]' in generated audio"); - println!("- Full terms: https://voicevox.hiroshiba.jp/"); - - Err(anyhow!( - "Voice models not available. Please run setup manually." - )) -} - -pub fn is_valid_models_directory(path: &PathBuf) -> bool { - fn find_vvm_files_recursive(dir: &PathBuf) -> bool { - if let Ok(entries) = std::fs::read_dir(dir) { - for entry in entries.filter_map(|e| e.ok()) { - let entry_path = entry.path(); - - if let Some(file_name) = entry.file_name().to_str() { - if file_name.ends_with(".vvm") { - return true; - } - } - - if entry_path.is_dir() && find_vvm_files_recursive(&entry_path) { - return true; - } - } - } - false - } - - find_vvm_files_recursive(path) -} diff --git a/src/synthesis/streaming.rs b/src/synthesis/streaming.rs index 4601b32..5e5e535 100644 --- a/src/synthesis/streaming.rs +++ b/src/synthesis/streaming.rs @@ -13,7 +13,7 @@ pub struct StreamingSynthesizer { impl StreamingSynthesizer { pub async fn new() -> Result { let daemon_client = DaemonClient::connect_with_retry().await?; - let config = Config::load().unwrap_or_default(); + let config = Config::default(); let text_splitter = TextSplitter::from_config(&config.text_splitter); Ok(Self { daemon_client, From 7a546bccc0dc44c68d55b63696aeae4f7b72a6ce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Sep 2025 05:02:31 +0000 Subject: [PATCH 03/14] ci(deps): bump cachix/install-nix-action from 31.5.2 to 31.6.0 Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 31.5.2 to 31.6.0. - [Release notes](https://github.com/cachix/install-nix-action/releases) - [Changelog](https://github.com/cachix/install-nix-action/blob/master/RELEASE.md) - [Commits](https://github.com/cachix/install-nix-action/compare/fc6e360bedc9ee72d75e701397f0bb30dce77568...56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8) --- updated-dependencies: - dependency-name: cachix/install-nix-action dependency-version: 31.6.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/update-flake.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4775d79..5eae6cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install Nix with cache - uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2 + uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0 with: nix_path: nixpkgs=channel:nixos-unstable github_access_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 61c32ee..58e4ed4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,7 @@ jobs: git push origin "${TAG_NAME}" - name: Install Nix - uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2 + uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0 - name: Setup Cachix uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/update-flake.yml b/.github/workflows/update-flake.yml index 2aa7124..0e32cd5 100644 --- a/.github/workflows/update-flake.yml +++ b/.github/workflows/update-flake.yml @@ -29,7 +29,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Install Nix - uses: cachix/install-nix-action@fc6e360bedc9ee72d75e701397f0bb30dce77568 # v31.5.2 + uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0 with: nix_path: nixpkgs=channel:nixos-unstable From c32a252505951f2bf88de754cd86b60c2ba6552c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Sep 2025 06:27:39 +0000 Subject: [PATCH 04/14] deps(deps): bump thiserror from 1.0.69 to 2.0.15 Bumps [thiserror](https://github.com/dtolnay/thiserror) from 1.0.69 to 2.0.15. - [Release notes](https://github.com/dtolnay/thiserror/releases) - [Commits](https://github.com/dtolnay/thiserror/compare/1.0.69...2.0.15) --- updated-dependencies: - dependency-name: thiserror dependency-version: 2.0.15 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3f7d6c9..2e1a57d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2960,7 +2960,7 @@ dependencies = [ "serde", "serde_json", "smallvec", - "thiserror 1.0.69", + "thiserror 2.0.15", "tokio", "tokio-util", "voicevox_core", diff --git a/Cargo.toml b/Cargo.toml index 1af5099..1c9e2cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ path = "src/lib.rs" [dependencies] anyhow = "1.0" -thiserror = "1.0" +thiserror = "2.0" clap = { version = "4.5", features = ["derive", "env", "unicode", "wrap_help"] } serde = { version = "1.0", features = ["derive", "rc"] } From 15a85600f324249fef392dc9801215868ba9ca7e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:10:58 +0000 Subject: [PATCH 05/14] deps(deps): bump the patch-updates group across 1 directory with 4 updates Bumps the patch-updates group with 4 updates in the / directory: [thiserror](https://github.com/dtolnay/thiserror), [clap](https://github.com/clap-rs/clap), [serde_json](https://github.com/serde-rs/json) and [mimalloc](https://github.com/purpleprotocol/mimalloc_rust). Updates `thiserror` from 2.0.15 to 2.0.16 - [Release notes](https://github.com/dtolnay/thiserror/releases) - [Commits](https://github.com/dtolnay/thiserror/compare/2.0.15...2.0.16) Updates `clap` from 4.5.45 to 4.5.46 - [Release notes](https://github.com/clap-rs/clap/releases) - [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md) - [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.45...clap_complete-v4.5.46) Updates `serde_json` from 1.0.142 to 1.0.143 - [Release notes](https://github.com/serde-rs/json/releases) - [Commits](https://github.com/serde-rs/json/compare/v1.0.142...v1.0.143) Updates `mimalloc` from 0.1.47 to 0.1.48 - [Release notes](https://github.com/purpleprotocol/mimalloc_rust/releases) - [Commits](https://github.com/purpleprotocol/mimalloc_rust/compare/v0.1.47...v0.1.48) --- updated-dependencies: - dependency-name: thiserror dependency-version: 2.0.16 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch-updates - dependency-name: clap dependency-version: 4.5.46 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch-updates - dependency-name: serde_json dependency-version: 1.0.143 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch-updates - dependency-name: mimalloc dependency-version: 0.1.48 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch-updates ... Signed-off-by: dependabot[bot] --- Cargo.lock | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2e1a57d..a146ca2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -405,9 +405,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.45" +version = "4.5.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc0e74a703892159f5ae7d3aac52c8e6c392f5ae5f359c70b5881d60aaac318" +checksum = "2c5e4fcf9c21d2e544ca1ee9d8552de13019a42aa7dbf32747fa7aaf1df76e57" dependencies = [ "clap_builder", "clap_derive", @@ -415,9 +415,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.44" +version = "4.5.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3e7f4214277f3c7aa526a59dd3fbe306a370daee1f8b7b8c987069cd8e888a8" +checksum = "fecb53a0e6fcfb055f686001bc2e2592fa527efaf38dbe81a6a9563562e57d41" dependencies = [ "anstream", "anstyle", @@ -1396,9 +1396,9 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libmimalloc-sys" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88cd67e9de251c1781dbe2f641a1a3ad66eaae831b8a2c38fbdc5ddae16d4d" +checksum = "667f4fec20f29dfc6bc7357c582d91796c169ad7e2fce709468aefeb2c099870" dependencies = [ "cc", "libc", @@ -1488,9 +1488,9 @@ dependencies = [ [[package]] name = "mimalloc" -version = "0.1.47" +version = "0.1.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1791cbe101e95af5764f06f20f6760521f7158f69dbf9d6baf941ee1bf6bc40" +checksum = "e1ee66a4b64c74f4ef288bcbb9192ad9c3feaad75193129ac8509af543894fd8" dependencies = [ "libmimalloc-sys", ] @@ -2014,7 +2014,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.15", + "thiserror 2.0.16", ] [[package]] @@ -2213,9 +2213,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.142" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" dependencies = [ "indexmap 2.9.0", "itoa", @@ -2645,11 +2645,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.15" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d76d3f064b981389ecb4b6b7f45a0bf9fdac1d5b9204c7bd6714fecc302850" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl 2.0.15", + "thiserror-impl 2.0.16", ] [[package]] @@ -2665,9 +2665,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.15" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d29feb33e986b6ea906bd9c3559a856983f92371b3eaa5e83782a351623de0" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", @@ -2960,7 +2960,7 @@ dependencies = [ "serde", "serde_json", "smallvec", - "thiserror 2.0.15", + "thiserror 2.0.16", "tokio", "tokio-util", "voicevox_core", @@ -3202,7 +3202,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] From 40400d3997a5be5a124bd42e0bbc8bb14dcfbc1f Mon Sep 17 00:00:00 2001 From: Motoki KAMIMURA <19676305+usabarashi@users.noreply.github.com> Date: Sun, 7 Sep 2025 07:10:39 +0900 Subject: [PATCH 06/14] Improve the instructions (#37) * Update README with improved setup instructions and prerequisites * Update instruction file references to VOICEVOX.md * Enhance tool descriptions for text_to_speech and list_voice_styles * Refactor VOICEVOX documentation and tool descriptions for clarity * Fix formatting issue in VOICEVOX.md by adding newline at EOF --- CLAUDE.md | 14 ++++---- INSTRUCTIONS.md | 81 ----------------------------------------------- README.md | 25 +++++++++++---- VOICEVOX.md | 71 +++++++++++++++++++++++++++++++++++++++++ docs/mcp-usage.md | 20 ++++++------ flake.nix | 4 +-- src/mcp/server.rs | 12 +++---- src/mcp/tools.rs | 12 +++---- 8 files changed, 120 insertions(+), 119 deletions(-) delete mode 100644 INSTRUCTIONS.md create mode 100644 VOICEVOX.md diff --git a/CLAUDE.md b/CLAUDE.md index b1fe321..02add77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,10 +62,10 @@ The MCP server dynamically loads behavior instructions to guide AI assistant int **Loading Priority (XDG Base Directory compliant):** 1. **Environment variable**: `VOICEVOX_MCP_INSTRUCTIONS` (highest priority) -2. **XDG user config**: `$XDG_CONFIG_HOME/voicevox/INSTRUCTIONS.md` (user-specific settings) -3. **Config fallback**: `~/.config/voicevox/INSTRUCTIONS.md` (when XDG_CONFIG_HOME is not set) -4. **Executable directory**: `INSTRUCTIONS.md` bundled with the binary (distribution default) -5. **Current directory**: `INSTRUCTIONS.md` in working directory (development use) +2. **XDG user config**: `$XDG_CONFIG_HOME/voicevox/VOICEVOX.md` (user-specific settings) +3. **Config fallback**: `~/.config/voicevox/VOICEVOX.md` (when XDG_CONFIG_HOME is not set) +4. **Executable directory**: `VOICEVOX.md` bundled with the binary (distribution default) +5. **Current directory**: `VOICEVOX.md` in working directory (development use) **Configuration examples:** @@ -76,13 +76,13 @@ voicevox-mcp-server # Method 2: XDG_CONFIG_HOME (if set) mkdir -p $XDG_CONFIG_HOME/voicevox -cp custom-instructions.md $XDG_CONFIG_HOME/voicevox/INSTRUCTIONS.md +cp custom-instructions.md $XDG_CONFIG_HOME/voicevox/VOICEVOX.md voicevox-mcp-server # Method 3: XDG user configuration mkdir -p ~/.config/voicevox -cp custom-instructions.md ~/.config/voicevox/INSTRUCTIONS.md +cp custom-instructions.md ~/.config/voicevox/VOICEVOX.md voicevox-mcp-server ``` -Server operates normally without instruction files. Default behavior defined in [INSTRUCTIONS.md](INSTRUCTIONS.md). \ No newline at end of file +Server operates normally without instruction files. Default behavior defined in [VOICEVOX.md](VOICEVOX.md). \ No newline at end of file diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md deleted file mode 100644 index 0a45f68..0000000 --- a/INSTRUCTIONS.md +++ /dev/null @@ -1,81 +0,0 @@ -# VOICEVOX MCP Server Instructions - -Convert Japanese text to speech using ずんだもん voice styles. - -## Tools - -### text_to_speech -- `text` (required): Japanese text to synthesize -- `style_id` (required): Voice style ID (see list_voice_styles) -- `rate` (optional): Speech rate 0.5-2.0, default 1.0 -- `streaming` (optional): Enable streaming, default true - -### list_voice_styles -- `speaker_name` (optional): Filter by speaker name -- `style_name` (optional): Filter by style name - -## Audio Usage Policy - -**MUST use audio (required):** -- **User responses**: Always provide audio when returning output to user -- **Critical errors**: When errors require immediate user attention -- **Task completion**: After complex operations taking >30 seconds -- **User explicit requests**: When user says "読み上げて" or similar -- **Important confirmations**: Before potentially destructive operations - -**SHOULD use audio (recommended):** -- **Significant milestones**: Important progress in multi-step workflows -- **Successful problem resolution**: When fixing reported issues -- **Long operation updates**: Status during builds, tests, downloads -- **Context transitions**: Moving between major workflow phases -- **Achievement celebrations**: Completing challenging tasks - -**When to avoid audio:** -- Routine edits, searches, small tasks -- Repetitive similar events within short time -- Information already clearly visible in text output -- During rapid iteration cycles -- When user is clearly in focused coding mode - -**Context-aware guidelines:** -- Prioritize user workflow pace and context -- Use audio for significant events that deserve attention -- Match voice style to situation (see Voice Styles section) -- Be proactive but not intrusive - -## Voice Styles - -- **ID: 3 (ノーマル)**: Default professional communication -- **ID: 1 (あまあま)**: Celebrating achievements -- **ID: 22 (ささやき)**: Technical discussions -- **ID: 76 (なみだめ)**: Error situations, seeking help -- **ID: 75 (ヘロヘロ)**: Complex problems needing guidance - -**Detailed Examples:** - -**Task completion:** -- Simple task: 「タスクが完了したのだ」(ID: 3, ノーマル) -- Complex achievement: 「やったのだ!難しいタスクを解決できたのだ!」(ID: 1, あまあま) -- Build success: 「ビルドが成功したのだ」(ID: 3, ノーマル) - -**Error situations:** -- Recoverable error: 「エラーが出てしまったのだ...でも大丈夫、対処してみるのだ」(ID: 76, なみだめ) -- Need user help: 「困ったのだ...一緒に見てもらえるのだ?」(ID: 76, なみだめ) -- Critical error: 「重要なエラーが発生したのだ!確認が必要なのだ」(ID: 76, なみだめ) - -**Progress updates:** -- Long operation start: 「時間のかかる処理を始めるのだ...」(ID: 22, ささやき) -- Progress milestone: 「順調に進んでいるのだ」(ID: 3, ノーマル) -- Operation complete: 「処理が完了したのだ」(ID: 3, ノーマル) - -**Guidance requests:** -- Decision needed: 「判断が難しいのだ...どうしたらいいか教えてほしいのだ」(ID: 75, ヘロヘロ) -- Technical discussion: 「ちょっと相談があるのだ」(ID: 22, ささやき) - - -## Communication Style - -- Build partnership, not dominance -- Seek user expertise when genuinely needed -- Resolve independently when possible -- Use「のだ」speech pattern consistently diff --git a/README.md b/README.md index 21736d1..47ac560 100644 --- a/README.md +++ b/README.md @@ -18,24 +18,35 @@ Japanese text-to-speech using VOICEVOX Core for Apple Silicon Macs ## Quick Start -**Prerequisites**: [Nix package manager for macOS](https://nixos.org/download.html#nix-install-macos) must be installed. +**Prerequisites**: macOS Apple Silicon (M1, M2, M3, etc.) required. ```bash -# Try temporarily -nix shell github:usabarashi/voicevox-cli +# Method 1: Manual download (check https://github.com/usabarashi/voicevox-cli/releases/latest for the latest version) +# Replace with the actual version number (e.g., v20250830122339) +curl -L -o voicevox-cli.tar.gz https://github.com/usabarashi/voicevox-cli/releases/download//voicevox-cli--aarch64-darwin.tar.gz -# Or install permanently -nix profile install github:usabarashi/voicevox-cli +# Method 2: Auto-download latest (requires jq) +curl -L -o voicevox-cli.tar.gz "$(curl -s https://api.github.com/repos/usabarashi/voicevox-cli/releases/latest | jq -r '.assets[] | select(.name | contains("aarch64-darwin.tar.gz")) | .browser_download_url')" + +# Extract and setup +tar -xzf voicevox-cli.tar.gz +cd voicevox-cli-* # Setup required resources first -voicevox-setup # Download all required resources +./voicevox-setup # Download all required resources # Then use voice synthesis -voicevox-say "こんにちは、ずんだもんなのだ" +./voicevox-say "こんにちは、ずんだもんなのだ" ``` **Note**: `voicevox-setup` downloads required resources. `voicevox-say` requires setup to be completed first. +**Optional**: Add to PATH for system-wide access: +```bash +# Move binaries to a directory in your PATH (e.g., /usr/local/bin) +sudo cp voicevox-* /usr/local/bin/ +``` + ## Installation ### Development diff --git a/VOICEVOX.md b/VOICEVOX.md new file mode 100644 index 0000000..4799db1 --- /dev/null +++ b/VOICEVOX.md @@ -0,0 +1,71 @@ +# VOICEVOX MCP Instructions + +Convert Japanese text to speech using ずんだもん voice styles. + +## Tools + +### text_to_speech +- `text`: Japanese text (15-50 chars recommended, 100+ may have compatibility issues) +- `style_id`: Voice style ID (see list_voice_styles) +- `rate`: Speech rate 0.5-2.0 (default 1.0) +- `streaming`: Enable streaming (default true) + +### list_voice_styles +- `speaker_name`: Filter by speaker name (optional) +- `style_name`: Filter by style name (optional) + +## Voice Styles + +- **ID: 3 (ノーマル)**: Default communication +- **ID: 1 (あまあま)**: Success, achievements, celebrations +- **ID: 22 (ささやき)**: Technical discussions, quiet updates +- **ID: 76 (なみだめ)**: Errors, problems, seeking help +- **ID: 75 (ヘロヘロ)**: Complex problems, need guidance + +## Audio Rules (Priority Order) + +### Always use audio +- User responses → ID: 3 +- Exit codes != 0 → ID: 76 + 「エラーなのだ」 +- Error keywords (error/failed/exception) → ID: 76 + 「問題なのだ」 +- User request "読み上げて" → ID: 3 + +### Use for important moments +- Task completion (>30s) → ID: 1「完了したのだ」 or ID: 3 +- Major milestones → ID: 1「進展があったのだ」 +- Problem resolution → ID: 1「解決できたのだ」 +- First error in sequence → ID: 76 + +### Rate limits +- Minimum 3 seconds between calls +- Skip identical messages within 10 seconds +- Max 3 audio per minute for routine tasks + +### Avoid audio +- Routine edits, searches, small tasks +- Rapid iteration cycles +- Information already visible in text + +## Text Guidelines + +**Optimal compatibility:** +- **15-50 characters**: All clients work well (~1-2s) +- **50-80 characters**: Most clients handle fine (~2-3s) +- **100+ characters**: Some clients may timeout - split into multiple calls + +**Communication style:** +- Always use「のだ」speech pattern +- Keep messages natural but concise +- Split at sentence boundaries when needed + +## Error Handling + +- If text_to_speech fails: Continue silently, no retry +- For detected errors: Use ID: 76, keep reasonably short +- Complex errors: Split into multiple calls if needed + +## Fallback Behavior + +- If style_id unavailable: Use ID: 3 (default) +- If synthesis fails: Continue without audio +- If daemon unavailable: Skip audio, don't block operations diff --git a/docs/mcp-usage.md b/docs/mcp-usage.md index 3b60f07..4c06520 100644 --- a/docs/mcp-usage.md +++ b/docs/mcp-usage.md @@ -43,7 +43,7 @@ The server will respond with its capabilities and available tools. ## AI Assistant Instructions -The MCP server automatically loads behavioral instructions for AI assistants from `INSTRUCTIONS.md`. These instructions define: +The MCP server automatically loads behavioral instructions for AI assistants from `VOICEVOX.md`. These instructions define: - **Audio usage policies**: When and how to use voice synthesis - **Voice style guidelines**: Which voice styles to use in different situations @@ -54,10 +54,10 @@ The MCP server automatically loads behavioral instructions for AI assistants fro The server loads instructions using XDG Base Directory specification with the following priority order: 1. **Environment variable**: File specified by `VOICEVOX_MCP_INSTRUCTIONS` (highest priority) -2. **XDG user config**: `$XDG_CONFIG_HOME/voicevox/INSTRUCTIONS.md` (user-specific settings) -3. **Config fallback**: `~/.config/voicevox/INSTRUCTIONS.md` (when XDG_CONFIG_HOME is not set) -4. **Executable directory**: `INSTRUCTIONS.md` bundled with the binary (distribution default) -5. **Current directory**: `INSTRUCTIONS.md` in working directory (development use) +2. **XDG user config**: `$XDG_CONFIG_HOME/voicevox/VOICEVOX.md` (user-specific settings) +3. **Config fallback**: `~/.config/voicevox/VOICEVOX.md` (when XDG_CONFIG_HOME is not set) +4. **Executable directory**: `VOICEVOX.md` bundled with the binary (distribution default) +5. **Current directory**: `VOICEVOX.md` in working directory (development use) ### Custom Instructions @@ -73,7 +73,7 @@ voicevox-mcp-server ```bash # When XDG_CONFIG_HOME is configured (higher priority) mkdir -p $XDG_CONFIG_HOME/voicevox -cp custom-instructions.md $XDG_CONFIG_HOME/voicevox/INSTRUCTIONS.md +cp custom-instructions.md $XDG_CONFIG_HOME/voicevox/VOICEVOX.md voicevox-mcp-server ``` @@ -81,7 +81,7 @@ voicevox-mcp-server ```bash # Create user-specific configuration (XDG default location) mkdir -p ~/.config/voicevox -cp custom-instructions.md ~/.config/voicevox/INSTRUCTIONS.md +cp custom-instructions.md ~/.config/voicevox/VOICEVOX.md voicevox-mcp-server ``` @@ -125,8 +125,8 @@ voicevox-mcp-server 2>&1 | grep "instructions" Example output: ``` -Trying instructions from XDG_CONFIG_HOME: /home/user/.config/voicevox/INSTRUCTIONS.md -Loaded instructions from: /home/user/.config/voicevox/INSTRUCTIONS.md +Trying instructions from XDG_CONFIG_HOME: /home/user/.config/voicevox/VOICEVOX.md +Loaded instructions from: /home/user/.config/voicevox/VOICEVOX.md ``` ## Available Tools @@ -220,7 +220,7 @@ Configure Claude Desktop to use the VOICEVOX MCP server: } ``` -The AI assistant will automatically receive and follow the instructions from `INSTRUCTIONS.md`, enabling context-aware voice synthesis during conversations. +The AI assistant will automatically receive and follow the instructions from `VOICEVOX.md`, enabling context-aware voice synthesis during conversations. ## Streaming vs Non-Streaming diff --git a/flake.nix b/flake.nix index d096fe2..264f526 100644 --- a/flake.nix +++ b/flake.nix @@ -160,8 +160,8 @@ # Install setup script install -m755 ${./scripts/voicevox-setup.sh} $out/bin/voicevox-setup - # Install INSTRUCTIONS.md for MCP server - install -m644 ${./INSTRUCTIONS.md} $out/bin/INSTRUCTIONS.md + # Install VOICEVOX.md for MCP server + install -m644 ${./VOICEVOX.md} $out/bin/VOICEVOX.md # Note: All resources (ONNX, dict, models) will be downloaded at runtime ''; diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 9394b1d..55e22a2 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -9,7 +9,7 @@ use crate::mcp::types::*; const MCP_VERSION: &str = "2025-03-26"; const INSTRUCTIONS_ENV_VAR: &str = "VOICEVOX_MCP_INSTRUCTIONS"; -const INSTRUCTIONS_FILE: &str = "INSTRUCTIONS.md"; +const INSTRUCTIONS_FILE: &str = "VOICEVOX.md"; fn load_instructions() -> Option { use std::path::{Path, PathBuf}; @@ -51,7 +51,7 @@ fn load_instructions() -> Option { } } - // 2. XDG user config: $XDG_CONFIG_HOME/voicevox/INSTRUCTIONS.md (user-specific settings) + // 2. XDG user config: $XDG_CONFIG_HOME/voicevox/VOICEVOX.md (user-specific settings) let xdg_config_var = std::env::var("XDG_CONFIG_HOME"); if let Ok(ref xdg_config) = xdg_config_var { let path = PathBuf::from(xdg_config) @@ -62,7 +62,7 @@ fn load_instructions() -> Option { } } - // 3. Config fallback: ~/.config/voicevox/INSTRUCTIONS.md (only when XDG_CONFIG_HOME is not set) + // 3. Config fallback: ~/.config/voicevox/VOICEVOX.md (only when XDG_CONFIG_HOME is not set) if xdg_config_var.is_err() { if let Ok(home) = std::env::var("HOME") { let path = PathBuf::from(home) @@ -75,7 +75,7 @@ fn load_instructions() -> Option { } } - // 4. Executable directory: INSTRUCTIONS.md bundled with the binary (distribution default) + // 4. Executable directory: VOICEVOX.md bundled with the binary (distribution default) if let Ok(exe_path) = std::env::current_exe() { if let Some(exe_dir) = exe_path.parent() { let path = exe_dir.join(INSTRUCTIONS_FILE); @@ -85,13 +85,13 @@ fn load_instructions() -> Option { } } - // 5. Current directory: INSTRUCTIONS.md in working directory (development use) + // 5. Current directory: VOICEVOX.md in working directory (development use) let path = PathBuf::from(INSTRUCTIONS_FILE); if let Some(content) = try_load(&path, "current directory") { return Some(content); } - eprintln!("No INSTRUCTIONS.md found in any location"); + eprintln!("No VOICEVOX.md found in any location"); None } diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index ab90799..016688e 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -5,28 +5,28 @@ pub fn get_tool_definitions() -> Vec { vec![ ToolDefinition { name: "text_to_speech".to_string(), - description: "Convert Japanese text to speech (TTS) and play on server".to_string(), + description: "Convert Japanese text to speech with VOICEVOX. Splits long messages automatically for client compatibility.".to_string(), input_schema: ToolInputSchema { schema_type: "object".to_string(), properties: json!({ "text": { "type": "string", - "description": "Japanese text to synthesize" + "description": "Japanese text (15-50 chars optimal, 100+ may need splitting)" }, "style_id": { "type": "integer", - "description": "Voice style ID (e.g., 3 for Zundamon Normal)" + "description": "3=normal, 1=happy, 22=whisper, 76=sad, 75=confused" }, "rate": { "type": "number", - "description": "Speech rate (0.5-2.0)", + "description": "Speed (0.5-2.0, default 1.0)", "minimum": 0.5, "maximum": 2.0, "default": 1.0 }, "streaming": { "type": "boolean", - "description": "Enable streaming playback for lower latency", + "description": "Lower latency mode", "default": true } }) @@ -38,7 +38,7 @@ pub fn get_tool_definitions() -> Vec { }, ToolDefinition { name: "list_voice_styles".to_string(), - description: "List available voice styles with optional filtering".to_string(), + description: "Get available VOICEVOX voice styles for text_to_speech. Use this before synthesizing speech to discover available style_ids and their characteristics. Filter by speaker_name or style_name (e.g., 'ノーマル', 'ささやき', 'なみだめ') to find appropriate voices. Returns style_id, speaker name, and style type for each voice. Call this when users ask about available voices or when you need to select an appropriate voice style based on context.".to_string(), input_schema: ToolInputSchema { schema_type: "object".to_string(), properties: json!({ From e988083aabedc8c9ce727399630df08f9f7f19a4 Mon Sep 17 00:00:00 2001 From: Motoki KAMIMURA <19676305+usabarashi@users.noreply.github.com> Date: Sun, 7 Sep 2025 09:52:50 +0900 Subject: [PATCH 07/14] Potential fix for code scanning alert no. 1: Workflow does not contain permissions Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .github/workflows/branch-restrictions.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/branch-restrictions.yml b/.github/workflows/branch-restrictions.yml index ff43dfe..a5e51e3 100644 --- a/.github/workflows/branch-restrictions.yml +++ b/.github/workflows/branch-restrictions.yml @@ -1,5 +1,8 @@ name: Branch Restrictions +permissions: + contents: read + on: pull_request: branches: From 5b7265e18fdc264d4f17fdae1363994bb16731df Mon Sep 17 00:00:00 2001 From: usabarashi <19676305+usabarashi@users.noreply.github.com> Date: Fri, 19 Sep 2025 07:12:58 +0900 Subject: [PATCH 08/14] Refactor Serena MCP configuration and update dependencies --- .mcp.json | 27 ++++++++-- flake.lock | 20 +++---- flake.nix | 35 +++++------- nix/serena.nix | 144 ------------------------------------------------- 4 files changed, 45 insertions(+), 181 deletions(-) delete mode 100644 nix/serena.nix diff --git a/.mcp.json b/.mcp.json index 1b187a8..503b735 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,12 +1,29 @@ { "mcpServers": { "serena": { - "command": "nix", + "command": "uvx", "args": [ - "develop", - "-c", - "serena-mcp-wrapper" - ] + "--cache-dir", + "./.project-home/.cache/uv", + "--from", + "git+https://github.com/oraios/serena", + "serena", + "start-mcp-server", + "--context", + "ide-assistant", + "--enable-web-dashboard", + "false", + "--project", + "." + ], + "env": { + "HOME": "./.project-home", + "XDG_DATA_HOME": "./.project-home/.local/share", + "XDG_CACHE_HOME": "./.project-home/.cache", + "UV_CACHE_DIR": "./.project-home/.cache/uv", + "UV_TOOL_DIR": "./.project-home/.local/uv/tools", + "CARGO_HOME": "./.project-home/.cargo" + } } } } diff --git a/flake.lock b/flake.lock index 8559ca9..4939397 100644 --- a/flake.lock +++ b/flake.lock @@ -8,11 +8,11 @@ "rust-analyzer-src": "rust-analyzer-src" }, "locked": { - "lastModified": 1755585599, - "narHash": "sha256-tl/0cnsqB/Yt7DbaGMel2RLa7QG5elA8lkaOXli6VdY=", + "lastModified": 1758004879, + "narHash": "sha256-kV7tQzcNbmo58wg2uE2MQ/etaTx+PxBMHeNrLP8vOgk=", "owner": "nix-community", "repo": "fenix", - "rev": "6ed03ef4c8ec36d193c18e06b9ecddde78fb7e42", + "rev": "07e5ce53dd020e6b337fdddc934561bee0698fa2", "type": "github" }, "original": { @@ -41,16 +41,16 @@ }, "nixpkgs": { "locked": { - "lastModified": 1755615617, - "narHash": "sha256-HMwfAJBdrr8wXAkbGhtcby1zGFvs+StOp19xNsbqdOg=", + "lastModified": 1758029226, + "narHash": "sha256-TjqVmbpoCqWywY9xIZLTf6ANFvDCXdctCjoYuYPYdMI=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "20075955deac2583bb12f07151c2df830ef346b4", + "rev": "08b8f92ac6354983f5382124fef6006cade4a1c1", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-unstable", + "ref": "nixpkgs-unstable", "repo": "nixpkgs", "type": "github" } @@ -65,11 +65,11 @@ "rust-analyzer-src": { "flake": false, "locked": { - "lastModified": 1755504847, - "narHash": "sha256-VX0B9hwhJypCGqncVVLC+SmeMVd/GAYbJZ0MiiUn2Pk=", + "lastModified": 1757362324, + "narHash": "sha256-/PAhxheUq4WBrW5i/JHzcCqK5fGWwLKdH6/Lu1tyS18=", "owner": "rust-lang", "repo": "rust-analyzer", - "rev": "a905e3b21b144d77e1b304e49f3264f6f8d4db75", + "rev": "9edc9cbe5d8e832b5864e09854fa94861697d2fd", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 264f526..1098450 100644 --- a/flake.nix +++ b/flake.nix @@ -24,7 +24,7 @@ }; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; flake-utils.url = "github:numtide/flake-utils"; fenix = { url = "github:nix-community/fenix"; @@ -169,9 +169,6 @@ meta = packageMeta; }; - # Import Serena configuration - serenaConfig = import ./nix/serena.nix { inherit pkgs rustToolchain; }; - in { packages = { @@ -215,23 +212,19 @@ devShells.default = pkgs.mkShell { CARGO_HOME = "./.project-home/.cargo"; - buildInputs = - with pkgs; - [ - # Use fenix-provided rust toolchain that matches rust-toolchain.toml - rustToolchain.defaultToolchain - rustToolchain.rust-analyzer - cargo-audit + buildInputs = with pkgs; [ + # Use fenix-provided rust toolchain that matches rust-toolchain.toml + rustToolchain.defaultToolchain + rustToolchain.rust-analyzer + cargo-audit - # Build tools - pkg-config - cmake + # Build tools + pkg-config + cmake - # MCP - Serena packages imported from config - ] - ++ serenaConfig.packages - ++ [ - ]; + # UV for Python package management (for Serena MCP) + uv + ]; shellHook = '' # Create project-home directory for CARGO_HOME @@ -244,11 +237,9 @@ echo " cargo run --bin voicevox-say - Run client" echo " nix build - Build with Nix" echo " nix run - Run voicevox-say directly" - echo " serena-index - Create Serena index for the project" - echo " serena-mcp-wrapper - Start Serena MCP server" - echo " serena-memory - Manage project memories" echo "" echo "Dynamic voice detection system - no hardcoded voice names" + echo "MCP servers are configured in .mcp.json" ''; }; diff --git a/nix/serena.nix b/nix/serena.nix deleted file mode 100644 index 75c91af..0000000 --- a/nix/serena.nix +++ /dev/null @@ -1,144 +0,0 @@ -# Serena MCP server configuration for VOICEVOX CLI -{ - pkgs, - rustToolchain, -}: -let - # Common Serena environment setup script - serenaEnvSetup = '' - # Get the directory where this script is invoked from - PROJECT_DIR="$(pwd)" - - # Create fake home directory structure in project - export HOME="$PROJECT_DIR/.project-home" - export XDG_DATA_HOME="$HOME/.local/share" - export XDG_CACHE_HOME="$HOME/.cache" - export UV_CACHE_DIR="$HOME/.cache/uv" - export UV_TOOL_DIR="$HOME/.local/uv/tools" - export CARGO_HOME="$PROJECT_DIR/.project-home/.cargo" - - # Create necessary directories - mkdir -p "$HOME/.serena/logs" - mkdir -p "$XDG_DATA_HOME/uv" - mkdir -p "$XDG_CACHE_HOME" - ''; - - # Helper function to run uvx with Serena - runSerenaCommand = '' - exec ${pkgs.uv}/bin/uvx \ - --cache-dir "$UV_CACHE_DIR" \ - --from git+https://github.com/oraios/serena \ - serena "$@" - ''; - - # Serena index creation wrapper - serenaIndexWrapper = pkgs.writeShellScriptBin "serena-index" '' - ${serenaEnvSetup} - - # Add rust-analyzer to PATH for Serena - export PATH="${rustToolchain.rust-analyzer}/bin:$PATH" - - echo "Creating Serena index for project..." - echo "HOME: $HOME" - echo "Project: $PROJECT_DIR" - echo "rust-analyzer: $(which rust-analyzer || echo 'not found')" - - # Run serena index command with all paths pointing to project directory - exec ${pkgs.uv}/bin/uvx \ - --cache-dir "$UV_CACHE_DIR" \ - --from git+https://github.com/oraios/serena \ - serena project index - ''; - - # Serena MCP server wrapper with project-local paths - serenaMcpWrapper = pkgs.writeShellScriptBin "serena-mcp-wrapper" '' - ${serenaEnvSetup} - - # Add rust-analyzer to PATH for Serena - export PATH="${rustToolchain.rust-analyzer}/bin:$PATH" - - echo "Starting Serena MCP server with project-local paths..." - echo "HOME: $HOME" - echo "Project: $PROJECT_DIR" - echo "rust-analyzer: $(which rust-analyzer || echo 'not found')" - - # Run serena with all paths pointing to project directory - exec ${pkgs.uv}/bin/uvx \ - --cache-dir "$UV_CACHE_DIR" \ - --from git+https://github.com/oraios/serena \ - serena start-mcp-server \ - --context ide-assistant \ - --enable-web-dashboard false \ - --project "$PROJECT_DIR" - ''; - - # Serena memory management wrapper - serenaMemoryWrapper = pkgs.writeShellScriptBin "serena-memory" '' - set -euo pipefail - - ${serenaEnvSetup} - - # Handle memory commands - case "''${1:-}" in - write) - if [ "$#" -lt 3 ]; then - echo "Error: write command requires at least 2 arguments" >&2 - echo "Usage: serena-memory write " >&2 - exit 1 - fi - MEMORY_NAME="$2" - echo "Writing memory: $MEMORY_NAME" - # Shift twice to get all remaining args as content - shift 2 - ${runSerenaCommand} memory write "$MEMORY_NAME" "$*" - ;; - read) - if [ "$#" -lt 2 ]; then - echo "Error: read command requires 1 argument" >&2 - echo "Usage: serena-memory read " >&2 - exit 1 - fi - ${runSerenaCommand} memory read "$2" - ;; - list) - ${runSerenaCommand} memory list - ;; - delete) - if [ "$#" -lt 2 ]; then - echo "Error: delete command requires 1 argument" >&2 - echo "Usage: serena-memory delete " >&2 - exit 1 - fi - echo "Deleting memory: $2" - ${runSerenaCommand} memory delete "$2" - ;; - *) - echo "Serena Memory Management" - echo "" - echo "Usage:" - echo " serena-memory write - Save a memory" - echo " serena-memory read - Read a memory" - echo " serena-memory list - List all memories" - echo " serena-memory delete - Delete a memory" - echo "" - echo "Example:" - echo " serena-memory write architecture 'This project uses daemon-client model'" - exit 1 - ;; - esac - ''; -in -{ - wrappers = { - serenaIndexWrapper = serenaIndexWrapper; - serenaMcpWrapper = serenaMcpWrapper; - serenaMemoryWrapper = serenaMemoryWrapper; - }; - - packages = [ - pkgs.uv - serenaIndexWrapper - serenaMcpWrapper - serenaMemoryWrapper - ]; -} \ No newline at end of file From d114b59cc9317551343423b4dae3dec63c6ef035 Mon Sep 17 00:00:00 2001 From: usabarashi <19676305+usabarashi@users.noreply.github.com> Date: Fri, 19 Sep 2025 07:22:50 +0900 Subject: [PATCH 09/14] Remove cache-dir argument from Serena MCP configuration --- .mcp.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/.mcp.json b/.mcp.json index 503b735..eda071e 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,8 +3,6 @@ "serena": { "command": "uvx", "args": [ - "--cache-dir", - "./.project-home/.cache/uv", "--from", "git+https://github.com/oraios/serena", "serena", From 80b077c8e3f7fe0f1f21e1ce3b466475f115a9f6 Mon Sep 17 00:00:00 2001 From: Motoki KAMIMURA <19676305+usabarashi@users.noreply.github.com> Date: Tue, 30 Sep 2025 00:08:05 +0900 Subject: [PATCH 10/14] Compatible with the cancellation protocol (#40) * Implement active request management and cancellation for MCP tools - Added `ActiveRequests` struct to manage active MCP requests and their cancellation tokens. - Implemented methods for registering, cancelling, and completing requests. - Enhanced `spawn_execution` to support cancellation during tool execution. - Refactored `run_mcp_server` to utilize `ActiveRequests` for handling incoming requests. - Updated tool execution logic in `tools.rs` to support cancellation for `text_to_speech`. - Removed unused `types.rs` file as its contents were integrated into other modules. * Add response channel to ActiveRequests for async tool execution * Enhance cancellation handling and debugging in MCP tools * Implement cancellation of active requests on client disconnect and shutdown * Refactor error handling and logging in MCP server and tools * Improve cancellable audio playback handling in synthesis function * Add cancellation request handling and tests for MCP protocol * Add tempfile dependency and improve audio playback cancellation * Refactor audio playback cancellation handling in tools.rs * Fix audio playback cancellation by retaining stream guard * Use existing runtime handle for tool request execution --- Cargo.lock | 1 + Cargo.toml | 1 + flake.lock | 18 +- flake.nix | 36 +-- src/bin/mcp_server.rs | 9 +- src/client/audio.rs | 39 ++-- src/daemon/process.rs | 5 +- src/daemon/server.rs | 54 ++--- src/mcp/handlers.rs | 277 ---------------------- src/mcp/mod.rs | 4 +- src/mcp/protocol.rs | 496 ++++++++++++++++++++++++++++++++++++++++ src/mcp/requests.rs | 192 ++++++++++++++++ src/mcp/server.rs | 371 ++++++++---------------------- src/mcp/tools.rs | 518 +++++++++++++++++++++++++++++++++++++++++- src/mcp/types.rs | 124 ---------- src/paths.rs | 6 +- src/voice.rs | 22 +- 17 files changed, 1356 insertions(+), 817 deletions(-) delete mode 100644 src/mcp/handlers.rs create mode 100644 src/mcp/protocol.rs create mode 100644 src/mcp/requests.rs delete mode 100644 src/mcp/types.rs diff --git a/Cargo.lock b/Cargo.lock index a146ca2..78eea02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2960,6 +2960,7 @@ dependencies = [ "serde", "serde_json", "smallvec", + "tempfile", "thiserror 2.0.16", "tokio", "tokio-util", diff --git a/Cargo.toml b/Cargo.toml index 1c9e2cd..29535d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" bincode = { version = "2.0", features = ["serde"] } dirs = "6.0" +tempfile = "3.10" # MCP Server dependencies jsonrpc-lite = "0.6" diff --git a/flake.lock b/flake.lock index 4939397..edd3deb 100644 --- a/flake.lock +++ b/flake.lock @@ -8,11 +8,11 @@ "rust-analyzer-src": "rust-analyzer-src" }, "locked": { - "lastModified": 1758004879, - "narHash": "sha256-kV7tQzcNbmo58wg2uE2MQ/etaTx+PxBMHeNrLP8vOgk=", + "lastModified": 1758350402, + "narHash": "sha256-xpbGgQ6ymKvz/LQ3RrUTHdbRKWznZAbdaNAH7TdbKZs=", "owner": "nix-community", "repo": "fenix", - "rev": "07e5ce53dd020e6b337fdddc934561bee0698fa2", + "rev": "bfa40349cb508ebec2a8d0f89d65022967d28dc4", "type": "github" }, "original": { @@ -41,11 +41,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1758029226, - "narHash": "sha256-TjqVmbpoCqWywY9xIZLTf6ANFvDCXdctCjoYuYPYdMI=", + "lastModified": 1758262103, + "narHash": "sha256-aBGl3XEOsjWw6W3AHiKibN7FeoG73dutQQEqnd/etR8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "08b8f92ac6354983f5382124fef6006cade4a1c1", + "rev": "12bd230118a1901a4a5d393f9f56b6ad7e571d01", "type": "github" }, "original": { @@ -65,11 +65,11 @@ "rust-analyzer-src": { "flake": false, "locked": { - "lastModified": 1757362324, - "narHash": "sha256-/PAhxheUq4WBrW5i/JHzcCqK5fGWwLKdH6/Lu1tyS18=", + "lastModified": 1758294437, + "narHash": "sha256-PXDZtnSSNXIlTlytspxkTm/RENaQKdTJ44RGqm/LPLA=", "owner": "rust-lang", "repo": "rust-analyzer", - "rev": "9edc9cbe5d8e832b5864e09854fa94861697d2fd", + "rev": "b12a1293473d4c1c74a63752184b8d21d32a6bde", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 1098450..a5ad75f 100644 --- a/flake.nix +++ b/flake.nix @@ -219,28 +219,13 @@ cargo-audit # Build tools - pkg-config cmake + pkg-config - # UV for Python package management (for Serena MCP) + # for MCP + nixd uv ]; - - shellHook = '' - # Create project-home directory for CARGO_HOME - mkdir -p .project-home - - echo "VOICEVOX CLI Development Environment (Apple Silicon)" - echo "Available commands:" - echo " cargo build --bin voicevox-say - Build client" - echo " cargo build --bin voicevox-daemon - Build daemon" - echo " cargo run --bin voicevox-say - Run client" - echo " nix build - Build with Nix" - echo " nix run - Run voicevox-say directly" - echo "" - echo "Dynamic voice detection system - no hardcoded voice names" - echo "MCP servers are configured in .mcp.json" - ''; }; lib = { @@ -251,26 +236,11 @@ } ) // { - # Example usage for other projects: - # { - # inputs.voicevox-cli.url = "github:usabarashi/voicevox-cli"; - # - # # In your system or home-manager configuration: - # environment.systemPackages = [ - # voicevox-cli.packages.aarch64-darwin.default - # ]; - # } - overlays.default = final: prev: { voicevox-cli = (self.packages.${final.system} or self.packages.aarch64-darwin).voicevox-cli; voicevox-say = final.voicevox-cli; }; overlays.voicevox-cli = self.overlays.default; - - # Project metadata (not a standard flake output) - # This information is available via: - # - Individual package meta attributes - # - README.md and LICENSE files }; } diff --git a/src/bin/mcp_server.rs b/src/bin/mcp_server.rs index e540d5a..9dd40f6 100644 --- a/src/bin/mcp_server.rs +++ b/src/bin/mcp_server.rs @@ -104,12 +104,9 @@ async fn handle_already_running(socket_path: &std::path::Path) -> DaemonResult<( }) } } - Err(e) => { - eprintln!("Warning: Failed to find daemon processes: {}", e); - Err(DaemonError::StartupFailed { - message: format!("Failed to find daemon processes: {}", e), - }) - } + Err(e) => Err(DaemonError::StartupFailed { + message: format!("Failed to find daemon processes: {}", e), + }), } } diff --git a/src/client/audio.rs b/src/client/audio.rs index d7f1702..1f2439c 100644 --- a/src/client/audio.rs +++ b/src/client/audio.rs @@ -1,8 +1,10 @@ -use anyhow::{anyhow, Result}; -use std::fs; +use anyhow::{anyhow, Context, Result}; +use std::process::Command; +use std::{env, io::Write}; +use tempfile::{Builder, NamedTempFile}; pub fn play_audio_from_memory(wav_data: &[u8]) -> Result<()> { - if std::env::var("VOICEVOX_LOW_LATENCY").is_ok() { + if env::var("VOICEVOX_LOW_LATENCY").is_ok() { play_audio_via_rodio(wav_data) } else { play_audio_via_system(wav_data) @@ -41,24 +43,16 @@ fn play_audio_via_rodio(wav_data: &[u8]) -> Result<()> { } fn play_audio_via_system(wav_data: &[u8]) -> Result<()> { - let temp_file = "/tmp/voicevox_say_temp.wav"; - fs::write(temp_file, wav_data)?; + let temp_file = create_temp_wav_file(wav_data)?; + let temp_path = temp_file.path(); - struct TempFileCleanup<'a>(&'a str); - impl Drop for TempFileCleanup<'_> { - fn drop(&mut self) { - let _ = fs::remove_file(self.0); - } - } - let _cleanup = TempFileCleanup(temp_file); - - if let Ok(output) = std::process::Command::new("afplay").arg(temp_file).output() { + if let Ok(output) = Command::new("afplay").arg(temp_path).output() { if output.status.success() { return Ok(()); } } - if let Ok(output) = std::process::Command::new("play").arg(temp_file).output() { + if let Ok(output) = Command::new("play").arg(temp_path).output() { if output.status.success() { return Ok(()); } @@ -68,3 +62,18 @@ fn play_audio_via_system(wav_data: &[u8]) -> Result<()> { "No audio player found. Install sox or use -o to save file" )) } + +pub(crate) fn create_temp_wav_file(wav_data: &[u8]) -> Result { + let mut temp = Builder::new() + .prefix("voicevox_") + .suffix(".wav") + .tempfile() + .context("Failed to create temporary audio file")?; + + temp.write_all(wav_data) + .context("Failed to write temporary audio file")?; + temp.flush() + .context("Failed to flush temporary audio file")?; + + Ok(temp) +} diff --git a/src/daemon/process.rs b/src/daemon/process.rs index 3f1726d..a792242 100644 --- a/src/daemon/process.rs +++ b/src/daemon/process.rs @@ -18,10 +18,7 @@ async fn handle_existing_socket(socket_path: &PathBuf) -> DaemonResult<()> { Ok(_) => { let pid = match find_daemon_processes() { Ok(pids) => pids.first().copied().unwrap_or(0), - Err(e) => { - eprintln!("Warning: Failed to find daemon processes: {}", e); - 0 - } + Err(_) => 0, }; Err(DaemonError::AlreadyRunning { pid }) } diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 31392e0..515032e 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -23,24 +23,11 @@ impl DaemonState { let core = VoicevoxCore::new()?; let style_to_model_map = Arc::new(Mutex::new(HashMap::new())); - println!("Building dynamic style-to-model mapping..."); let (mapping, speakers, models) = - crate::voice::build_style_to_model_map_async_with_progress( - &core, - |current, total, filename| { - println!(" Loading model {} ({}/{})", filename, current, total); - }, - ) - .await?; + crate::voice::build_style_to_model_map_async_with_progress(&core, |_, _, _| {}).await?; *style_to_model_map.lock().await = mapping; let all_speakers = Arc::new(Mutex::new(speakers)); let available_models = Arc::new(Mutex::new(models)); - println!( - "Discovered {} style mappings", - style_to_model_map.lock().await.len() - ); - - println!("Models will be loaded and unloaded per synthesis request."); Ok(DaemonState { core, @@ -80,8 +67,6 @@ impl DaemonState { }; } - println!("Loaded model {model_id} for synthesis"); - let synthesis_result = self.core.synthesize(&text, style_id); let available_models = self.available_models.lock().await; if let Some(model) = available_models.iter().find(|m| m.model_id == model_id) { @@ -98,7 +83,7 @@ impl DaemonState { } }; match self.core.unload_voice_model_by_path(path_str) { - Ok(_) => println!("Unloaded model {model_id} after synthesis"), + Ok(_) => {} Err(e) => eprintln!("Failed to unload model {model_id}: {e}"), } } else { @@ -107,12 +92,9 @@ impl DaemonState { match synthesis_result { Ok(wav_data) => OwnedResponse::SynthesizeResult { wav_data }, - Err(e) => { - eprintln!("Synthesis failed: {e}"); - OwnedResponse::Error { - message: format!("Synthesis failed: {e}"), - } - } + Err(e) => OwnedResponse::Error { + message: format!("Synthesis failed: {e}"), + }, } } @@ -145,8 +127,7 @@ pub async fn handle_client(mut stream: UnixStream, state: Arc bincode::config::standard(), ) { Ok((req, _)) => req, - Err(e) => { - println!("Failed to deserialize request: {e}"); + Err(_) => { break; } }, @@ -165,13 +146,11 @@ pub async fn handle_client(mut stream: UnixStream, state: Arc match bincode::serde::encode_to_vec(&response, bincode::config::standard()) { Ok(response_data) => { - if let Err(e) = framed_writer.send(response_data.into()).await { - println!("Failed to send response: {e}"); + if framed_writer.send(response_data.into()).await.is_err() { break; } } - Err(e) => { - println!("Failed to serialize response: {e}"); + Err(_) => { break; } } @@ -203,18 +182,11 @@ pub async fn run_daemon(socket_path: PathBuf, foreground: bool) -> Result<()> { let server = async { loop { - match listener.accept().await { - Ok((stream, _)) => { - let state_clone = Arc::clone(&state); - tokio::spawn(async move { - if let Err(e) = handle_client(stream, state_clone).await { - println!("Client handler error: {e}"); - } - }); - } - Err(e) => { - println!("Failed to accept connection: {e}"); - } + if let Ok((stream, _)) = listener.accept().await { + let state_clone = Arc::clone(&state); + tokio::spawn(async move { + let _ = handle_client(stream, state_clone).await; + }); } } }; diff --git a/src/mcp/handlers.rs b/src/mcp/handlers.rs deleted file mode 100644 index 7ef5c0c..0000000 --- a/src/mcp/handlers.rs +++ /dev/null @@ -1,277 +0,0 @@ -use anyhow::{anyhow, Context, Result}; -use rodio::Sink; -use serde::Deserialize; -use serde_json::Value; - -use crate::client::{audio::play_audio_from_memory, DaemonClient}; -use crate::mcp::types::{ToolCallResult, ToolContent}; -use crate::synthesis::StreamingSynthesizer; - -const MAX_STYLE_ID: u32 = 1000; - -#[derive(Debug, Deserialize)] -struct SynthesizeParams { - text: String, - style_id: u32, - #[serde(default = "default_rate")] - rate: f32, - #[serde(default = "default_streaming")] - streaming: bool, -} - -fn default_rate() -> f32 { - 1.0 -} - -fn default_streaming() -> bool { - true -} - -#[derive(Debug, Deserialize)] -struct ListVoiceStylesParams { - speaker_name: Option, - style_name: Option, -} - -pub async fn handle_text_to_speech(arguments: Value) -> Result { - let params: SynthesizeParams = - serde_json::from_value(arguments).context("Invalid parameters for text_to_speech")?; - - let text = params.text.trim(); - (!text.is_empty()) - .then_some(()) - .ok_or_else(|| anyhow!("Text cannot be empty"))?; - - const MAX_TEXT_LENGTH: usize = 10_000; - (text.len() <= MAX_TEXT_LENGTH) - .then_some(()) - .ok_or_else(|| { - anyhow!( - "Text too long: {} characters (max: {})", - text.len(), - MAX_TEXT_LENGTH - ) - })?; - - (0.5..=2.0) - .contains(¶ms.rate) - .then_some(()) - .ok_or_else(|| anyhow!("Rate must be between 0.5 and 2.0"))?; - - (params.style_id <= MAX_STYLE_ID) - .then_some(()) - .ok_or_else(|| { - anyhow!( - "Invalid style_id: {} (max: {})", - params.style_id, - MAX_STYLE_ID - ) - })?; - - if params.streaming { - handle_streaming_synthesis(params).await - } else { - handle_daemon_synthesis(params).await - } -} - -async fn handle_streaming_synthesis(params: SynthesizeParams) -> Result { - let stream = rodio::OutputStreamBuilder::open_default_stream() - .context("Failed to create audio output stream")?; - - let sink = Sink::connect_new(stream.mixer()); - - let mut synthesizer = StreamingSynthesizer::new() - .await - .context("Failed to create streaming synthesizer")?; - - synthesizer - .synthesize_streaming(¶ms.text, params.style_id, params.rate, &sink) - .await - .context("Streaming synthesis failed")?; - - sink.sleep_until_end(); - drop(stream); - - Ok(ToolCallResult { - content: vec![ToolContent { - content_type: "text".to_string(), - text: format!( - "Successfully synthesized {} characters using style ID {} in streaming mode", - params.text.len(), - params.style_id - ), - }], - is_error: Some(false), - }) -} - -async fn handle_daemon_synthesis(params: SynthesizeParams) -> Result { - // Try to connect with retries - let mut client = match DaemonClient::connect_with_retry().await { - Ok(client) => client, - Err(e) => { - return Ok(ToolCallResult { - content: vec![ToolContent { - content_type: "text".to_string(), - text: format!("Failed to connect to VOICEVOX daemon: {e}"), - }], - is_error: Some(true), - }); - } - }; - - let options = crate::ipc::OwnedSynthesizeOptions { rate: params.rate }; - - let wav_data = client - .synthesize(¶ms.text, params.style_id, options) - .await - .context("Synthesis failed")?; - - play_audio_from_memory(&wav_data).context("Failed to play audio")?; - - Ok(ToolCallResult { - content: vec![ToolContent { - content_type: "text".to_string(), - text: format!( - "Successfully synthesized {} characters using style ID {} (audio size: {} bytes)", - params.text.len(), - params.style_id, - wav_data.len() - ), - }], - is_error: Some(false), - }) -} - -pub async fn handle_list_voice_styles(arguments: Value) -> Result { - let params: ListVoiceStylesParams = - serde_json::from_value(arguments).context("Invalid parameters for list_voice_styles")?; - - let mut client = DaemonClient::connect_with_retry() - .await - .context("Failed to connect to VOICEVOX daemon after multiple attempts")?; - - let speakers = client.list_speakers().await?; - - let mut filtered_results = Vec::new(); - - for speaker in speakers { - if let Some(name_filter) = ¶ms.speaker_name { - if !speaker - .name - .to_lowercase() - .contains(&name_filter.to_lowercase()) - { - continue; - } - } - - let filtered_styles = if let Some(style_filter) = ¶ms.style_name { - speaker - .styles - .into_iter() - .filter(|style| { - style - .name - .to_lowercase() - .contains(&style_filter.to_lowercase()) - }) - .collect::>() - } else { - speaker.styles.to_vec() - }; - - if !filtered_styles.is_empty() { - filtered_results.push((speaker.name, filtered_styles)); - } - } - - let mut result_text = String::new(); - if filtered_results.is_empty() { - result_text.push_str("No speakers found matching the criteria."); - } else { - for (speaker_name, styles) in &filtered_results { - result_text.push_str(&format!("Speaker: {}\n", speaker_name)); - result_text.push_str("Styles:\n"); - for style in styles { - result_text.push_str(&format!(" - {} (ID: {})\n", style.name, style.id)); - } - result_text.push('\n'); - } - result_text.push_str(&format!("Total speakers found: {}", filtered_results.len())); - } - Ok(ToolCallResult { - content: vec![ToolContent { - content_type: "text".to_string(), - text: result_text.trim().to_string(), - }], - is_error: Some(false), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[tokio::test] - async fn test_text_to_speech_empty_text() { - let args = json!({ - "text": "", - "style_id": 3, - "streaming": false - }); - - let result = handle_text_to_speech(args).await; - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Text cannot be empty")); - } - - #[tokio::test] - async fn test_text_to_speech_text_too_long() { - let long_text = "あ".repeat(10_001); - let args = json!({ - "text": long_text, - "style_id": 3, - "streaming": false - }); - - let result = handle_text_to_speech(args).await; - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Text too long")); - } - - #[tokio::test] - async fn test_text_to_speech_invalid_rate() { - let args = json!({ - "text": "テスト", - "style_id": 3, - "rate": 3.0, - "streaming": false - }); - - let result = handle_text_to_speech(args).await; - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Rate must be between 0.5 and 2.0")); - } - - #[tokio::test] - async fn test_text_to_speech_invalid_style_id() { - let args = json!({ - "text": "テスト", - "style_id": MAX_STYLE_ID + 1, - "streaming": false - }); - - let result = handle_text_to_speech(args).await; - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Invalid style_id")); - } -} diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index 9e72d29..254aca5 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -1,6 +1,6 @@ -pub mod handlers; +pub mod protocol; +pub mod requests; pub mod server; pub mod tools; -pub mod types; pub use server::run_mcp_server; diff --git a/src/mcp/protocol.rs b/src/mcp/protocol.rs new file mode 100644 index 0000000..8484c85 --- /dev/null +++ b/src/mcp/protocol.rs @@ -0,0 +1,496 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::mcp::requests::ActiveRequests; +use crate::mcp::tools::{get_tool_definitions, ToolDefinition}; + +const MCP_VERSION: &str = "2025-06-18"; +const INSTRUCTIONS_ENV_VAR: &str = "VOICEVOX_MCP_INSTRUCTIONS"; +const INSTRUCTIONS_FILE: &str = "VOICEVOX.md"; + +// JSON-RPC 2.0 Protocol Types +#[derive(Debug, Serialize, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + pub method: String, + pub params: Option, + pub id: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub id: Value, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct JsonRpcError { + pub code: i32, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct JsonRpcNotification { + pub jsonrpc: String, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +impl JsonRpcResponse { + pub fn success(id: Value, result: Value) -> Self { + Self { + jsonrpc: "2.0".to_string(), + result: Some(result), + error: None, + id, + } + } + + pub fn error(id: Value, code: i32, message: String) -> Self { + Self { + jsonrpc: "2.0".to_string(), + result: None, + error: Some(JsonRpcError { + code, + message, + data: None, + }), + id, + } + } +} + +// JSON-RPC Error Codes +pub const PARSE_ERROR: i32 = -32700; +pub const INVALID_REQUEST: i32 = -32600; +pub const METHOD_NOT_FOUND: i32 = -32601; +pub const INVALID_PARAMS: i32 = -32602; +pub const INTERNAL_ERROR: i32 = -32603; + +// MCP Protocol Types +#[derive(Debug, Serialize, Deserialize)] +pub struct InitializeResult { + #[serde(rename = "protocolVersion")] + pub protocol_version: String, + #[serde(rename = "serverInfo")] + pub server_info: ServerInfo, + pub capabilities: ServerCapabilities, + #[serde(skip_serializing_if = "Option::is_none")] + pub instructions: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ServerInfo { + pub name: String, + pub version: String, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ServerCapabilities { + pub tools: serde_json::Map, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ToolsListResult { + pub tools: Vec, +} + +/// Parameters for MCP cancellation notifications. +/// +/// This structure represents the parameters sent in a `notifications/cancelled` message +/// according to the MCP specification. It provides Rust type safety for the JSON protocol. +/// +/// ## MCP Protocol Reference +/// +/// See the official MCP cancellation specification: +/// +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum CancelRequestId { + String(String), + Number(i64), +} + +impl CancelRequestId { + fn as_lookup_key(&self) -> String { + match self { + CancelRequestId::String(value) => value.clone(), + CancelRequestId::Number(value) => value.to_string(), + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CancelledParams { + /// The ID of the request to cancel. Must match the `id` field of the original request. + #[serde(rename = "requestId")] + pub request_id: CancelRequestId, + /// Optional human-readable reason for the cancellation. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// Load MCP server instructions from various locations. +/// +/// The instruction loading follows XDG Base Directory compliance with the following priority: +/// +/// 1. Environment variable: `VOICEVOX_MCP_INSTRUCTIONS` (highest priority) +/// 2. XDG user config: `$XDG_CONFIG_HOME/voicevox/VOICEVOX.md` +/// 3. Config fallback: `~/.config/voicevox/VOICEVOX.md` (when XDG_CONFIG_HOME is not set) +/// 4. Executable directory: `VOICEVOX.md` bundled with the binary (distribution default) +/// 5. Current directory: `VOICEVOX.md` in working directory (development use) +fn load_instructions() -> Option { + fn try_load(path: &Path, _description: &str) -> Option { + match fs::read_to_string(path) { + Ok(content) => Some(content), + Err(e) if e.kind() != std::io::ErrorKind::NotFound => None, + _ => None, + } + } + + // 1. Environment variable: VOICEVOX_MCP_INSTRUCTIONS (highest priority) + if let Ok(custom_path) = std::env::var(INSTRUCTIONS_ENV_VAR) { + let path = Path::new(&custom_path); + if let Ok(content) = fs::read_to_string(path) { + return Some(content); + } + } + + // 2. XDG user config: $XDG_CONFIG_HOME/voicevox/VOICEVOX.md (user-specific settings) + let xdg_config_var = std::env::var("XDG_CONFIG_HOME"); + if let Ok(ref xdg_config) = xdg_config_var { + let path = PathBuf::from(xdg_config) + .join("voicevox") + .join(INSTRUCTIONS_FILE); + if let Some(content) = try_load(&path, "XDG_CONFIG_HOME") { + return Some(content); + } + } + + // 3. Config fallback: ~/.config/voicevox/VOICEVOX.md (only when XDG_CONFIG_HOME is not set) + if xdg_config_var.is_err() { + if let Ok(home) = std::env::var("HOME") { + let path = PathBuf::from(home) + .join(".config") + .join("voicevox") + .join(INSTRUCTIONS_FILE); + if let Some(content) = try_load(&path, "~/.config") { + return Some(content); + } + } + } + + // 4. Executable directory: VOICEVOX.md bundled with the binary (distribution default) + if let Ok(exe_path) = std::env::current_exe() { + if let Some(exe_dir) = exe_path.parent() { + let path = exe_dir.join(INSTRUCTIONS_FILE); + if let Some(content) = try_load(&path, "executable directory") { + return Some(content); + } + } + } + + // 5. Current directory: VOICEVOX.md in working directory (development use) + let path = PathBuf::from(INSTRUCTIONS_FILE); + if let Some(content) = try_load(&path, "current directory") { + return Some(content); + } + + None +} + +/// Initialize request processor - MCP session initialization. +/// +/// Establishes the MCP session and returns server capabilities and information. +/// +/// ## MCP Protocol Reference +/// +/// See the official MCP lifecycle specification: +/// +/// +/// ## Parameters +/// +/// - `id`: Request ID for response correlation +/// - `params`: Initialize parameters (protocol version, capabilities, client info) +/// +/// ## Returns +/// +/// InitializeResult with server info, capabilities, and optional instructions +pub async fn process_initialize(id: Value, _params: Option) -> JsonRpcResponse { + let result = InitializeResult { + protocol_version: MCP_VERSION.to_string(), + server_info: ServerInfo { + name: "voicevox-mcp".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + capabilities: ServerCapabilities { + tools: serde_json::Map::new(), + }, + instructions: load_instructions(), + }; + + match serde_json::to_value(result) { + Ok(value) => JsonRpcResponse::success(id, value), + Err(_) => JsonRpcResponse::error( + id, + INTERNAL_ERROR, + "Failed to serialize response".to_string(), + ), + } +} + +/// Tools list request processor - Returns available tools. +/// +/// Returns a list of all tools provided by this MCP server. +/// +/// ## MCP Protocol Reference +/// +/// See the official MCP tools specification: +/// +/// +/// ## Parameters +/// +/// - `id`: Request ID for response correlation +/// - `params`: List parameters (currently unused) +/// +/// ## Returns +/// +/// ToolsListResult containing array of available tool definitions +pub async fn process_tools_list(id: Value, _params: Option) -> JsonRpcResponse { + let result = ToolsListResult { + tools: get_tool_definitions(), + }; + + match serde_json::to_value(result) { + Ok(value) => JsonRpcResponse::success(id, value), + Err(_) => JsonRpcResponse::error( + id, + INTERNAL_ERROR, + "Failed to serialize response".to_string(), + ), + } +} + +/// Tools call request processor - Executes a tool. +/// +/// Spawns an asynchronous task to execute the requested tool and manages +/// cancellation through the active requests system. +/// +/// ## MCP Protocol Reference +/// +/// See the official MCP tools specification: +/// +/// +/// ## Parameters +/// +/// - `id`: Request ID for response correlation and cancellation tracking +/// - `params`: Tool call parameters (name and arguments) +/// - `active_requests`: Request management for cancellation support +/// +/// ## Returns +/// +/// - `None`: No immediate response (async execution) +/// - `Some(ErrorResponse)`: Parameter validation errors +pub async fn process_tools_call( + id: Value, + params: Option, + active_requests: &ActiveRequests, +) -> Option { + let request_id = match &id { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + _ => "unknown".to_string(), + }; + + if let Some(params) = params { + if let Some(params_obj) = params.as_object() { + let tool_name = params_obj + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let arguments = params_obj + .get("arguments") + .cloned() + .unwrap_or(Value::Object(serde_json::Map::new())); + + // Spawn async execution for tool request + active_requests + .spawn_execution(request_id, id.clone(), tool_name, arguments) + .await; + None // No immediate response + } else { + Some(JsonRpcResponse::error( + id, + INVALID_PARAMS, + "Invalid params".to_string(), + )) + } + } else { + Some(JsonRpcResponse::error( + id, + INVALID_PARAMS, + "Missing params".to_string(), + )) + } +} + +/// Request dispatcher - Routes MCP requests to specific processors. +/// +/// Processes JSON-RPC 2.0 requests (messages with `id` field) and returns +/// appropriate responses. Each request type is processed by a dedicated function. +/// +/// ## MCP Protocol Reference +/// +/// See the official MCP specification for request handling: +/// +/// +/// ## Supported Requests +/// +/// - `initialize`: Session initialization +/// - `tools/list`: Tool enumeration +/// - `tools/call`: Tool execution (async) +/// +/// ## Parameters +/// +/// - `request`: JSON-RPC request with id, method, and optional params +/// - `active_requests`: Request management for cancellation support +/// +/// ## Returns +/// +/// - `Some(JsonRpcResponse)`: Immediate response +/// - `None`: Async response (tools/call only) +pub async fn process_request( + request: Value, + active_requests: &ActiveRequests, +) -> Option { + let id = request + .get("id") + .cloned() + .unwrap_or(Value::Number(serde_json::Number::from(0))); + let method = request.get("method").and_then(|v| v.as_str()).unwrap_or(""); + let params = request.get("params").cloned(); + + match method { + "initialize" => Some(process_initialize(id, params).await), + "tools/list" => Some(process_tools_list(id, params).await), + "tools/call" => process_tools_call(id, params, active_requests).await, + _ => Some(JsonRpcResponse::error( + id, + METHOD_NOT_FOUND, + format!("Method not found: {method}"), + )), + } +} + +/// Handles MCP notifications - messages without id that don't expect responses. +/// +/// Dispatches notifications to specific handlers based on the method field. +/// Unknown notifications are silently ignored per MCP specification. +/// +/// ## MCP Protocol Reference +/// +/// See the official MCP notification specification: +/// +/// +/// ## Parameters +/// +/// - `notification`: JSON-RPC notification message without id field +/// - `active_requests`: Request management for cancellation support +pub async fn handle_notification(notification: Value, active_requests: &ActiveRequests) { + let method = notification + .get("method") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let params = notification.get("params").cloned(); + + match method { + "notifications/initialized" => handle_notification_initialized(params).await, + "notifications/cancelled" => handle_notification_cancelled(params, active_requests).await, + _ => { + // Unknown notifications are silently ignored per MCP specification + } + } +} + +/// Initialized notification handler - MCP session confirmation. +/// +/// Called when the client sends a `notifications/initialized` message +/// to confirm that the MCP session is ready for operation. +/// +/// ## MCP Protocol Reference +/// +/// See the official MCP lifecycle specification: +/// +/// +/// ## Parameters +/// +/// - `_params`: Notification parameters (currently unused) +async fn handle_notification_initialized(_params: Option) { + // Currently no action needed for initialized notification + // This serves as a confirmation that the client is ready +} + +/// Cancellation notification handler - MCP request cancellation. +/// +/// Processes `notifications/cancelled` messages from the MCP client to cancel +/// actively running requests. Looks up the request by ID and sends the +/// cancellation signal through the associated oneshot channel. +/// +/// ## MCP Protocol Reference +/// +/// See the official MCP cancellation specification: +/// +/// +/// ## Parameters +/// +/// - `params`: Cancellation parameters containing request ID and optional reason +/// - `active_requests`: Request management for sending cancellation signals +async fn handle_notification_cancelled(params: Option, active_requests: &ActiveRequests) { + if let Some(params) = params { + if let Ok(cancelled_params) = serde_json::from_value::(params) { + let request_id = cancelled_params.request_id.as_lookup_key(); + let cancelled = active_requests + .cancel(&request_id, cancelled_params.reason.clone()) + .await; + let _ = cancelled; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn deserialize_cancelled_params_with_numeric_id() { + let params = json!({ + "requestId": 42, + }); + + let parsed = serde_json::from_value::(params) + .expect("numeric requestId should deserialize"); + assert_eq!(parsed.request_id.as_lookup_key(), "42"); + } + + #[test] + fn deserialize_cancelled_params_with_string_id() { + let params = json!({ + "requestId": "abc", + }); + + let parsed = serde_json::from_value::(params) + .expect("string requestId should deserialize"); + assert_eq!(parsed.request_id.as_lookup_key(), "abc"); + } +} diff --git a/src/mcp/requests.rs b/src/mcp/requests.rs new file mode 100644 index 0000000..9e069a9 --- /dev/null +++ b/src/mcp/requests.rs @@ -0,0 +1,192 @@ +use serde_json::Value; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::{mpsc, oneshot, Mutex}; + +use crate::mcp::protocol::{JsonRpcResponse, INTERNAL_ERROR}; +use crate::mcp::tools::{self, ToolCallResult, ToolContent}; + +/// Manages active requests and their cancellation tokens. +/// +/// This structure implements the server-side cancellation management for MCP requests. +/// It tracks active requests and provides a mechanism to cancel them through oneshot channels. +/// +/// ## MCP Protocol Reference +/// +/// Implements cancellation support as specified in: +/// +/// +/// ## Usage +/// +/// 1. Register a request with `register()` when starting execution +/// 2. Client sends `notifications/cancelled` to cancel the request +/// 3. Call `cancel()` to send cancellation signal to the executing task +/// 4. Call `complete()` to clean up finished requests +#[derive(Debug, Clone)] +pub struct ActiveRequests { + abort_channels: Arc>>>, + response_sender: mpsc::UnboundedSender, +} + +impl ActiveRequests { + pub fn new(response_sender: mpsc::UnboundedSender) -> Self { + Self { + abort_channels: Arc::new(Mutex::new(HashMap::new())), + response_sender, + } + } + + /// Register a new request with its cancellation channel. + /// + /// This should be called when starting execution of an MCP tool call. + /// The `sender` will be used to deliver cancellation signals if the client + /// sends a `notifications/cancelled` message for this request. + /// + /// ## Parameters + /// + /// - `request_id`: The unique identifier from the original MCP request + /// - `sender`: The oneshot channel sender for delivering cancellation signals + pub async fn register(&self, request_id: String, sender: oneshot::Sender) { + self.abort_channels.lock().await.insert(request_id, sender); + } + + /// Cancel a request by sending the cancellation signal. + /// + /// This method is called when a `notifications/cancelled` message is received + /// from the MCP client. It looks up the request by ID and sends the cancellation + /// reason through the associated oneshot channel. + /// + /// ## Parameters + /// + /// - `request_id`: The ID of the request to cancel + /// - `reason`: Optional human-readable cancellation reason + /// + /// ## Returns + /// + /// - `true` if the request was found and cancellation signal was sent + /// - `false` if the request was not found (already completed or invalid ID) + pub async fn cancel(&self, request_id: &str, reason: Option) -> bool { + if let Some(sender) = self.abort_channels.lock().await.remove(request_id) { + let _ = sender.send(reason.unwrap_or_default()); + true + } else { + false + } + } + + /// Remove a completed request from the active list. + /// + /// This should be called when a request completes (either successfully, + /// with an error, or due to cancellation) to clean up resources and + /// prevent memory leaks. + /// + /// ## Parameters + /// + /// - `request_id`: The ID of the completed request + pub async fn complete(&self, request_id: &str) { + self.abort_channels.lock().await.remove(request_id); + } + + /// Cancel all active requests with the provided reason. + /// + /// This method is called when the MCP client disconnects or the server + /// is shutting down. It iterates through all active requests and sends + /// cancellation signals to prevent orphaned audio playback processes. + /// + /// ## Parameters + /// + /// - `reason`: Human-readable reason for mass cancellation + /// + /// ## Returns + /// + /// The number of requests that were cancelled + pub async fn cancel_all_requests(&self, reason: &str) -> usize { + let mut channels = self.abort_channels.lock().await; + let count = channels.len(); + + // Send cancellation signal to all active requests + for (_request_id, sender) in channels.drain() { + let _ = sender.send(reason.to_string()); + } + + count + } + + /// Spawns an asynchronous execution for the requested MCP tool call with cancellation support. + /// + /// Creates a oneshot channel for cancellation signaling, registers the request + /// with the active requests manager, and spawns a blocking task to execute the request. + /// The execution automatically cleans up after completion and sends the response to stdout. + /// + /// ## MCP Protocol Reference + /// + /// Implements asynchronous request execution as specified in: + /// + /// + /// ## Parameters + /// + /// - `request_id`: Unique identifier for request tracking and cancellation + /// - `id`: JSON-RPC request ID for response correlation + /// - `tool_name`: Name of the tool to execute + /// - `arguments`: Tool execution arguments + pub async fn spawn_execution( + &self, + request_id: String, + id: Value, + tool_name: &str, + arguments: Value, + ) { + let (abort_tx, abort_rx) = oneshot::channel::(); + + // Register the cancellation channel + self.register(request_id.clone(), abort_tx).await; + + let tool_name = tool_name.to_string(); + let active_requests = self.clone(); + + let runtime_handle = tokio::runtime::Handle::current(); + + tokio::task::spawn_blocking(move || { + // Use current runtime handle instead of creating a new one + runtime_handle.block_on(async move { + let result = + tools::execute_tool_request(&tool_name, arguments, Some(abort_rx)).await; + + // Clean up the request from active list + active_requests.complete(&request_id).await; + + // Send response + let response = match result { + Ok(tool_result) => match serde_json::to_value(tool_result) { + Ok(value) => JsonRpcResponse::success(id, value), + Err(_) => JsonRpcResponse::error( + id, + INTERNAL_ERROR, + "Failed to serialize response".to_string(), + ), + }, + Err(e) => { + let error_result = ToolCallResult { + content: vec![ToolContent { + content_type: "text".to_string(), + text: format!("Tool execution error: {e}"), + }], + is_error: Some(true), + }; + match serde_json::to_value(error_result) { + Ok(value) => JsonRpcResponse::success(id, value), + Err(_) => JsonRpcResponse::error( + id, + INTERNAL_ERROR, + "Failed to serialize error response".to_string(), + ), + } + } + }; + + // Send response via channel + let _ = active_requests.response_sender.send(response); + }) + }); + } +} diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 55e22a2..5771c32 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -1,99 +1,10 @@ use anyhow::Result; use serde_json::Value; -use std::fs; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::mpsc; -use crate::mcp::handlers; -use crate::mcp::tools::get_tool_definitions; -use crate::mcp::types::*; - -const MCP_VERSION: &str = "2025-03-26"; -const INSTRUCTIONS_ENV_VAR: &str = "VOICEVOX_MCP_INSTRUCTIONS"; -const INSTRUCTIONS_FILE: &str = "VOICEVOX.md"; - -fn load_instructions() -> Option { - use std::path::{Path, PathBuf}; - - fn try_load(path: &Path, description: &str) -> Option { - eprintln!( - "Trying instructions from {}: {}", - description, - path.display() - ); - match fs::read_to_string(path) { - Ok(content) => { - eprintln!("Loaded instructions from: {}", path.display()); - Some(content) - } - Err(e) if e.kind() != std::io::ErrorKind::NotFound => { - eprintln!("Error loading instructions from {}: {}", path.display(), e); - None - } - _ => None, - } - } - - // 1. Environment variable: VOICEVOX_MCP_INSTRUCTIONS (highest priority) - if let Ok(custom_path) = std::env::var(INSTRUCTIONS_ENV_VAR) { - let path = Path::new(&custom_path); - eprintln!( - "Trying instructions from environment variable: {}", - path.display() - ); - match fs::read_to_string(path) { - Ok(content) => { - eprintln!("Loaded instructions from: {}", path.display()); - return Some(content); - } - Err(e) => { - eprintln!("Could not load instructions from {}: {}", path.display(), e); - } - } - } - - // 2. XDG user config: $XDG_CONFIG_HOME/voicevox/VOICEVOX.md (user-specific settings) - let xdg_config_var = std::env::var("XDG_CONFIG_HOME"); - if let Ok(ref xdg_config) = xdg_config_var { - let path = PathBuf::from(xdg_config) - .join("voicevox") - .join(INSTRUCTIONS_FILE); - if let Some(content) = try_load(&path, "XDG_CONFIG_HOME") { - return Some(content); - } - } - - // 3. Config fallback: ~/.config/voicevox/VOICEVOX.md (only when XDG_CONFIG_HOME is not set) - if xdg_config_var.is_err() { - if let Ok(home) = std::env::var("HOME") { - let path = PathBuf::from(home) - .join(".config") - .join("voicevox") - .join(INSTRUCTIONS_FILE); - if let Some(content) = try_load(&path, "~/.config") { - return Some(content); - } - } - } - - // 4. Executable directory: VOICEVOX.md bundled with the binary (distribution default) - if let Ok(exe_path) = std::env::current_exe() { - if let Some(exe_dir) = exe_path.parent() { - let path = exe_dir.join(INSTRUCTIONS_FILE); - if let Some(content) = try_load(&path, "executable directory") { - return Some(content); - } - } - } - - // 5. Current directory: VOICEVOX.md in working directory (development use) - let path = PathBuf::from(INSTRUCTIONS_FILE); - if let Some(content) = try_load(&path, "current directory") { - return Some(content); - } - - eprintln!("No VOICEVOX.md found in any location"); - None -} +use crate::mcp::protocol::{JsonRpcResponse, INVALID_REQUEST, PARSE_ERROR}; +use crate::mcp::requests::ActiveRequests; pub async fn run_mcp_server() -> Result<()> { let stdin = tokio::io::stdin(); @@ -101,214 +12,110 @@ pub async fn run_mcp_server() -> Result<()> { let reader = BufReader::new(stdin); let mut lines = reader.lines(); + // Create response channel for async tool execution + let (response_tx, mut response_rx) = mpsc::unbounded_channel::(); + let active_requests = ActiveRequests::new(response_tx); + let mut shutdown = tokio::spawn(async { let _ = tokio::signal::ctrl_c().await; }); loop { tokio::select! { - line = lines.next_line() => { - match line? { - Some(line) if !line.trim().is_empty() => { - let raw_request: Value = match serde_json::from_str(&line) { - Ok(req) => req, - Err(_) => { - let id = serde_json::from_str::(&line) - .ok() - .and_then(|v| v.get("id").cloned()) - .unwrap_or(Value::Number(serde_json::Number::from(0))); - let error_response = - JsonRpcResponse::error(id, PARSE_ERROR, "Parse error".to_string()); - if let Ok(response_str) = serde_json::to_string(&error_response) { - let _ = stdout.write_all(response_str.as_bytes()).await; - let _ = stdout.write_all(b"\n").await; - let _ = stdout.flush().await; - } - continue; - } - }; - - if raw_request.get("method").is_some() { - if let Some(response) = handle_request(raw_request).await { - if let Ok(response_str) = serde_json::to_string(&response) { - let _ = stdout.write_all(response_str.as_bytes()).await; - let _ = stdout.write_all(b"\n").await; - let _ = stdout.flush().await; - } - } - } else { - let id = raw_request - .get("id") - .cloned() - .unwrap_or(Value::Number(serde_json::Number::from(0))); - let response = - JsonRpcResponse::error(id, INVALID_REQUEST, "Invalid request".to_string()); - if let Ok(response_str) = serde_json::to_string(&response) { - let _ = stdout.write_all(response_str.as_bytes()).await; - let _ = stdout.write_all(b"\n").await; - let _ = stdout.flush().await; - } - } - } - None => break, - _ => continue, + line_result = lines.next_line() => { + if !process_line(line_result?, &active_requests, &mut stdout).await { + // Cancel all active requests when client disconnects + active_requests.cancel_all_requests("Client disconnected").await; + break; } } - _ = &mut shutdown => break, + Some(response) = response_rx.recv() => { + send_response(&response, &mut stdout).await; + } + _ = &mut shutdown => { + active_requests.cancel_all_requests("Server shutdown").await; + break; + } } } Ok(()) } -async fn handle_request(request: Value) -> Option { - let id = request.get("id").cloned(); - let method = request.get("method").and_then(|v| v.as_str()).unwrap_or(""); +async fn process_line( + line_option: Option, + active_requests: &ActiveRequests, + stdout: &mut tokio::io::Stdout, +) -> bool { + let line = match line_option { + Some(line) if !line.trim().is_empty() => line, + Some(_) => return true, // Empty line, continue + None => return false, // EOF, terminate + }; + + let raw_request = match parse_json_request(&line, stdout).await { + Some(request) => request, + None => return true, // Parse error handled, continue + }; + + if raw_request.get("method").is_some() { + handle_message(raw_request, active_requests, stdout).await; + } else { + send_invalid_request_error(&raw_request, stdout).await; + } - match method { - "initialize" => { - let id = id.unwrap_or(Value::Number(serde_json::Number::from(0))); - let result = InitializeResult { - protocol_version: MCP_VERSION.to_string(), - server_info: ServerInfo { - name: "voicevox-mcp".to_string(), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - capabilities: ServerCapabilities { - tools: serde_json::Map::new(), - }, - instructions: load_instructions(), - }; + true +} - match serde_json::to_value(result) { - Ok(value) => Some(JsonRpcResponse::success(id, value)), - Err(_) => Some(JsonRpcResponse::error( - id, - INTERNAL_ERROR, - "Failed to serialize response".to_string(), - )), - } - } - "notifications/initialized" => None, - "tools/list" => { - let id = id.unwrap_or(Value::Number(serde_json::Number::from(0))); - let result = ToolsListResult { - tools: get_tool_definitions(), - }; - match serde_json::to_value(result) { - Ok(value) => Some(JsonRpcResponse::success(id, value)), - Err(_) => Some(JsonRpcResponse::error( - id, - INTERNAL_ERROR, - "Failed to serialize response".to_string(), - )), - } +async fn parse_json_request(line: &str, stdout: &mut tokio::io::Stdout) -> Option { + match serde_json::from_str(line) { + Ok(request) => Some(request), + Err(_) => { + let id = extract_id_from_invalid_json(line); + let error_response = JsonRpcResponse::error(id, PARSE_ERROR, "Parse error".to_string()); + send_response(&error_response, stdout).await; + None } - "tools/call" => { - let id = id.unwrap_or(Value::Number(serde_json::Number::from(0))); - if let Some(params) = request.get("params") { - if let Some(params_obj) = params.as_object() { - let tool_name = params_obj - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or(""); + } +} - let arguments = params_obj - .get("arguments") - .cloned() - .unwrap_or(Value::Object(serde_json::Map::new())); +fn extract_id_from_invalid_json(line: &str) -> Value { + serde_json::from_str::(line) + .ok() + .and_then(|v| v.get("id").cloned()) + .unwrap_or(Value::Number(serde_json::Number::from(0))) +} - match tool_name { - "text_to_speech" => { - match handlers::handle_text_to_speech(arguments).await { - Ok(result) => match serde_json::to_value(result) { - Ok(value) => Some(JsonRpcResponse::success(id.clone(), value)), - Err(_) => Some(JsonRpcResponse::error( - id.clone(), - INTERNAL_ERROR, - "Failed to serialize response".to_string(), - )), - }, - Err(e) => { - let error_result = ToolCallResult { - content: vec![ToolContent { - content_type: "text".to_string(), - text: format!("Synthesis error: {e}"), - }], - is_error: Some(true), - }; - match serde_json::to_value(error_result) { - Ok(value) => { - Some(JsonRpcResponse::success(id.clone(), value)) - } - Err(_) => Some(JsonRpcResponse::error( - id.clone(), - INTERNAL_ERROR, - "Failed to serialize error response".to_string(), - )), - } - } - } - } - "list_voice_styles" => { - match handlers::handle_list_voice_styles(arguments).await { - Ok(result) => match serde_json::to_value(result) { - Ok(value) => Some(JsonRpcResponse::success(id.clone(), value)), - Err(_) => Some(JsonRpcResponse::error( - id.clone(), - INTERNAL_ERROR, - "Failed to serialize response".to_string(), - )), - }, - Err(e) => { - let error_result = ToolCallResult { - content: vec![ToolContent { - content_type: "text".to_string(), - text: format!("Error getting voices: {e}"), - }], - is_error: Some(true), - }; - match serde_json::to_value(error_result) { - Ok(value) => { - Some(JsonRpcResponse::success(id.clone(), value)) - } - Err(_) => Some(JsonRpcResponse::error( - id.clone(), - INTERNAL_ERROR, - "Failed to serialize error response".to_string(), - )), - } - } - } - } - _ => Some(JsonRpcResponse::error( - id.clone(), - METHOD_NOT_FOUND, - format!("Unknown tool: {tool_name}"), - )), - } - } else { - Some(JsonRpcResponse::error( - id.clone(), - INVALID_PARAMS, - "Invalid params".to_string(), - )) - } - } else { - Some(JsonRpcResponse::error( - id.clone(), - INVALID_PARAMS, - "Missing params".to_string(), - )) - } - } - _ => { - let id = id.unwrap_or(Value::Number(serde_json::Number::from(0))); - Some(JsonRpcResponse::error( - id, - METHOD_NOT_FOUND, - format!("Method not found: {method}"), - )) - } +async fn send_invalid_request_error(raw_request: &Value, stdout: &mut tokio::io::Stdout) { + let id = raw_request + .get("id") + .cloned() + .unwrap_or(Value::Number(serde_json::Number::from(0))); + let response = JsonRpcResponse::error(id, INVALID_REQUEST, "Invalid request".to_string()); + send_response(&response, stdout).await; +} + +async fn send_response(response: &JsonRpcResponse, stdout: &mut tokio::io::Stdout) { + if let Ok(response_str) = serde_json::to_string(response) { + let _ = stdout.write_all(response_str.as_bytes()).await; + let _ = stdout.write_all(b"\n").await; + let _ = stdout.flush().await; + } +} + +async fn handle_message( + request: Value, + active_requests: &ActiveRequests, + stdout: &mut tokio::io::Stdout, +) { + // Handle notifications (no response expected) + if request.get("id").is_none() { + crate::mcp::protocol::handle_notification(request, active_requests).await; + return; + } + + // Handle requests (response expected) + if let Some(response) = crate::mcp::protocol::process_request(request, active_requests).await { + send_response(&response, stdout).await; } } diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs index 016688e..dc21e75 100644 --- a/src/mcp/tools.rs +++ b/src/mcp/tools.rs @@ -1,5 +1,48 @@ -use crate::mcp::types::{ToolDefinition, ToolInputSchema}; -use serde_json::json; +use anyhow::{anyhow, Context, Result}; +use rodio::Sink; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{env, path::Path, sync::Arc}; +use tokio::sync::oneshot; + +use crate::client::{ + audio::{create_temp_wav_file, play_audio_from_memory}, + DaemonClient, +}; +use crate::synthesis::StreamingSynthesizer; + +// Tool Definition Types +#[derive(Debug, Serialize, Deserialize)] +pub struct ToolDefinition { + pub name: String, + pub description: String, + #[serde(rename = "inputSchema")] + pub input_schema: ToolInputSchema, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ToolInputSchema { + #[serde(rename = "type")] + pub schema_type: String, + pub properties: serde_json::Map, + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option>, +} + +// Tool Execution Result Types +#[derive(Debug, Serialize, Deserialize)] +pub struct ToolCallResult { + pub content: Vec, + #[serde(rename = "isError", skip_serializing_if = "Option::is_none")] + pub is_error: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ToolContent { + #[serde(rename = "type")] + pub content_type: String, + pub text: String, +} pub fn get_tool_definitions() -> Vec { vec![ @@ -59,3 +102,474 @@ pub fn get_tool_definitions() -> Vec { }, ] } + +/// Executes an MCP tool request with cancellation support. +/// +/// This is the main entry point for tool execution, dispatching requests to +/// the appropriate tool handler based on the tool name. +/// +/// ## Supported Tools +/// +/// - `text_to_speech`: Japanese text-to-speech synthesis with cancellation +/// - `list_voice_styles`: Voice style enumeration (no cancellation needed) +/// +/// ## Parameters +/// +/// - `tool_name`: Name of the tool to execute +/// - `arguments`: Tool execution arguments +/// - `cancel_rx`: Optional cancellation receiver channel +/// +/// ## Returns +/// +/// - `Ok(ToolCallResult)`: Successful tool execution result +/// - `Err(anyhow::Error)`: Tool execution error or unknown tool +pub async fn execute_tool_request( + tool_name: &str, + arguments: Value, + cancel_rx: Option>, +) -> Result { + match tool_name { + "text_to_speech" => handle_text_to_speech_cancellable(arguments, cancel_rx).await, + "list_voice_styles" => handle_list_voice_styles(arguments).await, + _ => Err(anyhow!("Unknown tool: {}", tool_name)), + } +} + +const MAX_STYLE_ID: u32 = 1000; + +#[derive(Debug, Deserialize)] +struct SynthesizeParams { + text: String, + style_id: u32, + #[serde(default = "default_rate")] + rate: f32, + #[serde(default = "default_streaming")] + streaming: bool, +} + +fn default_rate() -> f32 { + 1.0 +} + +fn default_streaming() -> bool { + true +} + +#[derive(Debug, Deserialize)] +struct ListVoiceStylesParams { + speaker_name: Option, + style_name: Option, +} + +pub async fn handle_text_to_speech(arguments: Value) -> Result { + handle_text_to_speech_cancellable(arguments, None).await +} + +pub async fn handle_text_to_speech_cancellable( + arguments: Value, + cancel_rx: Option>, +) -> Result { + let params: SynthesizeParams = + serde_json::from_value(arguments).context("Invalid parameters for text_to_speech")?; + + let text = params.text.trim(); + (!text.is_empty()) + .then_some(()) + .ok_or_else(|| anyhow!("Text cannot be empty"))?; + + const MAX_TEXT_LENGTH: usize = 10_000; + (text.len() <= MAX_TEXT_LENGTH) + .then_some(()) + .ok_or_else(|| { + anyhow!( + "Text too long: {} characters (max: {})", + text.len(), + MAX_TEXT_LENGTH + ) + })?; + + (0.5..=2.0) + .contains(¶ms.rate) + .then_some(()) + .ok_or_else(|| anyhow!("Rate must be between 0.5 and 2.0"))?; + + (params.style_id <= MAX_STYLE_ID) + .then_some(()) + .ok_or_else(|| { + anyhow!( + "Invalid style_id: {} (max: {})", + params.style_id, + MAX_STYLE_ID + ) + })?; + + if params.streaming { + handle_streaming_synthesis_cancellable(params, cancel_rx).await + } else { + handle_daemon_synthesis(params, cancel_rx).await + } +} + +async fn handle_streaming_synthesis_cancellable( + params: SynthesizeParams, + cancel_rx: Option>, +) -> Result { + let stream = rodio::OutputStreamBuilder::open_default_stream() + .context("Failed to create audio output stream")?; + let sink = Arc::new(Sink::connect_new(stream.mixer())); + + let mut synthesizer = StreamingSynthesizer::new() + .await + .context("Failed to create streaming synthesizer")?; + + let text = params.text.clone(); + let sink_clone = Arc::clone(&sink); + + let synthesis_and_playback_fut = async move { + synthesizer + .synthesize_streaming(&text, params.style_id, params.rate, &sink_clone) + .await + .context("Streaming synthesis failed")?; + + let res: Result<(), tokio::task::JoinError> = tokio::task::spawn_blocking(move || { + sink_clone.sleep_until_end(); + }) + .await; + res.context("Audio playback task failed")?; + Ok(()) as Result<()> + }; + + if let Some(mut cancel_rx) = cancel_rx { + tokio::pin!(synthesis_and_playback_fut); + tokio::select! { + res = &mut synthesis_and_playback_fut => { + res?; + } + reason = &mut cancel_rx => { + sink.stop(); + let detail = reason.unwrap_or_default(); + let message = if detail.is_empty() { + "Audio playback cancelled by client".to_string() + } else { + format!("Audio playback cancelled: {detail}") + }; + return Ok(ToolCallResult { + content: vec![ToolContent { + content_type: "text".to_string(), + text: message, + }], + is_error: Some(true), + }); + } + } + } else { + synthesis_and_playback_fut.await?; + } + + Ok(ToolCallResult { + content: vec![ToolContent { + content_type: "text".to_string(), + text: format!( + "Successfully synthesized {} characters using style ID {} in streaming mode", + params.text.len(), + params.style_id + ), + }], + is_error: Some(false), + }) +} + +async fn handle_daemon_synthesis( + params: SynthesizeParams, + cancel_rx: Option>, +) -> Result { + // Try to connect with retries + let mut client = match DaemonClient::connect_with_retry().await { + Ok(client) => client, + Err(e) => { + return Ok(ToolCallResult { + content: vec![ToolContent { + content_type: "text".to_string(), + text: format!("Failed to connect to VOICEVOX daemon: {e}"), + }], + is_error: Some(true), + }); + } + }; + + let options = crate::ipc::OwnedSynthesizeOptions { rate: params.rate }; + + let wav_data = client + .synthesize(¶ms.text, params.style_id, options) + .await + .context("Synthesis failed")?; + + let audio_size = wav_data.len(); + let text_len = params.text.len(); + let style_id = params.style_id; + + match play_daemon_audio_with_cancellation(wav_data, cancel_rx).await? { + PlaybackOutcome::Completed => Ok(ToolCallResult { + content: vec![ToolContent { + content_type: "text".to_string(), + text: format!( + "Successfully synthesized {text_len} characters using style ID {style_id} (audio size: {audio_size} bytes)" + ), + }], + is_error: Some(false), + }), + PlaybackOutcome::Cancelled(reason) => { + let message = if reason.is_empty() { + "Audio playback cancelled by client".to_string() + } else { + format!("Audio playback cancelled: {reason}") + }; + + Ok(ToolCallResult { + content: vec![ToolContent { + content_type: "text".to_string(), + text: message, + }], + is_error: Some(true), + }) + } + } +} + +enum PlaybackOutcome { + Completed, + Cancelled(String), +} + +async fn play_daemon_audio_with_cancellation( + wav_data: Vec, + cancel_rx: Option>, +) -> Result { + if let Some(mut cancel_rx) = cancel_rx { + if env::var("VOICEVOX_LOW_LATENCY").is_ok() { + play_low_latency_with_cancel(wav_data, &mut cancel_rx).await + } else { + play_system_player_with_cancel(&wav_data, &mut cancel_rx).await + } + } else { + play_audio_from_memory(&wav_data).context("Failed to play audio")?; + Ok(PlaybackOutcome::Completed) + } +} + +async fn play_low_latency_with_cancel( + wav_data: Vec, + cancel_rx: &mut oneshot::Receiver, +) -> Result { + let stream = rodio::OutputStreamBuilder::open_default_stream() + .context("Failed to create audio output stream")?; + let sink = Arc::new(Sink::connect_new(stream.mixer())); + let _stream_guard = stream; + + let cursor = std::io::Cursor::new(wav_data); + let source = rodio::Decoder::new(cursor).context("Failed to decode audio")?; + sink.append(source); + sink.play(); + + let playback_task = tokio::task::spawn_blocking({ + let sink_for_task = Arc::clone(&sink); + move || -> Result<()> { + sink_for_task.sleep_until_end(); + Ok(()) + } + }); + tokio::pin!(playback_task); + + tokio::select! { + res = &mut playback_task => { + res.context("Audio playback task failed")??; + Ok(PlaybackOutcome::Completed) + } + reason = cancel_rx => { + let reason = reason.unwrap_or_default(); + sink.stop(); + let _ = playback_task.await; + Ok(PlaybackOutcome::Cancelled(reason)) + } + } +} + +async fn play_system_player_with_cancel( + wav_data: &[u8], + cancel_rx: &mut oneshot::Receiver, +) -> Result { + // Hold the temp file open so external players can read it. + let temp_file = create_temp_wav_file(wav_data)?; + let temp_path = temp_file.path().to_owned(); + + if let Some(outcome) = run_player_with_cancel("afplay", &temp_path, cancel_rx).await? { + return Ok(outcome); + } + + if let Some(outcome) = run_player_with_cancel("play", &temp_path, cancel_rx).await? { + return Ok(outcome); + } + + Err(anyhow!( + "No audio player found. Install sox or use -o to save file" + )) +} +async fn run_player_with_cancel( + command: &str, + temp_path: &Path, + cancel_rx: &mut oneshot::Receiver, +) -> Result> { + let mut child = match tokio::process::Command::new(command).arg(temp_path).spawn() { + Ok(child) => child, + Err(_) => return Ok(None), + }; + + tokio::select! { + status = child.wait() => { + let status = status.with_context(|| format!("Failed to wait for {command}"))?; + if status.success() { + Ok(Some(PlaybackOutcome::Completed)) + } else { + Ok(None) + } + } + reason = cancel_rx => { + let reason = reason.unwrap_or_default(); + let _ = child.kill().await; + let _ = child.wait().await; + Ok(Some(PlaybackOutcome::Cancelled(reason))) + } + } +} + +pub async fn handle_list_voice_styles(arguments: Value) -> Result { + let params: ListVoiceStylesParams = + serde_json::from_value(arguments).context("Invalid parameters for list_voice_styles")?; + + let mut client = DaemonClient::connect_with_retry() + .await + .context("Failed to connect to VOICEVOX daemon after multiple attempts")?; + + let speakers = client.list_speakers().await?; + + let mut filtered_results = Vec::new(); + + for speaker in speakers { + if let Some(name_filter) = ¶ms.speaker_name { + if !speaker + .name + .to_lowercase() + .contains(&name_filter.to_lowercase()) + { + continue; + } + } + + let filtered_styles = if let Some(style_filter) = ¶ms.style_name { + speaker + .styles + .into_iter() + .filter(|style| { + style + .name + .to_lowercase() + .contains(&style_filter.to_lowercase()) + }) + .collect::>() + } else { + speaker.styles.to_vec() + }; + + if !filtered_styles.is_empty() { + filtered_results.push((speaker.name, filtered_styles)); + } + } + + let mut result_text = String::new(); + if filtered_results.is_empty() { + result_text.push_str("No speakers found matching the criteria."); + } else { + for (speaker_name, styles) in &filtered_results { + result_text.push_str(&format!("Speaker: {}\n", speaker_name)); + result_text.push_str("Styles:\n"); + for style in styles { + result_text.push_str(&format!(" - {} (ID: {})\n", style.name, style.id)); + } + result_text.push('\n'); + } + result_text.push_str(&format!("Total speakers found: {}", filtered_results.len())); + } + Ok(ToolCallResult { + content: vec![ToolContent { + content_type: "text".to_string(), + text: result_text.trim().to_string(), + }], + is_error: Some(false), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[tokio::test] + async fn test_text_to_speech_empty_text() { + let args = json!({ + "text": "", + "style_id": 3, + "streaming": false + }); + + let result = handle_text_to_speech(args).await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Text cannot be empty")); + } + + #[tokio::test] + async fn test_text_to_speech_text_too_long() { + let long_text = "あ".repeat(10_001); + let args = json!({ + "text": long_text, + "style_id": 3, + "streaming": false + }); + + let result = handle_text_to_speech(args).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Text too long")); + } + + #[tokio::test] + async fn test_text_to_speech_invalid_rate() { + let args = json!({ + "text": "テスト", + "style_id": 3, + "rate": 3.0, + "streaming": false + }); + + let result = handle_text_to_speech(args).await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Rate must be between 0.5 and 2.0")); + } + + #[tokio::test] + async fn test_text_to_speech_invalid_style_id() { + let args = json!({ + "text": "テスト", + "style_id": MAX_STYLE_ID + 1, + "streaming": false + }); + + let result = handle_text_to_speech(args).await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Invalid style_id")); + } +} diff --git a/src/mcp/types.rs b/src/mcp/types.rs deleted file mode 100644 index b0d2c4c..0000000 --- a/src/mcp/types.rs +++ /dev/null @@ -1,124 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Debug, Serialize, Deserialize)] -pub struct JsonRpcRequest { - pub jsonrpc: String, - pub method: String, - pub params: Option, - pub id: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct JsonRpcResponse { - pub jsonrpc: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub result: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - pub id: Value, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct JsonRpcError { - pub code: i32, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct JsonRpcNotification { - pub jsonrpc: String, - pub method: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub params: Option, -} - -impl JsonRpcResponse { - pub fn success(id: Value, result: Value) -> Self { - Self { - jsonrpc: "2.0".to_string(), - result: Some(result), - error: None, - id, - } - } - - pub fn error(id: Value, code: i32, message: String) -> Self { - Self { - jsonrpc: "2.0".to_string(), - result: None, - error: Some(JsonRpcError { - code, - message, - data: None, - }), - id, - } - } -} - -pub const PARSE_ERROR: i32 = -32700; -pub const INVALID_REQUEST: i32 = -32600; -pub const METHOD_NOT_FOUND: i32 = -32601; -pub const INVALID_PARAMS: i32 = -32602; -pub const INTERNAL_ERROR: i32 = -32603; - -#[derive(Debug, Serialize, Deserialize)] -pub struct InitializeResult { - #[serde(rename = "protocolVersion")] - pub protocol_version: String, - #[serde(rename = "serverInfo")] - pub server_info: ServerInfo, - pub capabilities: ServerCapabilities, - #[serde(skip_serializing_if = "Option::is_none")] - pub instructions: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ServerInfo { - pub name: String, - pub version: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ServerCapabilities { - pub tools: serde_json::Map, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ToolsListResult { - pub tools: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ToolDefinition { - pub name: String, - pub description: String, - #[serde(rename = "inputSchema")] - pub input_schema: ToolInputSchema, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ToolInputSchema { - #[serde(rename = "type")] - pub schema_type: String, - pub properties: serde_json::Map, - #[serde(skip_serializing_if = "Option::is_none")] - pub required: Option>, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ToolCallResult { - pub content: Vec, - #[serde(rename = "isError", skip_serializing_if = "Option::is_none")] - pub is_error: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct ToolContent { - #[serde(rename = "type")] - pub content_type: String, - pub text: String, -} diff --git a/src/paths.rs b/src/paths.rs index 25ca50f..c229d31 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -270,17 +270,13 @@ pub fn find_onnxruntime() -> Result { } } } else { - let expected_patterns = if cfg!(target_os = "macos") { + let _expected_patterns = if cfg!(target_os = "macos") { "libonnxruntime.dylib or libvoicevox_onnxruntime.*.dylib" } else if cfg!(target_os = "linux") { "libonnxruntime.so or libvoicevox_onnxruntime.*.so" } else { "onnxruntime.dll, libonnxruntime.dll, or libvoicevox_onnxruntime.*.dll" }; - eprintln!( - "Warning: ORT_DYLIB_PATH points to unexpected filename: {}. Expected: {}", - filename_str, expected_patterns - ); } } } diff --git a/src/voice.rs b/src/voice.rs index 2fd48f8..a81a40d 100644 --- a/src/voice.rs +++ b/src/voice.rs @@ -338,8 +338,7 @@ where progress_callback(index + 1, total_models, model_filename); - if let Err(e) = core.load_specific_model(&model_id.to_string()) { - eprintln!("Failed to load model {model_id} for mapping: {e}"); + if core.load_specific_model(&model_id.to_string()).is_err() { continue; } @@ -349,13 +348,10 @@ where let path_str = match path.to_str() { Some(s) => s, None => { - eprintln!("Model path contains invalid UTF-8: {:?}", path); continue; } }; - if let Err(e) = core.unload_voice_model_by_path(path_str) { - eprintln!("Failed to unload model {model_id} after error: {e}"); - } + let _ = core.unload_voice_model_by_path(path_str); continue; } }; @@ -373,13 +369,10 @@ where let path_str = match path.to_str() { Some(s) => s, None => { - eprintln!("Model path contains invalid UTF-8: {:?}", path); continue; } }; - if let Err(e) = core.unload_voice_model_by_path(path_str) { - eprintln!("Failed to unload model {model_id} after mapping: {e}"); - } + let _ = core.unload_voice_model_by_path(path_str); } let mut all_speakers = Vec::new(); @@ -394,9 +387,7 @@ where None => continue, }; - if let Err(e) = core.load_specific_model(&model_id.to_string()) { - eprintln!("Failed to reload model {model_id} for speakers: {e}"); - } + let _ = core.load_specific_model(&model_id.to_string()); } if let Ok(speakers) = core.get_speakers() { @@ -407,13 +398,10 @@ where let path_str = match path.to_str() { Some(s) => s, None => { - eprintln!("Model path contains invalid UTF-8: {:?}", path); continue; } }; - if let Err(e) = core.unload_voice_model_by_path(path_str) { - eprintln!("Failed to unload model after speaker collection: {e}"); - } + let _ = core.unload_voice_model_by_path(path_str); } // Build available models list using existing scan function From 7b01677d3e3d619908ecae07d143bee9840259e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 02:24:23 +0000 Subject: [PATCH 11/14] deps(deps): bump the patch-updates group with 6 updates Bumps the patch-updates group with 6 updates: | Package | From | To | | --- | --- | --- | | [anyhow](https://github.com/dtolnay/anyhow) | `1.0.99` | `1.0.100` | | [thiserror](https://github.com/dtolnay/thiserror) | `2.0.16` | `2.0.17` | | [clap](https://github.com/clap-rs/clap) | `4.5.46` | `4.5.48` | | [serde](https://github.com/serde-rs/serde) | `1.0.219` | `1.0.228` | | [serde_json](https://github.com/serde-rs/json) | `1.0.143` | `1.0.145` | | [libc](https://github.com/rust-lang/libc) | `0.2.175` | `0.2.176` | Updates `anyhow` from 1.0.99 to 1.0.100 - [Release notes](https://github.com/dtolnay/anyhow/releases) - [Commits](https://github.com/dtolnay/anyhow/compare/1.0.99...1.0.100) Updates `thiserror` from 2.0.16 to 2.0.17 - [Release notes](https://github.com/dtolnay/thiserror/releases) - [Commits](https://github.com/dtolnay/thiserror/compare/2.0.16...2.0.17) Updates `clap` from 4.5.46 to 4.5.48 - [Release notes](https://github.com/clap-rs/clap/releases) - [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md) - [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.46...clap_complete-v4.5.48) Updates `serde` from 1.0.219 to 1.0.228 - [Release notes](https://github.com/serde-rs/serde/releases) - [Commits](https://github.com/serde-rs/serde/compare/v1.0.219...v1.0.228) Updates `serde_json` from 1.0.143 to 1.0.145 - [Release notes](https://github.com/serde-rs/json/releases) - [Commits](https://github.com/serde-rs/json/compare/v1.0.143...v1.0.145) Updates `libc` from 0.2.175 to 0.2.176 - [Release notes](https://github.com/rust-lang/libc/releases) - [Changelog](https://github.com/rust-lang/libc/blob/0.2.176/CHANGELOG.md) - [Commits](https://github.com/rust-lang/libc/compare/0.2.175...0.2.176) --- updated-dependencies: - dependency-name: anyhow dependency-version: 1.0.100 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch-updates - dependency-name: thiserror dependency-version: 2.0.17 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch-updates - dependency-name: clap dependency-version: 4.5.48 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch-updates - dependency-name: serde dependency-version: 1.0.228 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch-updates - dependency-name: serde_json dependency-version: 1.0.145 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch-updates - dependency-name: libc dependency-version: 0.2.176 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: patch-updates ... Signed-off-by: dependabot[bot] --- Cargo.lock | 57 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 78eea02..eb2425e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,9 +121,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "arrayvec" @@ -405,9 +405,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.46" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c5e4fcf9c21d2e544ca1ee9d8552de13019a42aa7dbf32747fa7aaf1df76e57" +checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" dependencies = [ "clap_builder", "clap_derive", @@ -415,9 +415,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.46" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecb53a0e6fcfb055f686001bc2e2592fa527efaf38dbe81a6a9563562e57d41" +checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" dependencies = [ "anstream", "anstyle", @@ -430,9 +430,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.45" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -1374,9 +1374,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.175" +version = "0.2.176" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" [[package]] name = "libloading" @@ -2014,7 +2014,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -2193,18 +2193,28 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -2213,15 +2223,16 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "indexmap 2.9.0", "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -2645,11 +2656,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.16", + "thiserror-impl 2.0.17", ] [[package]] @@ -2665,9 +2676,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", @@ -2961,7 +2972,7 @@ dependencies = [ "serde_json", "smallvec", "tempfile", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tokio-util", "voicevox_core", From cc89f75512ff7339ef02c56f275df56e2a041dee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 02:25:20 +0000 Subject: [PATCH 12/14] deps(deps): bump tempfile in the minor-updates group Bumps the minor-updates group with 1 update: [tempfile](https://github.com/Stebalien/tempfile). Updates `tempfile` from 3.20.0 to 3.23.0 - [Changelog](https://github.com/Stebalien/tempfile/blob/master/CHANGELOG.md) - [Commits](https://github.com/Stebalien/tempfile/compare/v3.20.0...v3.23.0) --- updated-dependencies: - dependency-name: tempfile dependency-version: 3.23.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: minor-updates ... Signed-off-by: dependabot[bot] --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 78eea02..8ed2b6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2613,9 +2613,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.20.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", "getrandom 0.3.3", diff --git a/Cargo.toml b/Cargo.toml index 29535d5..b44de09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" bincode = { version = "2.0", features = ["serde"] } dirs = "6.0" -tempfile = "3.10" +tempfile = "3.23" # MCP Server dependencies jsonrpc-lite = "0.6" From a103294095ff48cdcebac5faf842c7ae57865525 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 02:30:25 +0000 Subject: [PATCH 13/14] ci(deps): bump cachix/install-nix-action from 31.6.0 to 31.7.0 Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 31.6.0 to 31.7.0. - [Release notes](https://github.com/cachix/install-nix-action/releases) - [Changelog](https://github.com/cachix/install-nix-action/blob/master/RELEASE.md) - [Commits](https://github.com/cachix/install-nix-action/compare/56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8...9280e7aca88deada44c930f1e2c78e21c3ae3edd) --- updated-dependencies: - dependency-name: cachix/install-nix-action dependency-version: 31.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/update-flake.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5eae6cd..e2c0f20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Install Nix with cache - uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0 + uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 with: nix_path: nixpkgs=channel:nixos-unstable github_access_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58e4ed4..3dc45fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,7 @@ jobs: git push origin "${TAG_NAME}" - name: Install Nix - uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0 + uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 - name: Setup Cachix uses: cachix/cachix-action@0fc020193b5a1fa3ac4575aa3a7d3aa6a35435ad # v16 diff --git a/.github/workflows/update-flake.yml b/.github/workflows/update-flake.yml index 0e32cd5..4731bcd 100644 --- a/.github/workflows/update-flake.yml +++ b/.github/workflows/update-flake.yml @@ -29,7 +29,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Install Nix - uses: cachix/install-nix-action@56a7bb7b56d9a92d4fd1bc05758de7eea4a370a8 # v31.6.0 + uses: cachix/install-nix-action@9280e7aca88deada44c930f1e2c78e21c3ae3edd # v31.7.0 with: nix_path: nixpkgs=channel:nixos-unstable From a5e651c13be6c34a6f6d1aa95809c905bd7ae45a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 02:30:44 +0000 Subject: [PATCH 14/14] ci(deps): bump softprops/action-gh-release from 2.3.2 to 2.3.3 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.3.2 to 2.3.3. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/72f2c25fcb47643c292f7107632f7a47c1df5cd8...6cbd405e2c4e67a21c47fa9e383d020e4e28b836) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 2.3.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58e4ed4..3d0a176 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,7 +80,7 @@ jobs: echo "ARCHIVE_NAME=${ARCHIVE_NAME}" >> $GITHUB_ENV - name: Create GitHub Release - uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2 + uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # v2.3.3 with: tag_name: ${{ steps.create_tag.outputs.TAG_NAME }} name: VOICEVOX CLI ${{ steps.create_tag.outputs.TAG_NAME }}