diff --git a/backend/api/routers/mcp_bindings.py b/backend/api/routers/mcp_bindings.py new file mode 100644 index 000000000..c1cf7aae7 --- /dev/null +++ b/backend/api/routers/mcp_bindings.py @@ -0,0 +1,53 @@ +"""REST CRUD for per-agent MCP voice bindings (Wave 2.2 / Spec 2). + +Loopback-gated — the Settings UI manages bindings here. The MCP tools +themselves resolve voices via ``services.mcp_bindings.resolve_voice``. +""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from api.dependencies import require_loopback +from services import mcp_bindings + +router = APIRouter( + prefix="/api/mcp", + tags=["mcp"], + dependencies=[Depends(require_loopback)], +) + + +class _BindingBody(BaseModel): + client_id: str = Field(..., min_length=1, max_length=128) + label: str | None = None + profile_id: str | None = None + default_engine: str | None = None + + +@router.get("/bindings") +def list_bindings(): + """All per-agent voice bindings, most-recently-seen first.""" + return mcp_bindings.list_bindings() + + +@router.put("/bindings") +def upsert_binding(body: _BindingBody): + """Create or update the binding for an MCP client id.""" + try: + return mcp_bindings.upsert_binding( + body.client_id, + label=body.label, + profile_id=body.profile_id, + default_engine=body.default_engine, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/bindings/{client_id}") +def delete_binding(client_id: str): + if not mcp_bindings.delete_binding(client_id): + raise HTTPException(status_code=404, detail="No binding for that client id") + return {"deleted": client_id} diff --git a/backend/core/db.py b/backend/core/db.py index 1687b4111..212e7d79f 100644 --- a/backend/core/db.py +++ b/backend/core/db.py @@ -142,6 +142,19 @@ def db_conn(): value TEXT NOT NULL, updated_at REAL NOT NULL ); + + -- Wave 2.2: per-agent MCP voice bindings. An MCP client (Claude Code, + -- Cursor, …) identified by the X-OmniVoice-Client-Id header it sends is + -- bound to a default voice profile / engine. Fresh installs create it + -- here; v0.3.x upgrades get it via alembic 0004. + CREATE TABLE IF NOT EXISTS mcp_client_bindings ( + client_id TEXT PRIMARY KEY, + label TEXT NOT NULL DEFAULT '', + profile_id TEXT, + default_engine TEXT, + last_seen_at REAL, + created_at REAL + ); """ # Only tables/columns this module is allowed to ALTER. Prevents SQL injection via diff --git a/backend/main.py b/backend/main.py index 1e8652054..27961acf1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -428,7 +428,23 @@ def _warm(): capture_preload_task = asyncio.create_task(_preload_capture_asr()) else: logger.info("Capture ASR preload disabled; dictation ASR will load on first use.") - yield + + # ── MCP session manager (Wave 2.2) ──────────────────────────────────── + # FastMCP's Streamable-HTTP transport needs its session manager running + # for the lifetime of the app. It's created lazily by streamable_http_app() + # (called in mount_mcp below), so we stack its `run()` context into ours + # via AsyncExitStack rather than replacing this lifespan. Best-effort: a + # missing/broken MCP layer must never stop the rest of the backend. + from contextlib import AsyncExitStack + async with AsyncExitStack() as _mcp_stack: + _sm = getattr(app.state, "mcp_session_manager", None) + if _sm is not None: + try: + await _mcp_stack.enter_async_context(_sm.run()) + logger.info("MCP server mounted at /mcp") + except Exception as e: + logger.warning("MCP session manager failed to start: %s", e) + yield # ── Graceful shutdown (SIGTERM from Tauri, Ctrl+C, etc.) ──────────── logger.info("Shutdown: cleaning up…") idle_task.cancel() @@ -749,6 +765,27 @@ def health(): app.include_router(marketplace.router) app.include_router(sonitranslate.router) app.include_router(settings_router.router) # Phase 1 AUTH-03 endpoints +from api.routers import mcp_bindings as _mcp_bindings_router # noqa: E402 +app.include_router(_mcp_bindings_router.router) # Wave 2.2 per-agent voice bindings + +# ── Mount the MCP server (Wave 2.2) ─────────────────────────────────────── +# FastMCP's Streamable-HTTP app is sub-mounted at /mcp; its session manager is +# stashed on app.state for the lifespan above to run. Opt-out via +# OMNIVOICE_MCP_DISABLE=1; best-effort so a missing mcp package or a build +# without it never breaks startup. +if os.environ.get("OMNIVOICE_MCP_DISABLE", "").strip().lower() not in ("1", "true", "yes", "on"): + try: + from mcp_server import create_mcp_server + + _mcp = create_mcp_server() + _mcp_app = _mcp.streamable_http_app() + app.state.mcp_session_manager = _mcp.session_manager + app.mount("/mcp", _mcp_app) + logging.getLogger("omnivoice.api").info("MCP app mounted at /mcp") + except Exception as _mcp_err: # noqa: BLE001 + logging.getLogger("omnivoice.api").info( + "MCP server not mounted (%s); /mcp disabled.", _mcp_err + ) frontend_path = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist") if os.path.exists(frontend_path): diff --git a/backend/mcp_server.py b/backend/mcp_server.py index f23eb0ec6..f0f8f21a6 100644 --- a/backend/mcp_server.py +++ b/backend/mcp_server.py @@ -53,6 +53,14 @@ def create_mcp_server(): "voice design, and video dubbing in 646 languages." ), ) + # Serve the Streamable-HTTP transport at the app root so mounting the whole + # app at "/mcp" on the main FastAPI yields the endpoint at "/mcp". FastMCP's + # default path is "/mcp", which would double-prefix to "/mcp/mcp" when + # sub-mounted. Harmless for the standalone CLI run() path. + try: + mcp.settings.streamable_http_path = "/" + except Exception: + pass # ── Helpers ───────────────────────────────────────────────────────── @@ -75,6 +83,21 @@ async def _api_post_form(path: str, data: dict, files: dict | None = None): # ── Tools ─────────────────────────────────────────────────────────── + def _current_client_id() -> str | None: + """The X-OmniVoice-Client-Id of the calling MCP client, if any. + + FastMCP exposes the HTTP request via its request context on the + Streamable-HTTP transport; stdio clients (and any version where the + accessor differs) simply resolve to None and fall back to the + global default voice.""" + try: + req = mcp.get_context().request_context.request + if req is not None: + return req.headers.get("x-omnivoice-client-id") + except Exception: + pass + return None + @mcp.tool() async def generate_speech( text: str, @@ -89,7 +112,8 @@ async def generate_speech( Args: text: The text to synthesize into speech. language: Target language (ISO code or 'Auto'). 646 languages supported. - profile_id: ID of a saved voice profile to clone. Omit for voice design mode. + profile_id: ID of a saved voice profile to clone. Omit to use this + agent's bound voice (Settings → MCP), else the global default. instruct: Style instruction (e.g. 'whisper', 'excited', 'narrator'). speed: Speech speed multiplier (0.5–2.0, default 1.0). steps: Diffusion steps (8=fast/draft, 16=balanced, 32=quality). @@ -98,6 +122,17 @@ async def generate_speech( JSON with audio_id, generation_time, audio_duration, and base64-encoded WAV data. """ + # Per-agent voice binding (Wave 2.2): explicit arg wins; otherwise + # resolve this client's bound profile, then the global default. + client_id = _current_client_id() + try: + from services import mcp_bindings + resolved = mcp_bindings.resolve_voice(client_id, profile_id) + profile_id = resolved.get("profile_id") + mcp_bindings.touch_last_seen(client_id) if client_id else None + except Exception: + pass # binding layer unavailable — use whatever was passed + form = { "text": text, "language": language, @@ -159,6 +194,34 @@ async def list_languages() -> str: '],"note":"Pass any ISO 639 code or set language=Auto for detection."}' ) + @mcp.tool() + async def transcribe(audio_base64: str, language: str | None = None) -> str: + """Transcribe spoken audio to text. + + Args: + audio_base64: Base64-encoded audio bytes (wav/mp3/webm/m4a). + language: Optional language hint; omit for auto-detect. + + Returns: + JSON with the recognized text, language, and duration. + """ + try: + raw = base64.b64decode(audio_base64, validate=True) + except Exception: + return '{"error":"audio_base64 is not valid base64"}' + # 200 MB cap — same spirit as voicebox's transcribe gate. Keeps a + # buggy/hostile agent from posting an unbounded blob. + if len(raw) > 200 * 1024 * 1024: + return '{"error":"audio exceeds 200 MB limit"}' + data = {} + if language: + data["language"] = language + r = await _api_post_form( + "/transcribe", data=data, + files={"audio": ("audio.wav", raw, "application/octet-stream")}, + ) + return str(r.json()) + @mcp.tool() async def check_health() -> str: """Check if the OmniVoice backend is running and what GPU device is active.""" diff --git a/backend/mcp_shim/__init__.py b/backend/mcp_shim/__init__.py new file mode 100644 index 000000000..e23031fc3 --- /dev/null +++ b/backend/mcp_shim/__init__.py @@ -0,0 +1 @@ +"""omnivoice-mcp — stdio MCP shim for clients that only speak stdio.""" diff --git a/backend/mcp_shim/__main__.py b/backend/mcp_shim/__main__.py new file mode 100644 index 000000000..f9f87fed7 --- /dev/null +++ b/backend/mcp_shim/__main__.py @@ -0,0 +1,176 @@ +"""omnivoice-mcp — stdio ↔ Streamable-HTTP MCP proxy (Wave 2.2). + +Adapted from voicebox (https://github.com/jamiepine/voicebox), MIT License, +Copyright (c) voicebox contributors. + +Some MCP clients only speak stdio. They spawn this binary; we pipe each +JSON-RPC message to ``http://127.0.0.1:/mcp/`` (the FastMCP app mounted +on the running OmniVoice backend) and stream the server's response back. + +Environment variables: + OMNIVOICE_PORT backend port (default 3900). + OMNIVOICE_HOST host (default 127.0.0.1). + OMNIVOICE_CLIENT_ID forwarded as X-OmniVoice-Client-Id on every request + (drives per-agent voice binding). + +Stdout is JSON-RPC only. Diagnostics go to stderr. +Exit 0 on clean EOF, 1 on transport error, 2 if the backend never answers. + +Usage in an MCP client config (stdio): + command: python + args: ["-m", "backend.mcp_shim"] + env: { OMNIVOICE_CLIENT_ID: "claude-code" } +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +from typing import Any + +import httpx + +CLIENT_ID_HEADER = "X-OmniVoice-Client-Id" +SESSION_HEADER = "mcp-session-id" +HEALTH_TIMEOUT_S = 30.0 +DEFAULT_PORT = 3900 + + +def _err(msg: str) -> None: + print(f"omnivoice-mcp: {msg}", file=sys.stderr, flush=True) + + +def _base_url() -> tuple[str, str]: + host = os.environ.get("OMNIVOICE_HOST", "127.0.0.1") + port = int(os.environ.get("OMNIVOICE_PORT", str(DEFAULT_PORT))) + return f"http://{host}:{port}/mcp/", f"http://{host}:{port}/health" + + +async def _wait_for_backend(client: httpx.AsyncClient, health_url: str) -> bool: + loop = asyncio.get_running_loop() + deadline = loop.time() + HEALTH_TIMEOUT_S + while loop.time() < deadline: + try: + r = await client.get(health_url, timeout=2.0) + if r.status_code == 200: + return True + except Exception: + pass + await asyncio.sleep(0.5) + return False + + +async def _read_stdin_line() -> str | None: + loop = asyncio.get_running_loop() + line = await loop.run_in_executor(None, sys.stdin.readline) + return line or None + + +def _write_stdout(obj: Any) -> None: + sys.stdout.write(json.dumps(obj, separators=(",", ":"))) + sys.stdout.write("\n") + sys.stdout.flush() + + +async def _handle_request( + client: httpx.AsyncClient, + url: str, + raw: str, + headers: dict[str, str], + session_id: list[str | None], +) -> None: + try: + message = json.loads(raw) + except json.JSONDecodeError as exc: + _err(f"invalid JSON on stdin: {exc}") + return + + req_headers = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + **headers, + } + if session_id[0]: + req_headers[SESSION_HEADER] = session_id[0] + + is_notification = isinstance(message, dict) and "id" not in message + + async with client.stream("POST", url, headers=req_headers, content=raw.encode("utf-8")) as response: + if session_id[0] is None: + sid = response.headers.get(SESSION_HEADER) + if sid: + session_id[0] = sid + + if response.status_code == 202: + return # notification acknowledged + if response.status_code >= 400: + body = await response.aread() + _err(f"server {response.status_code}: {body.decode('utf-8', errors='replace')[:400]}") + if is_notification: + return + _write_stdout({ + "jsonrpc": "2.0", + "id": message.get("id"), + "error": {"code": -32000, "message": f"OmniVoice MCP proxy got HTTP {response.status_code}"}, + }) + return + + ctype = response.headers.get("content-type", "") + if "text/event-stream" in ctype: + async for line in response.aiter_lines(): + if line.startswith("data:"): + payload = line[5:].strip() + if not payload: + continue + try: + _write_stdout(json.loads(payload)) + except json.JSONDecodeError: + _err(f"malformed SSE payload: {payload[:200]}") + else: + body = await response.aread() + try: + _write_stdout(json.loads(body)) + except json.JSONDecodeError: + _err(f"non-JSON response ({ctype}): {body.decode('utf-8', errors='replace')[:200]}") + + +async def _run() -> int: + url, health_url = _base_url() + forward_headers: dict[str, str] = {} + client_id = os.environ.get("OMNIVOICE_CLIENT_ID") + if client_id: + forward_headers[CLIENT_ID_HEADER] = client_id + + session_id: list[str | None] = [None] + + async with httpx.AsyncClient(timeout=httpx.Timeout(300.0)) as client: + if not await _wait_for_backend(client, health_url): + _err(f"timed out waiting for OmniVoice at {health_url} — is the app running?") + return 2 + try: + while True: + line = await _read_stdin_line() + if line is None: + return 0 + line = line.strip() + if not line: + continue + await _handle_request(client, url, line, forward_headers, session_id) + except (KeyboardInterrupt, SystemExit): + return 0 + except Exception as exc: + _err(f"proxy failed: {exc!r}") + return 1 + + +def main() -> int: + try: + return asyncio.run(_run()) + except KeyboardInterrupt: + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/migrations/versions/0004_mcp_client_bindings.py b/backend/migrations/versions/0004_mcp_client_bindings.py new file mode 100644 index 000000000..be01e14b1 --- /dev/null +++ b/backend/migrations/versions/0004_mcp_client_bindings.py @@ -0,0 +1,60 @@ +"""Parity program Wave 2.2: per-agent MCP voice bindings + +Revision ID: 0004_mcp_client_bindings +Revises: 0003_voice_profile_consent +Create Date: 2026-06-12 00:00:00.000000 + +Adds the ``mcp_client_bindings`` table backing per-agent voice binding +(docs/competitive-analysis.md Spec 2): each MCP client (identified by the +``X-OmniVoice-Client-Id`` header it sends) can be bound to a default voice +profile / engine, so "Claude Code speaks in Morgan, Cursor in Scarlett". + + * ``client_id`` TEXT PRIMARY KEY — the agent's stable id. + * ``label`` TEXT — human label shown in Settings. + * ``profile_id`` TEXT — voice profile to speak in (nullable FK-by-convention). + * ``default_engine`` TEXT — engine override (nullable). + * ``last_seen_at`` REAL — updated when the client calls a tool. + * ``created_at`` REAL. + +Additive + idempotent (guarded by sqlite_master), matching 0002/0003, so +re-running on a fresh-install DB where _BASE_SCHEMA already created it is a +no-op (Backward-compatible project data constraint). +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0004_mcp_client_bindings" +down_revision: Union[str, None] = "0003_voice_profile_consent" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _has_table(name: str) -> bool: + bind = op.get_bind() + row = bind.execute( + sa.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:n"), + {"n": name}, + ).fetchone() + return row is not None + + +def upgrade() -> None: + if _has_table("mcp_client_bindings"): + return + op.create_table( + "mcp_client_bindings", + sa.Column("client_id", sa.Text(), primary_key=True), + sa.Column("label", sa.Text(), nullable=False, server_default=""), + sa.Column("profile_id", sa.Text(), nullable=True), + sa.Column("default_engine", sa.Text(), nullable=True), + sa.Column("last_seen_at", sa.Float(), nullable=True), + sa.Column("created_at", sa.Float(), nullable=True), + ) + + +def downgrade() -> None: + if _has_table("mcp_client_bindings"): + op.drop_table("mcp_client_bindings") diff --git a/backend/services/mcp_bindings.py b/backend/services/mcp_bindings.py new file mode 100644 index 000000000..6b7285795 --- /dev/null +++ b/backend/services/mcp_bindings.py @@ -0,0 +1,126 @@ +"""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"} diff --git a/docs/mcp.json b/docs/mcp.json index 4f5fcb0d6..1d5ec92e7 100644 --- a/docs/mcp.json +++ b/docs/mcp.json @@ -1,11 +1,14 @@ { + "_comment": "MCP client config for OmniVoice Studio. See docs/mcp.md for both connection modes.", + "_streamable_http": "If your MCP client speaks Streamable HTTP, point it directly at the running app: http://localhost:3900/mcp — no separate process needed (the server is mounted on the backend). Send an X-OmniVoice-Client-Id header to bind this agent to a specific voice.", "mcpServers": { "omnivoice": { "command": "python", - "args": ["-m", "backend.mcp_server"], + "args": ["-m", "backend.mcp_shim"], "cwd": "/path/to/OmniVoice-Studio", "env": { - "OMNIVOICE_API_URL": "http://localhost:3900" + "OMNIVOICE_PORT": "3900", + "OMNIVOICE_CLIENT_ID": "claude-code" } } } diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 000000000..5b5c3820f --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,84 @@ +# MCP server — let agents speak in your voice + +OmniVoice ships an [MCP](https://modelcontextprotocol.io/) 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](#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): + +```json +{ + "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): + +```bash +# 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":""}' +# remove +curl -X DELETE localhost:3900/api/mcp/bindings/claude-code +``` + +Prefer a [consent-verified](../docs/competitive-analysis.md) voice profile for +any agent that speaks as you. + +## Disabling + +Set `OMNIVOICE_MCP_DISABLE=1` to skip mounting `/mcp` entirely. diff --git a/frontend/src/components/settings/MCPBindingsPanel.jsx b/frontend/src/components/settings/MCPBindingsPanel.jsx new file mode 100644 index 000000000..ac124d00c --- /dev/null +++ b/frontend/src/components/settings/MCPBindingsPanel.jsx @@ -0,0 +1,101 @@ +/** + * Settings → Sharing → MCP voice bindings panel (parity program Wave 2.2). + * + * Bind an MCP client id (the X-OmniVoice-Client-Id an agent sends) to a voice + * profile, so "Claude Code speaks in Morgan, Cursor in Scarlett". The MCP + * server is mounted at /mcp on the backend; see docs/mcp.md. + * + * Endpoints (loopback-only): + * GET /api/mcp/bindings + * PUT /api/mcp/bindings {client_id, label?, profile_id?, default_engine?} + * DELETE /api/mcp/bindings/{client_id} + */ +import React, { useCallback, useEffect, useState } from 'react'; +import { Bot, Trash2 } from 'lucide-react'; +import { apiJson, apiFetch } from '../../api/client'; +import { listProfiles } from '../../api/profiles'; +import './PerformancePanel.css'; + +export default function MCPBindingsPanel() { + const [bindings, setBindings] = useState([]); + const [profiles, setProfiles] = useState([]); + const [clientId, setClientId] = useState(''); + const [profileId, setProfileId] = useState(''); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setError(null); + try { + const [b, p] = await Promise.all([apiJson('/api/mcp/bindings'), listProfiles()]); + setBindings(b); + setProfiles(p); + } catch (e) { + setError(e?.message || 'Failed to load MCP bindings'); + } + }, []); + + useEffect(() => { refresh(); }, [refresh]); + + const profileName = (id) => profiles.find((p) => p.id === id)?.name || id || '—'; + + const onAdd = async () => { + if (!clientId.trim()) return; + setError(null); + try { + await apiFetch('/api/mcp/bindings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: clientId.trim(), profile_id: profileId || null }), + }); + setClientId(''); + setProfileId(''); + refresh(); + } catch (e) { + setError(e?.message || 'Failed to save binding'); + } + }; + + const onDelete = async (cid) => { + try { + await apiFetch(`/api/mcp/bindings/${encodeURIComponent(cid)}`, { method: 'DELETE' }); + refresh(); + } catch (e) { + setError(e?.message || 'Failed to delete binding'); + } + }; + + return ( +
+

