Files
VoiceStudio/backend/services/mcp_bindings.py
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

127 lines
4.6 KiB
Python

"""Per-agent MCP voice bindings (Wave 2.2 / Spec 2).
An MCP client identifies itself with the ``X-OmniVoice-Client-Id`` header.
Each client can be bound to a default voice profile + engine so different
agents speak in different voices ("Claude Code in Morgan, Cursor in
Scarlett"). Pure data layer over the ``mcp_client_bindings`` table — the
FastMCP tools call :func:`resolve_voice`; the Settings UI calls the CRUD
helpers via the REST router.
"""
from __future__ import annotations
import time
from typing import Optional
from core.db import db_conn
def list_bindings() -> list[dict]:
with db_conn() as conn:
# SQLite sorts NULL as smallest, so DESC naturally puts never-seen
# bindings after recently-active ones.
rows = conn.execute(
"SELECT * FROM mcp_client_bindings ORDER BY last_seen_at DESC, created_at DESC"
).fetchall()
return [dict(r) for r in rows]
def get_binding(client_id: str) -> Optional[dict]:
with db_conn() as conn:
row = conn.execute(
"SELECT * FROM mcp_client_bindings WHERE client_id=?", (client_id,)
).fetchone()
return dict(row) if row else None
def upsert_binding(
client_id: str,
*,
label: Optional[str] = None,
profile_id: Optional[str] = None,
default_engine: Optional[str] = None,
) -> dict:
"""Create or update a binding. Fields left as None on an existing row are
preserved; on a new row they default to empty/null."""
if not client_id or not client_id.strip():
raise ValueError("client_id must be non-empty")
cid = client_id.strip()
existing = get_binding(cid)
now = time.time()
if existing:
merged = {
"label": existing["label"] if label is None else label,
"profile_id": existing["profile_id"] if profile_id is None else (profile_id or None),
"default_engine": existing["default_engine"] if default_engine is None else (default_engine or None),
}
with db_conn() as conn:
conn.execute(
"UPDATE mcp_client_bindings SET label=?, profile_id=?, default_engine=? WHERE client_id=?",
(merged["label"], merged["profile_id"], merged["default_engine"], cid),
)
else:
with db_conn() as conn:
conn.execute(
"INSERT INTO mcp_client_bindings "
"(client_id, label, profile_id, default_engine, last_seen_at, created_at) "
"VALUES (?, ?, ?, ?, NULL, ?)",
(cid, label or "", profile_id or None, default_engine or None, now),
)
return get_binding(cid)
def delete_binding(client_id: str) -> bool:
with db_conn() as conn:
cur = conn.execute("DELETE FROM mcp_client_bindings WHERE client_id=?", (client_id,))
return cur.rowcount > 0
def touch_last_seen(client_id: str) -> None:
"""Best-effort 'last heard from this agent' stamp. Never raises — it's
telemetry for the Settings list, not load-bearing."""
if not client_id:
return
try:
with db_conn() as conn:
conn.execute(
"UPDATE mcp_client_bindings SET last_seen_at=? WHERE client_id=?",
(time.time(), client_id),
)
except Exception:
pass
def _global_default_profile() -> Optional[str]:
"""The fallback voice when a client has no binding. Reads the same
pref the Settings 'default playback voice' would set; None if unset."""
try:
from core import prefs
return prefs.get("mcp_default_profile_id") or None
except Exception:
return None
def resolve_voice(client_id: Optional[str], explicit_profile_id: Optional[str]) -> dict:
"""Resolve which voice an MCP speak call should use.
Precedence (Spec 2): explicit tool arg → the client's binding →
the global default → nothing (caller decides / errors with a hint).
Returns ``{profile_id, default_engine, source}`` where ``source`` is one
of ``explicit`` | ``binding`` | ``global`` | ``none`` for diagnostics.
"""
if explicit_profile_id:
return {"profile_id": explicit_profile_id, "default_engine": None, "source": "explicit"}
if client_id:
binding = get_binding(client_id)
if binding and binding.get("profile_id"):
return {
"profile_id": binding["profile_id"],
"default_engine": binding.get("default_engine"),
"source": "binding",
}
g = _global_default_profile()
if g:
return {"profile_id": g, "default_engine": None, "source": "global"}
return {"profile_id": None, "default_engine": None, "source": "none"}