feat(mcp): connect authenticated AquaPortal tools to Vocero (#1)

Vocero needs to query AquaPortal on behalf of the caller selected by the existing phone link. This adds server-owned conversations and MCP tool execution for voice and HTTP reasoning while leaving the speech pipeline unchanged.

Release notes:

- Added browser-bound conversations that keep backend credentials server-side and isolate callers.
- Connected the eight-tool AquaPortal catalog through authenticated Streamable HTTP with full input schemas and structured results.
- Added bounded multi-step tool execution with cancellation, expiry, and stale-result protection.
- Updated browser bootstrap, tool progress, reasoning headers, and conversation cleanup.
- Disabled legacy unscoped content preloads in MCP mode and documented configuration and deployment constraints.
- Preserved disabled-mode contracts and added local SDK, WebSocket, and browser regression coverage.

Reviewed-on: AquaPortal/s2s#1
This commit is contained in:
2026-10-08 08:49:39 -04:00
parent ccb1f708ca
commit 96895996d9
37 changed files with 2498 additions and 55 deletions
+51 -3
View File
@@ -1,6 +1,13 @@
import { z } from "zod";
import { configSchema, recordSchema, reportsSchema, toolsEnvelopeSchema } from "./contracts";
import {
configSchema,
conversationSchema,
mcpToolsSchema,
recordSchema,
reportsSchema,
toolsEnvelopeSchema,
} from "./contracts";
export class ApiError extends Error {
readonly status: number;
@@ -51,10 +58,16 @@ export async function request<T>(
}
}
export function post<T>(path: string, schema: z.ZodType<T>, body: unknown, signal?: AbortSignal) {
export function post<T>(
path: string,
schema: z.ZodType<T>,
body: unknown,
signal?: AbortSignal,
headers?: Record<string, string>,
) {
return request(path, schema, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", ...headers },
body: JSON.stringify(body),
signal,
});
@@ -107,6 +120,41 @@ export async function getTools(signal: AbortSignal, refresh = false) {
return result.data.tools;
}
export function conversationHeaders(id: string, turn?: number): Record<string, string> {
return { "X-Vocero-Conversation": id, ...(turn === undefined ? {} : { "X-Vocero-Turn": String(turn) }) };
}
export function createConversation(phoneNumber: string, signal: AbortSignal) {
return request(
"/api/conversations",
conversationSchema,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phoneNumber }),
signal,
},
15000,
);
}
export async function getMcpTools(id: string, signal: AbortSignal) {
const result = await request("/api/mcp/tools", mcpToolsSchema, { headers: conversationHeaders(id), signal }, 30000);
return result.tools;
}
export function endConversation(id: string) {
void fetch("/api/conversations/current", {
method: "DELETE",
headers: conversationHeaders(id),
keepalive: true,
}).catch(() => undefined);
}
export function interruptConversation(id: string, turn: number) {
void fetch("/api/conversations/current/interrupt", {
method: "POST",
headers: conversationHeaders(id, turn),
keepalive: true,
}).catch(() => undefined);
}
export const reasoningSchema = z.object({ answer: z.string().default("") });
export const searchSchema = z.object({
answer: z.string().nullish(),
+1 -1
View File
@@ -68,7 +68,7 @@ export function buildInstructions(
base,
config.injectProfile ? profileContext(profile) : "",
ragContext(rag),
base.includes(TOOL_MARKER) ? "" : toolContext(tools),
config.mcp || base.includes(TOOL_MARKER) ? "" : toolContext(tools),
]
.filter(Boolean)
.join("\n\n");
+7
View File
@@ -15,6 +15,7 @@ export const configSchema = z.object({
startSessionMode: z.string().default(""),
theme: z.string().default(""),
apipilot: z.boolean().default(false),
mcp: z.boolean().default(false),
});
export type ServerConfig = z.infer<typeof configSchema>;
export const defaultConfig = configSchema.parse({});
@@ -54,6 +55,12 @@ export const toolsEnvelopeSchema = z.object({
data: z.object({ tools: z.array(toolSchema), count: z.number().optional() }).nullish(),
error: z.unknown().optional(),
});
export const conversationSchema = z.object({
conversationId: z.string().min(1),
expiresIn: z.number(),
profile: z.object({ id: z.string(), name: z.string() }),
});
export const mcpToolsSchema = z.object({ tools: z.array(toolSchema) });
export const grantSchema = z.object({
session_id: z.string(),
@@ -91,6 +91,21 @@ afterEach(() => {
});
describe("realtime session", () => {
it("shows processing during a server MCP call and returns to listening on failure", async () => {
const client = new RealtimeClient(options());
const status = vi.fn();
client.on("status", status);
const socket = await connect(client);
socket.receive({ type: "vocero.tool.started", name: "list_reports" });
await vi.waitFor(() => {
expect(status).toHaveBeenLastCalledWith("processing");
});
socket.receive({ type: "vocero.tool.failed" });
await vi.waitFor(() => {
expect(status).toHaveBeenLastCalledWith("listening");
});
await client.close();
});
it("waits for buffered audio to drain after response.done", async () => {
const client = new RealtimeClient(options());
const socket = await connect(client);
+12 -1
View File
@@ -69,6 +69,7 @@ export class RealtimeClient {
private activeResponses = new Set<string>();
private activeResponse = "";
private createInFlight = false;
private serverToolPending = false;
private pendingCreates = 0;
private deferredTexts: string[] = [];
private suppressed = new Set<string>();
@@ -314,6 +315,14 @@ export class RealtimeClient {
private handle(event: RealtimeEvent) {
const id = event.response_id ?? event.response?.id ?? this.activeResponse;
switch (event.type) {
case "vocero.tool.started":
this.serverToolPending = true;
this.setStatus("processing");
break;
case "vocero.tool.failed":
this.serverToolPending = false;
this.setStatus("listening");
break;
case "session.created":
this.send({
type: "session.update",
@@ -341,6 +350,7 @@ export class RealtimeClient {
}
break;
case "input_audio_buffer.speech_started":
this.serverToolPending = false;
this.playbackPending = false;
this.audio.clear();
this.setStatus("user-speaking");
@@ -349,6 +359,7 @@ export class RealtimeClient {
this.setStatus("processing");
break;
case "response.created":
this.serverToolPending = false;
this.activeResponse = id;
this.activeResponses.add(id);
this.createInFlight = false;
@@ -463,7 +474,7 @@ export class RealtimeClient {
}
}
private busy() {
return this.activeResponses.size > 0 || this.createInFlight;
return this.activeResponses.size > 0 || this.createInFlight || this.serverToolPending;
}
private send(event: Record<string, unknown>) {
if (!this.closed && this.socket?.readyState === WebSocket.OPEN) {
@@ -367,3 +367,196 @@ describe("voice application lifecycle", () => {
expect(controller.getSnapshot().status).toBe("listening");
});
});
describe("authenticated MCP conversations", () => {
const relayUrl = () =>
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/v1/realtime`;
function enableMcp(reasoningType = "OPENAI") {
controller = new VoiceController({ phoneNumber: "+56000000001", episodeId: "episode1" });
const original = fetchMock.getMockImplementation();
let sequence = 0;
if (!original) {
throw new Error("Missing fetch mock");
}
fetchMock.mockImplementation((path, init) => {
if (path === "/api/config") {
return Promise.resolve(new Response(JSON.stringify({ mcp: true, reasoningType, s2sUrl: relayUrl() })));
}
if (path === "/api/conversations") {
sequence++;
return Promise.resolve(
new Response(
JSON.stringify({
conversationId: `conversation-${String(sequence)}`,
expiresIn: 3600,
profile: { id: "alice", name: "Alice" },
}),
),
);
}
if (path === "/api/mcp/tools") {
return Promise.resolve(
new Response(
JSON.stringify({
tools: [
{
name: "list_reports",
parameters: { properties: { request: { type: "object" } } },
requires_auth: true,
},
],
}),
),
);
}
if (path.startsWith("/api/conversations/current")) {
return Promise.resolve(new Response(null, { status: 204 }));
}
return original(path, init);
});
}
it("bootstraps once, keeps credentials out of storage, and skips legacy context loading", async () => {
enableMcp("PROXY");
const client = await start();
expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(["/api/config", "/api/conversations", "/api/mcp/tools"]);
expect(controller.getSnapshot()).toMatchObject({
profile: { name: "Alice" },
reports: null,
tools: [{ name: "list_reports" }],
});
expect(client.options.directUrl).toBe(`${relayUrl()}?conversation_id=conversation-1`);
expect(client.options.instructions).not.toContain("APIPILOT");
expect(localStorage.length).toBe(0);
expect(sessionStorage.length).toBe(0);
const discovery = fetchMock.mock.calls.find(([path]) => path === "/api/mcp/tools");
expect(new Headers(discovery?.[1]?.headers).get("X-Vocero-Conversation")).toBe("conversation-1");
await controller.stop();
const teardown = fetchMock.mock.calls.find(([path]) => path === "/api/conversations/current");
expect(teardown?.[1]).toMatchObject({ method: "DELETE", keepalive: true });
await controller.start();
expect(MockClient.instances[1]?.options.directUrl).toContain("conversation_id=conversation-2");
});
it("blocks voice startup when phone login fails and retries on a later manual start", async () => {
enableMcp();
const original = fetchMock.getMockImplementation();
let denied = true;
if (!original) {
throw new Error("Missing fetch mock");
}
fetchMock.mockImplementation((path, init) =>
path === "/api/conversations" && denied
? Promise.resolve(new Response(JSON.stringify({ detail: "Usuario no disponible." }), { status: 401 }))
: original(path, init),
);
await controller.initialize();
expect(controller.getSnapshot()).toMatchObject({
configReady: true,
contextLoading: false,
error: "Usuario no disponible.",
});
controller.offerAutoStart();
await controller.start();
expect(MockClient.instances).toHaveLength(0);
denied = false;
await controller.start();
expect(MockClient.instances).toHaveLength(1);
});
it("does not initialize voice when the phone selector is missing", async () => {
enableMcp();
controller = new VoiceController({ phoneNumber: "", episodeId: "" });
await controller.initialize();
await controller.start();
expect(MockClient.instances).toHaveLength(0);
expect(controller.getSnapshot().error).toContain("número de teléfono");
expect(fetchMock.mock.calls.map(([path]) => path)).toEqual(["/api/config"]);
});
it("binds HTTP reasoning and cancellation to the conversation and exact turn", async () => {
enableMcp();
const client = await start();
client.emit("transcript", { id: "u1", role: "user", text: "Reportes", partial: false });
const old = pendingReasoning;
const first = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning");
const firstHeaders = new Headers(first?.[1]?.headers);
expect(firstHeaders.get("X-Vocero-Conversation")).toBe("conversation-1");
client.emit("status", "user-speaking");
const interrupt = fetchMock.mock.calls.find(([path]) => path.endsWith("/interrupt"));
expect(new Headers(interrupt?.[1]?.headers).get("X-Vocero-Turn")).toBe(firstHeaders.get("X-Vocero-Turn"));
client.emit("transcript", { id: "u2", role: "user", text: "El último", partial: false });
pendingReasoning?.(new Response(JSON.stringify({ answer: "Actual" })));
await vi.advanceTimersByTimeAsync(1);
old?.(new Response(JSON.stringify({ answer: "Anterior" })));
await vi.advanceTimersByTimeAsync(1);
expect(client.renderExternalAnswer).toHaveBeenCalledExactlyOnceWith("Actual");
});
it("closes a revoked conversation without falling back to public providers", async () => {
enableMcp();
const client = await start();
client.emit("transcript", { id: "u1", role: "user", text: "Reportes", partial: false });
pendingReasoning?.(new Response(JSON.stringify({ detail: "La sesión expiró." }), { status: 401 }));
await vi.advanceTimersByTimeAsync(1);
expect(client.close).toHaveBeenCalledOnce();
expect(controller.getSnapshot()).toMatchObject({ status: "error", error: "La sesión expiró." });
expect(fetchMock.mock.calls.some(([path]) => path.startsWith("/api/user/"))).toBe(false);
});
it("cleans up a conversation whose catalog arrives after teardown", async () => {
enableMcp();
const original = fetchMock.getMockImplementation();
let finishCatalog: ((value: Response) => void) | undefined;
if (!original) {
throw new Error("Missing fetch mock");
}
fetchMock.mockImplementation((path, init) =>
path === "/api/mcp/tools"
? new Promise<Response>((resolve) => {
finishCatalog = resolve;
})
: original(path, init),
);
const initializing = controller.initialize();
await vi.advanceTimersByTimeAsync(1);
await controller.stop();
finishCatalog?.(new Response(JSON.stringify({ tools: [] })));
await initializing;
expect(controller.getSnapshot()).toMatchObject({
profile: null,
tools: [],
status: "closed",
contextLoading: false,
});
expect(
fetchMock.mock.calls.some(([path, init]) => path === "/api/conversations/current" && init?.method === "DELETE"),
).toBe(true);
expect(MockClient.instances).toHaveLength(0);
});
it("keeps separate conversation IDs in separate controller instances", async () => {
enableMcp();
await controller.initialize();
const other = new VoiceController({ phoneNumber: "5678", episodeId: "" });
await other.initialize();
await controller.start();
await other.start();
expect(MockClient.instances.map((client) => client.options.directUrl)).toEqual([
`${relayUrl()}?conversation_id=conversation-1`,
`${relayUrl()}?conversation_id=conversation-2`,
]);
await other.stop();
});
it("does not forward the conversation ID to a direct external speech service", async () => {
enableMcp();
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({ mcp: true, reasoningType: "OPENAI", s2sUrl: "wss://speech.example.test/v1/realtime" }),
),
);
const client = await start();
expect(client.options.directUrl).toBe("wss://speech.example.test/v1/realtime");
});
});
+140 -39
View File
@@ -2,11 +2,17 @@ import { z } from "zod";
import { createAudioContext } from "../audio/engine";
import {
ApiError,
conversationHeaders,
createConversation,
endConversation,
errorMessage,
getConfig,
getMcpTools,
getProfile,
getReports,
getTools,
interruptConversation,
post,
reasoningSchema,
searchSchema,
@@ -79,6 +85,9 @@ export class VoiceController {
private sessionNumber = 0;
private reasoned = new Set<string>();
private autoStarted = false;
private conversationId: string | null = null;
private conversationVersion = 0;
private starting = false;
constructor(caller = callerFromLocation()) {
this.caller = caller;
@@ -149,47 +158,91 @@ export class VoiceController {
}
parent?.addEventListener("abort", cancel, { once: true });
this.update({ loading: true, configReady: false, configError: "", contextLoading: !!this.caller.phoneNumber });
const configuration = getConfig(signal);
const profile = getProfile(this.caller.phoneNumber, signal)
.then((profile) => {
if (!signal.aborted) {
this.update({ profile, contextLoading: false });
this.pushSettings();
}
})
.catch(() => undefined);
const reports = getReports(this.caller.phoneNumber, signal)
.then((reports) => {
if (!signal.aborted) {
this.update({ reports });
}
})
.catch(() => undefined);
const configure = async () => {
try {
const config = await configuration;
if (signal.aborted) {
return;
}
this.update({ config, configReady: true, loading: false });
if (config.apipilot) {
await profile;
try {
const config = await getConfig(signal);
if (signal.aborted) {
return;
}
this.update({
config,
configReady: true,
loading: false,
contextLoading: config.mcp || !!this.caller.phoneNumber,
});
if (config.mcp) {
try {
await this.openConversation(signal);
} catch (error) {
if (!signal.aborted) {
await this.refreshTools(signal, !!this.state.profile);
this.update({ error: errorMessage(error) });
}
}
} catch (error) {
if (!signal.aborted) {
this.update({ loading: false, configError: `No se pudo cargar la configuración. ${errorMessage(error)}` });
}
return;
}
const profile = getProfile(this.caller.phoneNumber, signal)
.then((profile) => {
if (!signal.aborted) {
this.update({ profile, contextLoading: false });
this.pushSettings();
}
})
.catch(() => undefined);
const reports = getReports(this.caller.phoneNumber, signal)
.then((reports) => {
if (!signal.aborted) {
this.update({ reports });
}
})
.catch(() => undefined);
const tools = profile.then(async () => {
if (config.apipilot && !signal.aborted) {
await this.refreshTools(signal, !!this.state.profile);
}
});
await Promise.all([profile, reports, tools]);
} catch (error) {
if (!signal.aborted) {
this.update({ loading: false, configError: `No se pudo cargar la configuración. ${errorMessage(error)}` });
}
};
try {
await Promise.all([configure(), profile, reports]);
} finally {
parent?.removeEventListener("abort", cancel);
}
}
private async openConversation(signal: AbortSignal) {
const version = ++this.conversationVersion;
let createdId: string | undefined;
this.releaseConversation();
this.update({ contextLoading: true, profile: null, reports: null, tools: [], toolsLoading: true, toolsError: "" });
try {
if (!this.caller.phoneNumber) {
throw new Error("Abre Vocero desde un enlace que incluya tu número de teléfono.");
}
const conversation = await createConversation(this.caller.phoneNumber, signal);
createdId = conversation.conversationId;
const tools = await getMcpTools(createdId, signal);
signal.throwIfAborted();
if (version !== this.conversationVersion) {
throw new DOMException("Conversation replaced", "AbortError");
}
this.conversationId = createdId;
this.update({ profile: conversation.profile, tools, error: "" });
} catch (error) {
if (createdId) {
endConversation(createdId);
}
throw error;
} finally {
if (version === this.conversationVersion) {
this.update({ contextLoading: false, toolsLoading: false });
}
}
}
private releaseConversation() {
if (this.conversationId) {
endConversation(this.conversationId);
this.conversationId = null;
}
}
dispose() {
this.initialization?.abort();
void this.stop();
@@ -197,7 +250,9 @@ export class VoiceController {
async refreshTools(signal: AbortSignal, refresh = false) {
this.update({ toolsLoading: true, toolsError: "" });
try {
const tools = await getTools(signal, refresh);
const tools = this.state.config.mcp
? await getMcpTools(this.conversationId ?? "", signal)
: await getTools(signal, refresh);
if (!signal.aborted) {
this.update({ tools, toolsLoading: false });
this.pushSettings();
@@ -265,7 +320,13 @@ export class VoiceController {
this.update({ wakeWordEnabled });
}
offerAutoStart() {
if (this.autoStarted || this.client || !this.state.configReady || this.state.contextLoading) {
if (
this.autoStarted ||
this.client ||
!this.state.configReady ||
this.state.contextLoading ||
(this.state.config.mcp && !this.conversationId)
) {
return;
}
this.autoStarted = true;
@@ -275,12 +336,14 @@ export class VoiceController {
}
}
async start(automatic = false, preparedContext?: AudioContext, greeting?: string) {
if (this.client || !this.state.configReady || this.state.contextLoading) {
if (this.client || this.starting || !this.state.configReady || this.state.contextLoading) {
if (preparedContext) {
void preparedContext.close().catch(() => undefined);
}
return;
}
this.starting = true;
this.requests = new AbortController();
this.update({
error: "",
notice: "",
@@ -293,14 +356,36 @@ export class VoiceController {
if (!navigator.mediaDevices?.getUserMedia || typeof AudioContext === "undefined") {
throw new Error("El micrófono necesita HTTPS o localhost y un navegador compatible.");
}
const target = connectionTarget(this.state.config, this.state.settings, this.caller);
const target = connectionTarget(
this.state.config,
this.state.settings,
this.state.config.mcp ? { phoneNumber: "", episodeId: "" } : this.caller,
);
context = preparedContext ?? createAudioContext();
if (automatic && context.state !== "running") {
void context.close().catch(() => undefined);
this.update({ status: "idle", notice: "Toca el micrófono para empezar a conversar." });
return;
}
this.requests = new AbortController();
if (this.state.config.mcp) {
if (!this.conversationId) {
await this.openConversation(this.requests.signal);
}
this.requests.signal.throwIfAborted();
if (target.directUrl) {
const url = new URL(target.directUrl);
const sameOrigin =
url.host === window.location.host &&
url.protocol === (window.location.protocol === "https:" ? "wss:" : "ws:");
if (this.state.config.reasoningType === "PROXY" && (!sameOrigin || url.pathname !== "/v1/realtime")) {
throw new Error("La conexión de voz debe pasar por Vocero para consultar AquaPortal.");
}
if (sameOrigin && url.pathname === "/v1/realtime") {
url.searchParams.set("conversation_id", this.conversationId ?? "");
target.directUrl = url.toString();
}
}
}
this.reasoned.clear();
this.sessionNumber++;
const client = new RealtimeClient({
@@ -415,7 +500,11 @@ export class VoiceController {
if (context) {
void context.close().catch(() => undefined);
}
this.reportError(error);
if (!this.requests.signal.aborted) {
this.reportError(error);
}
} finally {
this.starting = false;
}
}
private reportError(error: unknown) {
@@ -446,6 +535,9 @@ export class VoiceController {
});
}
private cancelReasoning() {
if (this.reasoningRequest && this.conversationId) {
interruptConversation(this.conversationId, this.reasoningTurn);
}
this.reasoningTurn++;
this.reasoningRequest?.abort();
this.reasoningRequest = null;
@@ -476,6 +568,7 @@ export class VoiceController {
episode_id: this.caller.episodeId || null,
},
request.signal,
config.mcp ? conversationHeaders(this.conversationId ?? "", turn) : undefined,
);
if (!current()) {
return;
@@ -513,6 +606,9 @@ export class VoiceController {
if (current()) {
this.externalSpeaking = false;
this.update({ error: errorMessage(error) });
if (config.mcp && error instanceof ApiError && error.status === 401) {
await this.stop("error");
}
}
} finally {
if (turn === this.reasoningTurn) {
@@ -592,6 +688,8 @@ export class VoiceController {
await this.start(false, context);
}
async stop(status: ConnectionStatus = "closed") {
this.initialization?.abort();
this.conversationVersion++;
const client = this.client;
this.client = null;
this.subscriptions.forEach((unsubscribe) => {
@@ -601,6 +699,7 @@ export class VoiceController {
clearInterval(this.idleTimer);
this.requests.abort();
this.cancelReasoning();
this.releaseConversation();
window.speechSynthesis?.cancel();
this.updateLevel(0);
this.update({
@@ -608,6 +707,8 @@ export class VoiceController {
wakeWordEnabled: false,
muted: false,
joinDeadline: 0,
contextLoading: false,
toolsLoading: false,
messages: this.state.messages.map((message) =>
message.partial ? { ...message, partial: false, interrupted: message.role === "assistant" } : message,
),