mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-16 05:10:42 -07:00
POST /speak is a REST wrapper around voicebox.speak for agents that
don't talk MCP (shell scripts, ACP, A2A). It reads X-Voicebox-Client-Id
and uses it for the same per-client profile resolution + default
personality lookup the MCP tool does (speak.py:39-64), so its callers
are first-class clients — but the ClientIdMiddleware only stamped
last_seen_at on /mcp* paths. REST speak callers showed up as "never
seen" in Settings → MCP despite actively acting on their bindings.
Widen the stamp predicate to an explicit ("/mcp", "/speak") prefix
list, and require a path boundary on match so future routes named
/mcpfoo or /speakers don't silently inherit the stamp via the prefix.
New test_client_id_middleware.py pins the scope with 17 parametrised
cases (both the allowed set and the overlap cases that must not match).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
102 lines
3.6 KiB
Python
102 lines
3.6 KiB
Python
"""Per-request client identity for MCP calls.
|
|
|
|
MCP clients identify themselves via an ``X-Voicebox-Client-Id`` HTTP header
|
|
(direct-HTTP clients set it in their MCP config; the stdio shim forwards it
|
|
from the ``VOICEBOX_CLIENT_ID`` env var). Middleware copies the value into a
|
|
ContextVar so tool implementations can read it without plumbing the request
|
|
object through every service call.
|
|
"""
|
|
|
|
import logging
|
|
from contextvars import ContextVar
|
|
from datetime import datetime
|
|
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response
|
|
from starlette.types import ASGIApp
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CLIENT_ID_HEADER = "X-Voicebox-Client-Id"
|
|
|
|
# Tool handlers read this to apply per-client voice bindings.
|
|
current_client_id: ContextVar[str | None] = ContextVar(
|
|
"current_client_id", default=None
|
|
)
|
|
|
|
# Endpoints that consume X-Voicebox-Client-Id for its MCP-semantic
|
|
# meaning (per-client profile resolution + per-client default_personality).
|
|
# These are the paths where a stamp into last_seen_at is accurate.
|
|
# Unrelated REST traffic that happens to set the header is intentionally
|
|
# ignored so the Settings UI's "last heard from" column only reflects
|
|
# calls that actually acted on the client's bindings.
|
|
#
|
|
# - /mcp — FastMCP tool calls (voicebox.speak, voicebox.transcribe, …)
|
|
# and the /mcp/bindings admin surface. The admin surface is never
|
|
# called with the header in practice (the frontend manages bindings
|
|
# over plain REST), so the `startswith("/mcp")` match doesn't cause
|
|
# false stamps.
|
|
# - /speak — REST mirror of voicebox.speak for non-MCP agents (shell
|
|
# scripts, ACP, A2A). Uses the same per-client binding lookup, so its
|
|
# callers belong in the last-seen list too.
|
|
_STAMPED_PATH_PREFIXES: tuple[str, ...] = ("/mcp", "/speak")
|
|
|
|
|
|
class ClientIdMiddleware(BaseHTTPMiddleware):
|
|
"""Copy X-Voicebox-Client-Id into a ContextVar and stamp last_seen_at
|
|
for requests that act on the caller's MCP bindings."""
|
|
|
|
def __init__(self, app: ASGIApp) -> None:
|
|
super().__init__(app)
|
|
|
|
async def dispatch(self, request: Request, call_next) -> Response:
|
|
client_id = request.headers.get(CLIENT_ID_HEADER)
|
|
token = current_client_id.set(client_id)
|
|
try:
|
|
response = await call_next(request)
|
|
finally:
|
|
current_client_id.reset(token)
|
|
|
|
if client_id and _is_stamped_path(request.url.path):
|
|
_stamp_last_seen(client_id)
|
|
return response
|
|
|
|
|
|
def _is_stamped_path(path: str) -> bool:
|
|
# Require a path boundary so a future ``/speakers`` or ``/mcpfoo``
|
|
# route doesn't silently inherit the stamp from ``/speak`` / ``/mcp``.
|
|
return any(path == p or path.startswith(p + "/") for p in _STAMPED_PATH_PREFIXES)
|
|
|
|
|
|
def _stamp_last_seen(client_id: str) -> None:
|
|
"""Update or create the MCPClientBinding row for this client_id."""
|
|
try:
|
|
from ..database import get_db
|
|
from ..database.models import MCPClientBinding
|
|
except Exception:
|
|
return
|
|
try:
|
|
db = next(get_db())
|
|
except Exception:
|
|
return
|
|
try:
|
|
row = (
|
|
db.query(MCPClientBinding)
|
|
.filter(MCPClientBinding.client_id == client_id)
|
|
.first()
|
|
)
|
|
if row is None:
|
|
row = MCPClientBinding(client_id=client_id)
|
|
db.add(row)
|
|
row.last_seen_at = datetime.utcnow()
|
|
db.commit()
|
|
except Exception:
|
|
logger.debug(
|
|
"Could not stamp last_seen_at for %s", client_id, exc_info=True
|
|
)
|
|
db.rollback()
|
|
finally:
|
|
db.close()
|