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
@@ -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");
});
});