Files
VoiceStudio/docs/mcp.md
T
Palash DebnathandClaude Fable 5 99357e8c5b feat(mcp): MCP server v1 — mount on /mcp, per-agent voice binding, stdio shim (Wave 2.2) (#368)
* feat(mcp): MCP server v1 — mount on /mcp, per-agent voice binding, stdio shim (Wave 2.2)

The FastMCP server (previously dead code, never mounted) is now mounted on
the main FastAPI app at /mcp via Streamable HTTP, with its session manager
composed into the app lifespan through an AsyncExitStack (best-effort: a
missing mcp package or OMNIVOICE_MCP_DISABLE=1 never breaks startup).
streamable_http_path set to '/' so the sub-mount lands at /mcp, not
/mcp/mcp. Adds the 'mcp' dependency (1.27.x).

Per-agent voice binding (Spec 2 headline): each MCP client sends an
X-OmniVoice-Client-Id header; generate_speech resolves the voice as
explicit arg > the client's binding > global default > app default. New
mcp_client_bindings table (alembic 0004 + _BASE_SCHEMA, additive/idempotent),
services/mcp_bindings.py (CRUD + resolve_voice + best-effort last_seen),
and a loopback-gated REST router (/api/mcp/bindings) the Settings panel
drives.

New transcribe tool (base64 audio in, 200 MB cap). Stdio shim
(backend/mcp_shim, httpx-only, ported from voicebox MIT) proxies stdio
clients to the mounted endpoint and forwards OMNIVOICE_CLIENT_ID as the
binding header. Settings → Sharing gains an MCP bindings panel. Docs:
docs/mcp.md (both connection modes + binding REST) and docs/mcp.json
updated to the shim form.

Tests: bindings service + resolution precedence + migration up/down (pure,
run locally); REST CRUD + mount-not-404 + disable-flag (main-importing,
validated in CI). MCP build + mount + initialize handshake verified
out-of-band (no torch).

Spec: docs/competitive-analysis.md Spec 2 / parity program Wave 2.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mcp): assert /mcp mount via app.routes, not a lifespan client

The two main-importing mount tests ran the app lifespan, which now starts
the FastMCP session manager and binds asyncio queues to the test loop —
contaminating later lifespan-running tests ('bound to a different event
loop'). The mount happens at import time, so inspecting app.routes for the
/mcp Mount is the correct loop-free assertion. Same fix shape as the
Wave 0.2 consent tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(mcp): stop reload-main poisoning across the MCP test files

Root cause of the CI failure: the bindings REST fixture set
OMNIVOICE_MCP_DISABLE=1 and reloaded main but never restored it, so a
later 'from main import app' in test_mcp_mount saw /mcp un-mounted
({'/audio','/voice_audio'}). Reloading main mutates the shared module for
every subsequent test.

- REST fixture: drop the disable flag (the mount is harmless without a
  lifespan), yield the client, and restore main (+ core.config/db) to the
  default data dir in teardown so the global module is clean again.
- test_main_mounts_mcp_route: reload main with the disable flag cleared so
  the assertion is independent of any earlier reload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:56:19 +05:30

2.6 KiB

MCP server — let agents speak in your voice

OmniVoice ships an MCP server so AI agents (Claude Code, Cursor, …) can synthesize speech, transcribe audio, and list your voices — locally, in a voice you choose per agent. The server is mounted on the running backend at /mcp, so there's nothing extra to start once OmniVoice is open.

Tools

Tool What it does
generate_speech text → WAV (base64). Uses the agent's bound voice unless a profile_id is passed.
transcribe base64 audio → text (646 languages).
list_voices / list_personalities / list_languages enumerate what's available.
check_health backend status + active GPU device.

Connecting

Streamable HTTP (modern clients)

Point your client at the mounted endpoint:

http://localhost:3900/mcp

To bind this agent to a specific voice, send an X-OmniVoice-Client-Id header (e.g. claude-code). See per-agent voices.

stdio (clients that only speak stdio)

Use the bundled shim — it proxies stdio ↔ the mounted HTTP endpoint. Drop this into your client's MCP config (docs/mcp.json is a template):

{
  "mcpServers": {
    "omnivoice": {
      "command": "python",
      "args": ["-m", "backend.mcp_shim"],
      "cwd": "/path/to/OmniVoice-Studio",
      "env": { "OMNIVOICE_PORT": "3900", "OMNIVOICE_CLIENT_ID": "claude-code" }
    }
  }
}

The shim forwards OMNIVOICE_CLIENT_ID as the X-OmniVoice-Client-Id header, so the per-agent voice binding works the same as the HTTP path. It waits for the backend to be up, relays JSON-RPC, and exits cleanly when the client closes.

Per-agent voices

Each agent identifies itself with a client id. Bind a client id to a voice profile so different agents speak differently — "Claude Code in Morgan, Cursor in Scarlett". Voice resolution precedence on every generate_speech call:

  1. an explicit profile_id argument, else
  2. the calling agent's binding, else
  3. the global default voice, else
  4. OmniVoice's default voice.

Manage bindings over the loopback REST API (the Settings UI uses these):

# list
curl localhost:3900/api/mcp/bindings
# bind claude-code → a voice profile
curl -X PUT localhost:3900/api/mcp/bindings \
  -H 'Content-Type: application/json' \
  -d '{"client_id":"claude-code","label":"Claude Code","profile_id":"<voice-profile-id>"}'
# remove
curl -X DELETE localhost:3900/api/mcp/bindings/claude-code

Prefer a consent-verified voice profile for any agent that speaks as you.

Disabling

Set OMNIVOICE_MCP_DISABLE=1 to skip mounting /mcp entirely.