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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user