From 528268b424974ce88171b1e01ca0651c60452bd5 Mon Sep 17 00:00:00 2001 From: Rehoboam AI Date: Tue, 27 Oct 2026 11:11:46 -0300 Subject: [PATCH] Add Ouzap (WhatsApp) to the connector catalogue Ouzap runs a hosted MCP server for WhatsApp with OAuth discovery, dynamic client registration and PKCE as a public client, the same shape as Notion, so it fits the catalogue without new machinery. Sending, replying, scheduling and creating a contact are pinned as writes; contacts:write is not requested. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +++++ server/src/plugins/catalogue.ts | 58 ++++++++++++++++++++++++++ server/tests/plugin-catalogue.test.ts | 59 +++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 500cb41634..5b54c10e7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,16 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. `computers/` on the workspace volume (below). Files saved before the upgrade stay at the root of the volume, where no Bot sees them; move them into a Bot's folder to give them back. +### Ouzap joins the connector catalogue + +Ouzap, a Brazilian WhatsApp gateway with a hosted MCP server, is now a governed connector, connected +the way Notion is: this deployment registers itself on first connect, so there is no client to set +up, and each person consents with their own Ouzap account. Bots can list, read and reply to that +number's WhatsApp conversations and start new ones. Sending, replying, scheduling and creating a +contact are writes, so a boundary written about writes covers every message a Bot could send. +`contacts:write` is not requested, so `create_contact` is refused by Ouzap. Setup is enable at +`/admin/plugins/ouzap`, connect at `/settings/connected-accounts`, refresh tools, then grant. + ### Each Bot on a shared computer has its own files A computer that serves many Bots kept one folder of files for all of them, so a note one Bot saved diff --git a/server/src/plugins/catalogue.ts b/server/src/plugins/catalogue.ts index 479fdd55e0..5639b5b18e 100644 --- a/server/src/plugins/catalogue.ts +++ b/server/src/plugins/catalogue.ts @@ -409,6 +409,64 @@ export const CATALOGUE: readonly CatalogueEntry[] = Object.freeze([ ]), docsUrl: "https://developers.notion.com/guides/mcp/build-mcp-client", }, + { + key: "ouzap", + title: "Ouzap", + vendor: "Ouzap", + summary: "WhatsApp conversations on the asker's Ouzap number.", + /* + * Ouzap's hosted MCP server, on the default MCP transport — the same shape as Notion: a public + * client that registers itself and proves itself with PKCE (`token_endpoint_auth_methods_supported` + * is `none`). + */ + host: "https://ouzap.com.br", + path: "/mcp", + auth: { + kind: "user-oauth", + // From https://ouzap.com.br/.well-known/oauth-authorization-server, verified live. + authorizationUrl: "https://ouzap.com.br/oauth/authorize", + tokenUrl: "https://ouzap.com.br/oauth/token", + /* + * Ouzap publishes no revocation_endpoint. The token endpoint stands in, as it does for Notion + * by Notion's own choice; a disconnect still drops the grant here. + */ + revokeUrl: "https://ouzap.com.br/oauth/token", + /* + * Read and reply, which is what Ouzap's consent screen describes. `contacts:write` is left out + * on purpose: nothing a Bot does in a conversation needs it, so `create_contact` fails at the + * vendor until somebody decides it should not. + */ + scopes: Object.freeze([ + "conversations", + "messages:read", + "messages:write", + ]), + clientRegistration: "dynamic", + registrationUrl: "https://ouzap.com.br/oauth/register", + }, + /* + * Everything that sends a message or creates a record. `create_contact` stays listed for the + * reason Drive's writes do. The read list below is what actually closes the gap: a write tool + * Ouzap adds later reaches a real person's phone, so it must not classify as a read just because + * nobody listed it here. + */ + writeTools: Object.freeze([ + "create_contact", + "reply_conversation", + "schedule_whatsapp_message", + "send_whatsapp_message", + ]), + // The only reads, so anything else Ouzap advertises is a write until reviewed. + readTools: Object.freeze([ + "get_message_status", + "list_conversations", + "list_scheduled_messages", + "list_whatsapp_numbers", + "read_conversation", + "search_contacts", + ]), + docsUrl: "https://ouzap.com.br/whatsapp-para-agentes", + }, { key: "routines", title: "Routines", diff --git a/server/tests/plugin-catalogue.test.ts b/server/tests/plugin-catalogue.test.ts index 39f0ed608b..0d62e85ec9 100644 --- a/server/tests/plugin-catalogue.test.ts +++ b/server/tests/plugin-catalogue.test.ts @@ -252,6 +252,65 @@ describe("Notion", () => { }); }); +describe("Ouzap", () => { + const entry = catalogueEntry("ouzap"); + + test("is in the catalogue with the MCP transport", () => { + expect(entry).not.toBeNull(); + expect(entry?.transport).toBeUndefined(); + expect(entry?.host).toBe("https://ouzap.com.br"); + expect(entry?.path).toBe("/mcp"); + }); + + test("registers its client dynamically, with every endpoint pinned to https", () => { + if (entry?.auth.kind !== "user-oauth") throw new Error("wrong auth kind"); + expect(entry.auth.clientRegistration).toBe("dynamic"); + expect(entry.auth.registrationUrl?.startsWith("https://")).toBe(true); + expect(entry.auth.authorizationUrl.startsWith("https://")).toBe(true); + expect(entry.auth.tokenUrl.startsWith("https://")).toBe(true); + // Read and reply only; contacts:write is deliberately not asked for. + expect(entry.auth.scopes).toEqual([ + "conversations", + "messages:read", + "messages:write", + ]); + }); + + test("pins the exact write list, so a dropped or renamed entry fails here", () => { + // Every one of these reaches a real person's phone, so the list is pinned literally. + expect(entry?.writeTools).toEqual([ + "create_contact", + "reply_conversation", + "schedule_whatsapp_message", + "send_whatsapp_message", + ]); + for (const name of entry?.writeTools ?? []) { + expect(classifyTool(entry, name, true)).toBe("write"); + } + expect(classifyTool(entry, "brand-new-tool", false)).toBe("write"); + }); + + test("names its reads, so a tool Ouzap adds later is a write until reviewed", () => { + expect(entry?.readTools).toEqual([ + "get_message_status", + "list_conversations", + "list_scheduled_messages", + "list_whatsapp_numbers", + "read_conversation", + "search_contacts", + ]); + for (const name of entry?.readTools ?? []) { + expect(classifyTool(entry, name, true)).toBe("read"); + } + // Advertised but unreviewed: a new sending tool must not slip through as a read. + expect(classifyTool(entry, "send_whatsapp_template", true)).toBe("write"); + // The read list does not loosen a vendor label that says write. + expect(classifyTool(entry, "list_conversations", true, "write")).toBe( + "write", + ); + }); +}); + describe("Routines", () => { const entry = catalogueEntry("routines");