+ MCP voice bindings +

+

+ Agents reach OmniVoice at /mcp. Bind an agent's client id + to a voice so it speaks in that profile. See docs/mcp.md. +

+ + {error &&
{error}
} + + {bindings.map((b) => ( +
+ {b.label || b.client_id} + {profileName(b.profile_id)} + +
+ ))} + +
+ setClientId(e.target.value)} + placeholder="client id (e.g. claude-code)" style={{ flex: 1 }} data-testid="mcp-client-id" /> + + +
+
+ ); +} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index d2e7780b4..483f23b80 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -38,6 +38,7 @@ import AppearancePanel from '../components/settings/AppearancePanel'; import StoragePanel from '../components/settings/StoragePanel'; import SharingPanel from '../components/settings/SharingPanel'; import RemoteBackendPanel from '../components/settings/RemoteBackendPanel'; +import MCPBindingsPanel from '../components/settings/MCPBindingsPanel'; import EngineCompatibilityMatrix from '../components/EngineCompatibilityMatrix'; import DictationDemo from '../components/DictationDemo'; import ReportBugButton from '../components/ReportBugButton'; @@ -1337,6 +1338,7 @@ export default function Settings() { <> + )} diff --git a/pyproject.toml b/pyproject.toml index c0ebef74d..fd688d277 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,6 +108,7 @@ dependencies = [ # (Assumption A1 in RESEARCH.md was checked at execute-time and proved # false — `cryptography` is not on the install path today). "cryptography>=41", + "mcp>=1.2", ] [project.optional-dependencies] diff --git a/tests/test_mcp_bindings.py b/tests/test_mcp_bindings.py new file mode 100644 index 000000000..9e7fea8fb --- /dev/null +++ b/tests/test_mcp_bindings.py @@ -0,0 +1,189 @@ +"""Per-agent MCP voice bindings (Wave 2.2) — service + resolution + migration. + +The service layer is pure (db_conn over an isolated tmp DB), so these run +without importing `main`. The REST CRUD test uses a TestClient and is +validated in CI (local torch/Triton segfault on main-importing tests). +""" +import os +import sqlite3 +import sys + +import pytest + +os.environ.setdefault("OMNIVOICE_MODEL", "test") +os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1") + + +@pytest.fixture +def db(tmp_path, monkeypatch): + monkeypatch.setenv("OMNIVOICE_DATA_DIR", str(tmp_path)) + import importlib + import core.config as _cfg + importlib.reload(_cfg) + import core.db as _db + importlib.reload(_db) + _db.init_db() + import services.mcp_bindings as mb + importlib.reload(mb) + return mb + + +def test_upsert_creates_then_updates(db): + b = db.upsert_binding("claude-code", label="Claude Code", profile_id="morgan") + assert b["client_id"] == "claude-code" + assert b["profile_id"] == "morgan" + assert b["created_at"] is not None + + # Update only the profile; label preserved. + b2 = db.upsert_binding("claude-code", profile_id="scarlett") + assert b2["profile_id"] == "scarlett" + assert b2["label"] == "Claude Code" + + +def test_empty_client_id_rejected(db): + with pytest.raises(ValueError): + db.upsert_binding(" ", profile_id="x") + + +def test_list_and_delete(db): + db.upsert_binding("a", profile_id="p1") + db.upsert_binding("b", profile_id="p2") + assert {x["client_id"] for x in db.list_bindings()} == {"a", "b"} + assert db.delete_binding("a") is True + assert db.delete_binding("a") is False + assert {x["client_id"] for x in db.list_bindings()} == {"b"} + + +def test_resolution_precedence(db): + db.upsert_binding("cursor", profile_id="bound-voice") + + # Explicit arg wins over everything. + r = db.resolve_voice("cursor", "explicit-voice") + assert r == {"profile_id": "explicit-voice", "default_engine": None, "source": "explicit"} + + # No explicit → the client's binding. + r = db.resolve_voice("cursor", None) + assert r["profile_id"] == "bound-voice" and r["source"] == "binding" + + # Unknown client, no global default → none. + r = db.resolve_voice("unknown", None) + assert r["source"] == "none" and r["profile_id"] is None + + +def test_resolution_global_default(db, monkeypatch): + from core import prefs + monkeypatch.setattr(prefs, "get", lambda k, default=None: "global-voice" if k == "mcp_default_profile_id" else default) + r = db.resolve_voice("no-binding-client", None) + assert r == {"profile_id": "global-voice", "default_engine": None, "source": "global"} + + +def test_touch_last_seen_is_best_effort(db): + db.upsert_binding("agent", profile_id="v") + before = db.get_binding("agent")["last_seen_at"] + assert before is None + db.touch_last_seen("agent") + assert db.get_binding("agent")["last_seen_at"] is not None + # Never raises for an unknown client. + db.touch_last_seen("ghost") + + +# ── Migration ─────────────────────────────────────────────────────────────── + +# Migrations 0002/0003 ALTER voice_profiles, so a realistic pre-0004 DB must +# carry it (plus settings, created by 0001). Mirrors the post-0001/pre-0002 +# shape so the whole chain upgrades cleanly. +_PRE_0004 = """ + CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at REAL NOT NULL); + CREATE TABLE voice_profiles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + ref_audio_path TEXT, + ref_text TEXT DEFAULT '', + instruct TEXT DEFAULT '', + language TEXT DEFAULT 'Auto', + locked_audio_path TEXT DEFAULT '', + seed INTEGER DEFAULT NULL, + is_locked INTEGER DEFAULT 0, + personality TEXT DEFAULT '', + created_at REAL + ); +""" + + +def _run_alembic(direction, db_path, target="head"): + from alembic import command + from alembic.config import Config + + here = os.path.abspath(os.path.dirname(__file__)) + root = here + while root and root != "/" and not os.path.isfile(os.path.join(root, "alembic.ini")): + root = os.path.dirname(root) + cfg = Config(os.path.join(root, "alembic.ini")) + cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}") + (command.upgrade if direction == "upgrade" else command.downgrade)(cfg, target) + + +def _tables(db_path): + with sqlite3.connect(str(db_path)) as conn: + return {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + + +def test_migration_0004_adds_table(tmp_path): + dbf = tmp_path / "pre.db" + with sqlite3.connect(str(dbf)) as conn: + conn.executescript(_PRE_0004) + conn.commit() + _run_alembic("upgrade", str(dbf)) + assert "mcp_client_bindings" in _tables(dbf) + + +def test_migration_0004_downgrade_drops_table(tmp_path): + dbf = tmp_path / "pre.db" + with sqlite3.connect(str(dbf)) as conn: + conn.executescript(_PRE_0004) + conn.commit() + _run_alembic("upgrade", str(dbf)) + _run_alembic("downgrade", str(dbf), target="0003_voice_profile_consent") + assert "mcp_client_bindings" not in _tables(dbf) + + +# ── REST CRUD (main-importing — CI only) ───────────────────────────────────── + +@pytest.fixture +def client(tmp_path, monkeypatch): + monkeypatch.setenv("OMNIVOICE_DATA_DIR", str(tmp_path)) + import importlib + for m in ("core.config", "core.db", "services.mcp_bindings"): + if m in sys.modules: + importlib.reload(importlib.import_module(m)) + import core.db as _db + _db.init_db() + import main as _main + importlib.reload(_main) + from fastapi.testclient import TestClient + # No `with` — running the lifespan rebinds module-level event-bus queues + # to this loop and contaminates later lifespan tests (Wave 0.2 footgun). + try: + yield TestClient(_main.app, client=("127.0.0.1", 50000)) + finally: + # Reloading main above poisons the global module for any later test + # that does `from main import …`. Reload once more with the default + # (project) data dir restored so the shared module is clean again. + monkeypatch.undo() + importlib.reload(importlib.import_module("core.config")) + importlib.reload(importlib.import_module("core.db")) + importlib.reload(_main) + + +def test_rest_crud_roundtrip(client): + assert client.get("/api/mcp/bindings").json() == [] + r = client.put("/api/mcp/bindings", json={"client_id": "claude-code", "label": "CC", "profile_id": "morgan"}) + assert r.status_code == 200 and r.json()["profile_id"] == "morgan" + assert len(client.get("/api/mcp/bindings").json()) == 1 + assert client.delete("/api/mcp/bindings/claude-code").status_code == 200 + assert client.delete("/api/mcp/bindings/claude-code").status_code == 404 + + +def test_rest_rejects_empty_client_id(client): + r = client.put("/api/mcp/bindings", json={"client_id": ""}) + assert r.status_code == 422 # pydantic min_length diff --git a/tests/test_mcp_mount.py b/tests/test_mcp_mount.py new file mode 100644 index 000000000..2d4cb492b --- /dev/null +++ b/tests/test_mcp_mount.py @@ -0,0 +1,73 @@ +"""MCP server mount + tool surface (Wave 2.2). + +The build/tool-surface checks need only the FastMCP server (no `main`, so no +torch — these run locally). The mount-on-main check imports `main` and is +validated in CI (local torch/Triton segfault on main-importing tests). +""" +import asyncio +import os + +os.environ.setdefault("OMNIVOICE_MODEL", "test") +os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1") + +import pytest + +mcp_pkg = pytest.importorskip("mcp") # skip cleanly if the optional dep is absent + + +def test_server_builds_with_expected_tools(): + from mcp_server import create_mcp_server + + server = create_mcp_server() + names = {t.name for t in asyncio.run(server.list_tools())} + # v1 surface: speak, transcribe, and the read-only listers. + assert {"generate_speech", "transcribe", "list_voices", "list_personalities", + "list_languages", "check_health"} <= names + + +def test_streamable_app_serves_at_root_for_submounting(): + from mcp_server import create_mcp_server + + server = create_mcp_server() + app = server.streamable_http_app() + # streamable_http_path was set to "/" so a mount at "/mcp" lands at "/mcp" + # (not the double-prefixed "/mcp/mcp"). + paths = [getattr(r, "path", None) for r in app.routes] + assert "/" in paths + assert server.session_manager is not None + + +def _mount_paths(app) -> set[str]: + from starlette.routing import Mount + return {r.path for r in app.routes if isinstance(r, Mount)} + + +def test_main_mounts_mcp_route(monkeypatch): + """Importing main wires the /mcp mount. + + Inspect app.routes rather than driving a TestClient — running the app + lifespan starts the FastMCP session manager, which binds asyncio queues + to the test's event loop and contaminates later lifespan-running tests + ("bound to a different event loop"). The mount happens at import time. + + Reload main with the disable flag cleared so this is independent of any + earlier test that reloaded main (e.g. with OMNIVOICE_MCP_DISABLE set). + """ + monkeypatch.delenv("OMNIVOICE_MCP_DISABLE", raising=False) + import importlib + import main as _main + importlib.reload(_main) + assert "/mcp" in _mount_paths(_main.app) + + +def test_mcp_disable_env_skips_mount(monkeypatch): + monkeypatch.setenv("OMNIVOICE_MCP_DISABLE", "1") + import importlib + import main as _main + importlib.reload(_main) + try: + assert "/mcp" not in _mount_paths(_main.app) + finally: + # Restore the default app so other tests see /mcp mounted again. + monkeypatch.delenv("OMNIVOICE_MCP_DISABLE", raising=False) + importlib.reload(_main) diff --git a/uv.lock b/uv.lock index a0d9a2310..4ca021101 100644 --- a/uv.lock +++ b/uv.lock @@ -1703,6 +1703,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "huggingface-hub" version = "1.7.2" @@ -2362,6 +2371,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, ] +[[package]] +name = "mcp" +version = "1.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/3c/347cf965d313f5d41764e7d46bea6ffe7d9ef13b983cc429b0340962a082/mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef", size = 621116, upload-time = "2026-05-29T17:16:04.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/11/252c6f971dc4f16af1d98a1c469d8ba523aab00d1bb76b4d3bc1ff32eacc/mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5", size = 220498, upload-time = "2026-05-29T17:16:02.442Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -3072,6 +3106,7 @@ dependencies = [ { name = "gradio" }, { name = "imageio-ffmpeg" }, { name = "kittentts" }, + { name = "mcp" }, { name = "mlx-audio", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, { name = "mlx-whisper", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'" }, { name = "numpy" }, @@ -3142,6 +3177,7 @@ requires-dist = [ { name = "jiwer", marker = "extra == 'eval'", specifier = "==3.1.0" }, { name = "kittentts", url = "https://github.com/KittenML/KittenTTS/releases/download/0.8.1/kittentts-0.8.1-py3-none-any.whl" }, { name = "librosa", marker = "extra == 'eval'" }, + { name = "mcp", specifier = ">=1.2" }, { name = "mlx-audio", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'", specifier = ">=0.3.0" }, { name = "mlx-whisper", marker = "platform_machine == 'arm64' and sys_platform == 'darwin'", specifier = ">=0.2.1" }, { name = "numpy" }, @@ -4044,6 +4080,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, +] + [[package]] name = "pydub" version = "0.25.1" @@ -4103,6 +4153,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/f4/035fb8c06deff827f540a9a4ed9122c54e5376fca3e42eddf0c263730775/pyinstaller_hooks_contrib-2026.4-py3-none-any.whl", hash = "sha256:1de1a5e49a878122010b88c7e295502bc69776c157c4a4dc78741a4e6178b00f", size = 455496, upload-time = "2026-03-31T14:10:49.867Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyloudnorm" version = "0.2.0" @@ -4196,6 +4260,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + [[package]] name = "python-multipart" version = "0.0.22" @@ -4262,6 +4335,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -5355,6 +5450,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/ae/57d1d7af907e20c077e113e0e4976f87b82c0a415403d99284a262229dd0/srsly-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d822083fe26ec6728bd8c273ac121fc4ab3864a0fdf0cf0ff3efb188fcd209ed", size = 650229, upload-time = "2026-03-23T11:56:46.148Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, +] + [[package]] name = "standard-aifc" version = "3.13.0"