OpenRouter Voice MCP
# OpenRouter Voice MCP
A small MCP server (Python + FastMCP, stdio) that turns text into **local audio
files** using OpenRouter speech models. Default model:
```
fish-audio/s2.1-pro-free:free
```
Built for Vietnamese video voiceover: Codex or Claude Code writes a narration
script, calls `render_voiceover()`, and gets back an absolute path to an MP3 it
can hand straight to FFmpeg.
```
Codex / Claude Code
| MCP stdio
OpenRouter Voice MCP
| HTTPS
OpenRouter -> fish-audio/s2.1-pro-free:free
|
MP3 bytes -> local file -> FFmpeg / video pipeline
```
**No** PyTorch, CUDA, local model downloads, local LLM, or local HTTP port. Just
Python, three pure-Python packages and an OpenRouter API key.
---
## Install
Requirements: **Python >= 3.10** on PATH, plus **ffmpeg** if you want
`render_long_voiceover()` to concatenate segments. Get a free API key at
<https://openrouter.ai/keys>.
One command does everything -- venv, dependencies, `.env`, acceptance tests, and
registration with both Claude Code and Codex:
```powershell
git clone https://github.com/Trandu1/mcp_voice.git D:\VoiceAI\openrouter-voice-mcp
cd D:\VoiceAI\openrouter-voice-mcp
.\install.ps1 -ApiKey "sk-or-v1-..." -Register
```
Manual equivalent, if you would rather see each step:
```powershell
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
copy .env.example .env # then set OPENROUTER_API_KEY=sk-or-v1-...
.\.venv\Scripts\python.exe tests\acceptance.py
```
On macOS / Linux there is no `install.ps1`; use the manual steps with
`python3 -m venv .venv` and `.venv/bin/python`, then register as shown below.
### Register with Claude Code
```powershell
claude mcp add openrouter-voice --scope user -- `
D:\VoiceAI\openrouter-voice-mcp\.venv\Scripts\python.exe `
D:\VoiceAI\openrouter-voice-mcp\server.py
claude mcp get openrouter-voice # expect: Connected
```
### Register with Codex
```powershell
codex mcp add openrouter-voice -- `
D:\VoiceAI\openrouter-voice-mcp\.venv\Scripts\python.exe `
D:\VoiceAI\openrouter-voice-mcp\server.py
codex mcp list # expect: openrouter-voice
```
The API key is read from `.env` next to `server.py`, so it never appears on a
command line or in either CLI's config file. You can also export
`OPENROUTER_API_KEY` in the environment instead — an exported value wins over
`.env`.
---
## Tools
| Tool | What it does |
| --- | --- |
| `health()` | Config + key status. Free auth probe only, never renders audio. |
| `render_voiceover(...)` | **The main tool.** Text -> local audio file. |
| `render_long_voiceover(...)` | Splits a long script into segments, renders each, concatenates with FFmpeg when available. |
| `preview_voice(text, voice)` | Short sample, written to `<output_dir>/previews` and opened in the default player. |
| `list_speech_models()` | Every OpenRouter model with `output_modalities: speech` (id, name, pricing). |
| `model_info(model)` | Live provider / tier / pricing / voice-cloning support for one model. |
### `render_voiceover`
```python
render_voiceover(
text: str,
output_path: str = "", # absolute or relative; parents are created
voice: str = "", # empty = model default (correct for Fish Audio)
response_format: str = "", # "mp3" (default) or "pcm"
instructions: str = "", # only sent to providers that document it
overwrite: bool = False, # False never clobbers an existing file
reference_audio_path: str = "", # optional stateless voice cloning
reference_text: str = "",
)
```
Returns:
```json
{
"status": "ok",
"model": "fish-audio/s2.1-pro-free:free",
"audio_path": "D:\\campaigns\\abc\\audio\\narration.mp3",
"format": "mp3",
"content_type": "audio/mpeg",
"bytes": 123456,
"elapsed_seconds": 2.31,
"duration_seconds": 12.4,
"generation_id": "gen-..."
}
```
Audio bytes are written to disk and **never** returned base64-encoded through
MCP — the point is a real file for FFmpeg.
---
## Configuration
All settings are environment variables (see `.env.example`):
| Variable | Default | Notes |
| --- | --- | --- |
| `OPENROUTER_API_KEY` | — | Required. Never logged or returned. |
| `OPENROUTER_VOICE_MODEL` | `fish-audio/s2.1-pro-free:free` | |
| `OPENROUTER_VOICE` | empty | Fish Audio documents no preset voice ids; leave empty. |
| `OPENROUTER_AUDIO_FORMAT` | `mp3` | `mp3` or `pcm`. |
| `OPENROUTER_TIMEOUT_SECONDS` | `120` | |
| `OPENROUTER_HTTP_REFERER` | empty | Sent only when set. |
| `OPENROUTER_APP_TITLE` | `OpenRouter Voice MCP` | Sent as `X-OpenRouter-Title`. |
| `VOICE_OUTPUT_DIR` | `%USERPROFILE%\OpenRouterVoice\output` | Used when the caller passes no `output_path`. |
| `OPENROUTER_VOICE_FALLBACK_MODEL` | empty | **Leave empty.** Only set it if you accept being billed for a paid model when the free one is down. |
---
## What the API actually supports
Verified against the live OpenRouter Speech API and Models API (2026-08-25),
not inferred from the older OpenAI TTS API:
- Endpoint `POST https://openrouter.ai/api/v1/audio/speech` returns a **raw
audio byte stream**. Only non-200 responses carry JSON.
- Top-level fields: `model`, `input`, `voice`, `response_format`, `speed`,
`input_references`, `provider`.
- `response_format` is `mp3` or `pcm`. **The API defaults to `pcm`**, so this
server always sends the format explicitly.
- `instructions` is *not* a top-level field. It is an OpenAI provider option
(`provider.options.openai.instructions`). Fish Audio documents no provider
options, so `instructions` is dropped for Fish models and reported back in
`warnings` — no invented fields are ever sent.
- `speed` is only honoured by some providers (OpenAI, Azure); it is dropped
elsewhere rather than silently ignored server-side.
- Fish Audio has **no preset voice ids** (`alloy` / `nova` / `shimmer` belong to
OpenAI). Leave `voice` empty.
- Voice cloning **is** available: the endpoints API reports
`supports_voice_cloning: true` for `fish-audio/s2.1-pro-free:free`. It is
*stateless* — you pass a base64 audio sample in `input_references` on every
request. There is no persistent `voice_id` to create, so this server has no
`clone_voice` tool; use `reference_audio_path` on `render_voiceover` instead.
- Attribution headers are `HTTP-Referer` and `X-OpenRouter-Title`.
### Free-model limits
`fish-audio/s2.1-pro-free:free` is a free variant:
- 20 requests/minute, 50 requests/day (1000/day once ≥ $10 of credit has been
purchased on the account).
- Availability, queueing and latency are not guaranteed.
- When the free model is unavailable the server returns a clear error. It
**never** switches to a paid model unless you explicitly set
`OPENROUTER_VOICE_FALLBACK_MODEL`.
Transient failures (408, 429, 5xx, network errors) are retried twice with short
exponential backoff. 400/401/403 are never retried.
---
## Tests
```powershell
.\.venv\Scripts\python.exe -m pytest tests -q --asyncio-mode=auto # unit, mocked HTTP
.\.venv\Scripts\python.exe tests\smoke_test.py # live, needs a key
.\.venv\Scripts\python.exe tests\acceptance.py # full checklist
```
`smoke_test.py` and the live half of `acceptance.py` skip cleanly without a key.
A skip is reported as SKIP, never as PASS.
---
## Security
- The API key lives in `.env` (git-ignored) or the environment. It is never
logged, never written to a command line, and never returned through MCP.
- `health()` and `model_info()` return configuration, never credentials.
- The server speaks stdio only and binds no TCP port.
- It executes no shell commands from tool input. FFmpeg/ffprobe are invoked only
on files this server just wrote, and only when present.
- File writes go exactly where the caller asks (Codex needs to write into
arbitrary campaign directories), but directories, invalid Windows filenames and
reserved device names are rejected, and `overwrite=False` never clobbers.
## License
MIT
TDQS
Scored across 6 tools
Each tool has a clearly distinct purpose: health for configuration status, preview_voice for a sample, render_voiceover for standard text-to-speech, render_long_voiceover for long scripts with splitting, list_speech_models for enumerating models, and model_info for details on a specific model. No overlaps in functionality.
Most tools follow a verb_noun pattern (preview_voice, render_voiceover, list_speech_models), but 'health' and 'model_info' are arguably nouns instead of commands. Minor inconsistency in verb usage, but overall naming is clear and predictable.
With 6 tools, the server is well-scoped for a text-to-speech utility. Each tool addresses a distinct need—status, preview, rendering, long rendering, model discovery, and model details—without unnecessary extras.
The tool surface covers the full lifecycle of using OpenRouter's TTS: checking configuration, previewing voices, rendering short and long audio, listing available models, and querying model attributes. No obvious dead ends or critical missing operations.