@wabery/cli 0.15.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,76 @@ import { createExampleConfig } from "./config-example.js";
5
5
  import { checkWaberyConnection } from "./diagnostics.js";
6
6
  const configSchema = z.record(z.string(), z.unknown());
7
7
  const metadataSchema = z.record(z.string(), z.unknown()).optional();
8
+ const groupIdSchema = z
9
+ .string()
10
+ .min(1)
11
+ .describe("Opaque WhatsApp group_id; never use a phone number here.");
12
+ const groupJoinRequestIdsSchema = z
13
+ .array(z.string().min(1))
14
+ .min(1)
15
+ .max(8)
16
+ .describe("Bulk join-request IDs from wabery_list_group_join_requests.");
17
+ const groupParticipantSchema = z.object({
18
+ user: z
19
+ .string()
20
+ .min(1)
21
+ .describe("WhatsApp user ID, encoded in Meta's required { user } shape."),
22
+ });
23
+ const groupMessageBodySchema = z
24
+ .object({
25
+ text: z.string().min(1).max(4096).optional(),
26
+ media: z
27
+ .object({
28
+ type: z.enum(["image", "document", "video", "audio"]),
29
+ id: z.string().min(1).optional(),
30
+ link: z.string().url().optional(),
31
+ caption: z.string().max(1024).optional(),
32
+ filename: z.string().max(255).optional(),
33
+ })
34
+ .refine((media) => Boolean(media.id) !== Boolean(media.link), {
35
+ message: "Provide exactly one of media.id or media.link",
36
+ })
37
+ .optional(),
38
+ sticker: z
39
+ .object({
40
+ id: z.string().min(1).optional(),
41
+ link: z.string().url().optional(),
42
+ })
43
+ .refine((sticker) => Boolean(sticker.id) !== Boolean(sticker.link), {
44
+ message: "Provide exactly one of sticker.id or sticker.link",
45
+ })
46
+ .optional(),
47
+ template: z
48
+ .object({
49
+ id: z.string().min(1).optional(),
50
+ name: z.string().min(1).optional(),
51
+ language: z.string().min(2).max(10).optional(),
52
+ components: z.array(z.record(z.string(), z.unknown())).optional(),
53
+ })
54
+ .refine((template) => Boolean(template.id) || Boolean(template.name && template.language), {
55
+ message: "Provide template.id or both template.name and template.language",
56
+ })
57
+ .optional(),
58
+ pin: z
59
+ .discriminatedUnion("type", [
60
+ z.object({
61
+ type: z.literal("pin"),
62
+ message_id: z.string().min(1),
63
+ expiration_days: z.number().int().min(1).max(30),
64
+ }),
65
+ z.object({
66
+ type: z.literal("unpin"),
67
+ message_id: z.string().min(1),
68
+ }),
69
+ ])
70
+ .optional(),
71
+ reply_to: z.string().min(1).max(200).optional(),
72
+ idempotency_key: z.string().min(1).max(200),
73
+ })
74
+ .strict()
75
+ .refine((body) => [body.text, body.media, body.sticker, body.template, body.pin].filter(Boolean).length === 1, {
76
+ message: "Provide exactly one of text, media, sticker, template, or pin",
77
+ });
8
78
  const readinessRequirementSchema = z.enum([
9
79
  "sandbox_test",
10
80
  "production_inbound",
@@ -80,22 +150,21 @@ const broadcastAudienceFilterSchema = z.object({
80
150
  created_after: z.string().datetime().optional(),
81
151
  created_before: z.string().datetime().optional(),
82
152
  });
153
+ const MESSAGE_MUTATION_GUIDANCE = 'Inbound WhatsApp messages can use outer type "unsupported" when Cloud API does not provide the original body. Inspect unsupported.type: "revoke" and "edit" are message mutations, not unavailable media; never answer them or call wabery_send_message in response. Other unsupported subtypes are unavailable content and may be handled as such.';
83
154
  const toolOutputSchema = {
84
155
  result: z
85
156
  .record(z.string(), z.unknown())
86
- .describe("The structured Wabery API result object. Its operation-specific fields are described by the tool and Wabery API documentation."),
157
+ .describe(`The structured Wabery API result object. Its operation-specific fields are described by the tool and Wabery API documentation. ${MESSAGE_MUTATION_GUIDANCE}`),
87
158
  };
88
159
  // Claude Code caps MCP server instructions at 2KB and silently truncates from
89
160
  // the end (worse with multiple servers). Keep this text + WRITE_NOTE under
90
161
  // 1900 UTF-8 bytes as a buffer, and put invariants first. Per-tool playbooks
91
162
  // belong on tool descriptions, not in this handshake.
92
- const SERVER_INSTRUCTIONS = `Wabery is a developer platform for building and operating WhatsApp AI agents. Start discovery with wabery_list_projects, select the project, then wabery_get_project_readiness; use channel readiness only for phone-number/provider detail.
93
- Use Wabery for WhatsApp contacts, opted-in messaging, conversations and message history, approved templates, Flows, broadcasts, routing/human handoff, signed webhooks, hosted functions, and Meta Business Agent configuration.
94
- One hosted AI agent, two versions: Live is read-only customer traffic; Draft is the only editable version, tested in the shared Sandbox. Config apply, validate, and preview only create or change Draft — they never edit Live or publish. Publish Live in the Wabery AI builder after review.
95
- Keep sandbox and production readiness distinct: shared sandbox is for controlled Draft tests; production inbound, proactive templates, and broadcasts need an eligible dedicated WhatsApp channel. Enroll only controlled or opted-in contacts; keep the returned contact_id and channel_id.
96
- Read conversations and message history before replying. Apply never publishes a hosted agent or Flow; draft and validate Flows, then confirm publish or send separately.
97
- Broadcasts: approved templates, exactly one audience source, review recipients/exclusions, then confirm.
98
- Use routing and thread-control tools for automation ownership and human handoff. Hosted function test/invoke is side-effecting. Obtain explicit user confirmation before any external or irreversible action (send, publish, delete, rotate secrets, update providers, run functions). After send, call wabery_get_message, wabery_get_dispatch, or wabery_get_broadcast for provider status; acceptance is not delivery.`;
163
+ const SERVER_INSTRUCTIONS = `Wabery is a developer platform for WhatsApp AI agents. Start with wabery_list_projects, select a project, then wabery_get_project_readiness; channel readiness is for provider detail.
164
+ Use Wabery for WhatsApp contacts, opted-in messaging, conversations and message history, approved templates, Flows, broadcasts, Groups, routing/human handoff, signed webhooks, hosted functions, and Business Agent configuration.
165
+ Sandbox is for controlled Draft tests; production inbound, proactive templates, and broadcasts need an eligible dedicated channel. Enroll only controlled or opted-in contacts and retain contact_id/channel_id. Live hosted-agent traffic is read-only; only Draft is editable. Config apply, validate, and preview never publish; publish in the Wabery AI builder.
166
+ Before replying, inspect message history. For inbound type unsupported, inspect unsupported.type: revoke/edit are mutations and must not be answered; other subtypes are unavailable content. Draft and validate Flows, then confirm publish or send separately. Broadcasts require approved templates, one reviewed audience source, and confirmation.
167
+ Use routing and thread-control tools for human handoff. Groups require an Official Business Account and Cloud API number; max 8 participants, 10k groups per number, one Cloud API business per group. Group commerce, interactive, calls, ephemeral, view-once, auth, edit, and delete messages are unsupported. Obtain explicit user confirmation before external or irreversible actions, including group sends/mutations, send, publish, delete, rotate, provider updates, and function runs. After a send, inspect provider status with wabery_get_message, wabery_get_dispatch, or wabery_get_broadcast; acceptance is not delivery.`;
99
168
  // The write gate differs by transport, and the model has to be told the truth
100
169
  // about the session it is actually in: a hosted OAuth client (ChatGPT, Claude)
101
170
  // cannot set an env var, so advertising the env gate there makes it refuse to
@@ -208,10 +277,24 @@ export function createWaberyMcpServer(client = new WaberyApiClient(), options =
208
277
  name: "wabery",
209
278
  version: options.serverVersion ?? "0.1.0",
210
279
  }, { instructions: serverInstructions(writePolicy) });
211
- const registerTool = (name, config, callback) => server.registerTool(name, {
212
- outputSchema: toolOutputSchema,
213
- ...config,
214
- }, callback);
280
+ const registerTool = (name, config, callback) => {
281
+ options.onToolRegistered?.({
282
+ name,
283
+ title: config.title,
284
+ description: config.description,
285
+ readOnly: Boolean(config.annotations.readOnlyHint),
286
+ });
287
+ return server.registerTool(name, {
288
+ outputSchema: toolOutputSchema,
289
+ ...config,
290
+ }, callback);
291
+ };
292
+ const groupPath = (channelId, groupId) => {
293
+ const base = `/channels/${encodeURIComponent(channelId)}/whatsapp-groups`;
294
+ return groupId === undefined
295
+ ? base
296
+ : `${base}/${encodeURIComponent(groupId)}`;
297
+ };
215
298
  const requireWrite = async (action, token, expectedToken) => {
216
299
  const modeBlocked = checkWriteMode(action, undefined, undefined, writePolicy);
217
300
  if (modeBlocked)
@@ -931,13 +1014,17 @@ export function createWaberyMcpServer(client = new WaberyApiClient(), options =
931
1014
  }, async ({ project_id }) => jsonResult(await client.get(`/projects/${encodeURIComponent(project_id)}`)));
932
1015
  registerTool("wabery_update_project", {
933
1016
  title: "Update a Wabery project",
934
- description: "Use this when you need to overwrite project name, description, routing_mode, require_opt_in, or signed-webhook settings for a project_id from wabery_list_projects. Prefer wabery_apply_config for reviewed project-wide declarative changes.",
1017
+ description: "Use this when you need to overwrite project name, description, routing_mode, require_opt_in, group management webhook opt-in, or signed-webhook settings for a project_id from wabery_list_projects. Prefer wabery_apply_config for reviewed project-wide declarative changes.",
935
1018
  inputSchema: {
936
1019
  project_id: z.string().min(1),
937
1020
  name: z.string().min(1).max(100).optional(),
938
1021
  description: z.string().max(500).nullable().optional(),
939
1022
  routing_mode: z.enum(["NONE", "FLOWS", "EXTERNAL"]).optional(),
940
1023
  require_opt_in: z.boolean().optional(),
1024
+ group_webhook_events_enabled: z
1025
+ .boolean()
1026
+ .optional()
1027
+ .describe("Explicitly opt into the four new WhatsApp group management event names; defaults false."),
941
1028
  webhook_url: z.string().url().nullable().optional(),
942
1029
  webhook_signing_enabled: z.boolean().optional(),
943
1030
  webhook_secret: z.string().min(16).max(256).optional(),
@@ -1467,6 +1554,10 @@ export function createWaberyMcpServer(client = new WaberyApiClient(), options =
1467
1554
  inputSchema: {
1468
1555
  contact_id: z.string().optional(),
1469
1556
  active: z.boolean().optional(),
1557
+ recipient_type: z
1558
+ .enum(["individual", "group", "all"])
1559
+ .optional()
1560
+ .describe("Defaults to individual for backward compatibility; use group or all explicitly."),
1470
1561
  limit: z.number().int().min(1).max(100).optional(),
1471
1562
  starting_after: z.string().optional(),
1472
1563
  },
@@ -1485,6 +1576,7 @@ export function createWaberyMcpServer(client = new WaberyApiClient(), options =
1485
1576
  description: "Use this when you need one organization-scoped WhatsApp conversation by conversation_id from wabery_list_conversations.",
1486
1577
  inputSchema: {
1487
1578
  conversation_id: z.string().min(1),
1579
+ recipient_type: z.enum(["individual", "group"]).optional(),
1488
1580
  },
1489
1581
  annotations: {
1490
1582
  readOnlyHint: true,
@@ -1492,12 +1584,13 @@ export function createWaberyMcpServer(client = new WaberyApiClient(), options =
1492
1584
  idempotentHint: true,
1493
1585
  openWorldHint: false,
1494
1586
  },
1495
- }, async ({ conversation_id }) => jsonResult(await client.get(`/conversations/${conversation_id}`)));
1587
+ }, async ({ conversation_id, ...query }) => jsonResult(await client.get(`/conversations/${conversation_id}`, query)));
1496
1588
  registerTool("wabery_list_conversation_messages", {
1497
1589
  title: "List Wabery conversation messages",
1498
- description: "Use this when you need message history for a conversation_id from wabery_list_conversations. Retained inbound media may include short-lived signed URLs; expires_at is the retention deadline. type can be unsupported when WhatsApp Cloud API withheld the original content (official errors 131051 / 131060). type revoke/edit are official coexistence delete/edit events with original_message_id.",
1590
+ description: `Use this when you need message history for a conversation_id from wabery_list_conversations. Retained inbound media may include short-lived signed URLs; expires_at is the retention deadline. ${MESSAGE_MUTATION_GUIDANCE} Direct type revoke/edit are also official coexistence delete/edit events; inspect original_message_id when present.`,
1499
1591
  inputSchema: {
1500
1592
  conversation_id: z.string().min(1),
1593
+ recipient_type: z.enum(["individual", "group"]).optional(),
1501
1594
  limit: z.number().int().min(1).max(100).optional(),
1502
1595
  starting_after: z.string().optional(),
1503
1596
  order: z.enum(["asc", "desc"]).optional(),
@@ -1509,13 +1602,298 @@ export function createWaberyMcpServer(client = new WaberyApiClient(), options =
1509
1602
  openWorldHint: false,
1510
1603
  },
1511
1604
  }, async ({ conversation_id, ...query }) => jsonResult(await client.get(`/conversations/${conversation_id}/messages`, query)));
1605
+ registerTool("wabery_list_groups", {
1606
+ title: "List WhatsApp groups",
1607
+ description: "Use this when you need organization-scoped WhatsApp group_id values. A group_id is an opaque Meta ID, not a phone number or the messages.send to field. Groups require an eligible Official Business Account Cloud API channel. limit defaults to 25 and accepts 1–1024; starting_after/ending_before (or after/before) are opaque cursors.",
1608
+ inputSchema: {
1609
+ channel_id: z.string().min(1),
1610
+ limit: z.number().int().min(1).max(1024).optional(),
1611
+ starting_after: z.string().optional(),
1612
+ ending_before: z.string().optional(),
1613
+ after: z.string().optional(),
1614
+ before: z.string().optional(),
1615
+ },
1616
+ annotations: {
1617
+ readOnlyHint: true,
1618
+ destructiveHint: false,
1619
+ idempotentHint: true,
1620
+ openWorldHint: false,
1621
+ },
1622
+ }, async ({ channel_id, ...query }) => jsonResult(await client.get(groupPath(channel_id), query)));
1623
+ registerTool("wabery_get_group", {
1624
+ title: "Get WhatsApp group",
1625
+ description: "Use this when you need group settings, suspension state, participant count, or participant data for one opaque group_id from wabery_list_groups. The participant count excludes the business account.",
1626
+ inputSchema: {
1627
+ channel_id: z.string().min(1),
1628
+ group_id: groupIdSchema,
1629
+ },
1630
+ annotations: {
1631
+ readOnlyHint: true,
1632
+ destructiveHint: false,
1633
+ idempotentHint: true,
1634
+ openWorldHint: false,
1635
+ },
1636
+ }, async ({ channel_id, group_id }) => jsonResult(await client.get(groupPath(channel_id, group_id))));
1637
+ registerTool("wabery_create_group", {
1638
+ title: "Create WhatsApp group",
1639
+ description: "Use this when you need to create a WhatsApp group on an eligible Official Business Account Cloud API channel. Supply a caller-generated stable idempotency_key and reuse it for every retry. Meta limits groups to 8 participants and 10,000 groups per business number; explicit confirmation is required.",
1640
+ inputSchema: {
1641
+ channel_id: z.string().min(1),
1642
+ subject: z.string().min(1).max(128),
1643
+ description: z.string().max(2048).optional(),
1644
+ join_approval_mode: z
1645
+ .enum(["auto_approve", "approval_required"])
1646
+ .optional(),
1647
+ idempotency_key: z
1648
+ .string()
1649
+ .trim()
1650
+ .min(1)
1651
+ .max(200)
1652
+ .describe("Stable caller-generated key reused for every retry of this group creation."),
1653
+ confirmation_token: z.string().optional(),
1654
+ },
1655
+ annotations: {
1656
+ readOnlyHint: false,
1657
+ destructiveHint: true,
1658
+ idempotentHint: true,
1659
+ openWorldHint: true,
1660
+ },
1661
+ }, async ({ channel_id, confirmation_token, idempotency_key, ...body }) => {
1662
+ const blocked = await requireWrite("wabery_create_group", confirmation_token, "create_group");
1663
+ if (blocked)
1664
+ return blocked;
1665
+ return apiResult(client.post(groupPath(channel_id), body, idempotency_key));
1666
+ });
1667
+ registerTool("wabery_update_group_settings", {
1668
+ title: "Update WhatsApp group settings",
1669
+ description: "Use this when you need to change a group's subject or description. Meta reports the final result asynchronously through group.settings; explicit confirmation is required.",
1670
+ inputSchema: {
1671
+ channel_id: z.string().min(1),
1672
+ group_id: groupIdSchema,
1673
+ subject: z.string().min(1).max(128).optional(),
1674
+ description: z.string().max(2048).optional(),
1675
+ confirmation_token: z.string().optional(),
1676
+ },
1677
+ annotations: {
1678
+ readOnlyHint: false,
1679
+ destructiveHint: true,
1680
+ idempotentHint: true,
1681
+ openWorldHint: true,
1682
+ },
1683
+ }, async ({ channel_id, group_id, confirmation_token, ...body }) => {
1684
+ const blocked = await requireWrite("wabery_update_group_settings", confirmation_token, "update_group_settings");
1685
+ if (blocked)
1686
+ return blocked;
1687
+ return apiResult(client.post(groupPath(channel_id, group_id), body));
1688
+ });
1689
+ registerTool("wabery_update_group_profile_picture", {
1690
+ title: "Update WhatsApp group profile picture",
1691
+ description: "Use this when you need to upload a JPEG group profile picture as base64. Meta validates square aspect ratio and minimum dimensions and reports the final result asynchronously through group.settings. Explicit confirmation is required.",
1692
+ inputSchema: {
1693
+ channel_id: z.string().min(1),
1694
+ group_id: groupIdSchema,
1695
+ jpeg_base64: z.string().min(4).max(8_000_000),
1696
+ filename: z.string().min(1).max(240).optional(),
1697
+ subject: z.string().min(1).max(128).optional(),
1698
+ description: z.string().max(2048).optional(),
1699
+ confirmation_token: z.string().optional(),
1700
+ },
1701
+ annotations: {
1702
+ readOnlyHint: false,
1703
+ destructiveHint: true,
1704
+ idempotentHint: true,
1705
+ openWorldHint: true,
1706
+ },
1707
+ }, async ({ channel_id, group_id, jpeg_base64, filename, subject, description, confirmation_token, }) => {
1708
+ const blocked = await requireWrite("wabery_update_group_profile_picture", confirmation_token, "update_group_profile_picture");
1709
+ if (blocked)
1710
+ return blocked;
1711
+ const bytes = Buffer.from(jpeg_base64, "base64");
1712
+ if (bytes.length < 3 ||
1713
+ bytes[0] !== 0xff ||
1714
+ bytes[1] !== 0xd8 ||
1715
+ bytes[2] !== 0xff) {
1716
+ throw new Error("jpeg_base64 must decode to a JPEG file");
1717
+ }
1718
+ const form = new FormData();
1719
+ form.set("profile_picture_file", new Blob([bytes], { type: "image/jpeg" }), filename ?? "profile.jpg");
1720
+ if (subject !== undefined)
1721
+ form.set("subject", subject);
1722
+ if (description !== undefined)
1723
+ form.set("description", description);
1724
+ return apiResult(client.postForm(groupPath(channel_id, group_id), form));
1725
+ });
1726
+ registerTool("wabery_get_group_invite_link", {
1727
+ title: "Get WhatsApp group invite link",
1728
+ description: "Use this when you need the current chat.whatsapp.com invite link for a group_id. Reading the link is safe; share it only with the intended audience.",
1729
+ inputSchema: {
1730
+ channel_id: z.string().min(1),
1731
+ group_id: groupIdSchema,
1732
+ },
1733
+ annotations: {
1734
+ readOnlyHint: true,
1735
+ destructiveHint: false,
1736
+ idempotentHint: true,
1737
+ openWorldHint: false,
1738
+ },
1739
+ }, async ({ channel_id, group_id }) => jsonResult(await client.get(`${groupPath(channel_id, group_id)}/invite-link`)));
1740
+ registerTool("wabery_reset_group_invite_link", {
1741
+ title: "Reset WhatsApp group invite link",
1742
+ description: "Use this when you need Meta to invalidate the current group invite link and issue a new one. Existing links stop working; explicit confirmation is required.",
1743
+ inputSchema: {
1744
+ channel_id: z.string().min(1),
1745
+ group_id: groupIdSchema,
1746
+ confirmation_token: z.string().optional(),
1747
+ },
1748
+ annotations: {
1749
+ readOnlyHint: false,
1750
+ destructiveHint: true,
1751
+ idempotentHint: false,
1752
+ openWorldHint: true,
1753
+ },
1754
+ }, async ({ channel_id, group_id, confirmation_token }) => {
1755
+ const blocked = await requireWrite("wabery_reset_group_invite_link", confirmation_token, "reset_group_invite_link");
1756
+ if (blocked)
1757
+ return blocked;
1758
+ return apiResult(client.post(`${groupPath(channel_id, group_id)}/invite-link`));
1759
+ });
1760
+ registerTool("wabery_list_group_join_requests", {
1761
+ title: "List WhatsApp group join requests",
1762
+ description: "Use this when you need pending join-request IDs before approving or rejecting requests in bulk. limit defaults to 25 and accepts 1–1024; starting_after/ending_before (or after/before) are opaque cursors. Pass the returned opaque IDs to the corresponding mutation tool.",
1763
+ inputSchema: {
1764
+ channel_id: z.string().min(1),
1765
+ group_id: groupIdSchema,
1766
+ limit: z.number().int().min(1).max(1024).optional(),
1767
+ starting_after: z.string().optional(),
1768
+ ending_before: z.string().optional(),
1769
+ after: z.string().optional(),
1770
+ before: z.string().optional(),
1771
+ },
1772
+ annotations: {
1773
+ readOnlyHint: true,
1774
+ destructiveHint: false,
1775
+ idempotentHint: true,
1776
+ openWorldHint: false,
1777
+ },
1778
+ }, async ({ channel_id, group_id, ...query }) => jsonResult(await client.get(`${groupPath(channel_id, group_id)}/join-requests`, query)));
1779
+ registerTool("wabery_approve_group_join_requests", {
1780
+ title: "Approve WhatsApp group join requests",
1781
+ description: "Use this when you need to approve one or more pending group join-request IDs. This uses Meta's bulk join_requests contract and requires explicit confirmation.",
1782
+ inputSchema: {
1783
+ channel_id: z.string().min(1),
1784
+ group_id: groupIdSchema,
1785
+ join_request_ids: groupJoinRequestIdsSchema,
1786
+ confirmation_token: z.string().optional(),
1787
+ },
1788
+ annotations: {
1789
+ readOnlyHint: false,
1790
+ destructiveHint: true,
1791
+ idempotentHint: true,
1792
+ openWorldHint: true,
1793
+ },
1794
+ }, async ({ channel_id, group_id, join_request_ids, confirmation_token }) => {
1795
+ const blocked = await requireWrite("wabery_approve_group_join_requests", confirmation_token, "approve_group_join_requests");
1796
+ if (blocked)
1797
+ return blocked;
1798
+ return apiResult(client.post(`${groupPath(channel_id, group_id)}/join-requests`, {
1799
+ join_requests: join_request_ids,
1800
+ }));
1801
+ });
1802
+ registerTool("wabery_reject_group_join_requests", {
1803
+ title: "Reject WhatsApp group join requests",
1804
+ description: "Use this when you need to reject one or more pending group join-request IDs. This uses Meta's bulk join_requests contract and requires explicit confirmation.",
1805
+ inputSchema: {
1806
+ channel_id: z.string().min(1),
1807
+ group_id: groupIdSchema,
1808
+ join_request_ids: groupJoinRequestIdsSchema,
1809
+ confirmation_token: z.string().optional(),
1810
+ },
1811
+ annotations: {
1812
+ readOnlyHint: false,
1813
+ destructiveHint: true,
1814
+ idempotentHint: true,
1815
+ openWorldHint: true,
1816
+ },
1817
+ }, async ({ channel_id, group_id, join_request_ids, confirmation_token }) => {
1818
+ const blocked = await requireWrite("wabery_reject_group_join_requests", confirmation_token, "reject_group_join_requests");
1819
+ if (blocked)
1820
+ return blocked;
1821
+ return apiResult(client.delete(`${groupPath(channel_id, group_id)}/join-requests`, {
1822
+ join_requests: join_request_ids,
1823
+ }));
1824
+ });
1825
+ registerTool("wabery_remove_group_participants", {
1826
+ title: "Remove WhatsApp group participants",
1827
+ description: "Use this when you need to remove WhatsApp users from a group. Pass participant user IDs in Meta's required participants:[{user}] shape; partial failures are returned per participant. Explicit confirmation is required.",
1828
+ inputSchema: {
1829
+ channel_id: z.string().min(1),
1830
+ group_id: groupIdSchema,
1831
+ participants: z.array(groupParticipantSchema).min(1).max(8),
1832
+ confirmation_token: z.string().optional(),
1833
+ },
1834
+ annotations: {
1835
+ readOnlyHint: false,
1836
+ destructiveHint: true,
1837
+ idempotentHint: true,
1838
+ openWorldHint: true,
1839
+ },
1840
+ }, async ({ channel_id, group_id, participants, confirmation_token }) => {
1841
+ const blocked = await requireWrite("wabery_remove_group_participants", confirmation_token, "remove_group_participants");
1842
+ if (blocked)
1843
+ return blocked;
1844
+ return apiResult(client.delete(`${groupPath(channel_id, group_id)}/participants`, {
1845
+ participants: participants.map(({ user }) => user),
1846
+ }));
1847
+ });
1848
+ registerTool("wabery_delete_group", {
1849
+ title: "Delete WhatsApp group",
1850
+ description: "Use this when you need to permanently delete a Wabery-managed WhatsApp group. This is irreversible at the provider and requires explicit confirmation.",
1851
+ inputSchema: {
1852
+ channel_id: z.string().min(1),
1853
+ group_id: groupIdSchema,
1854
+ confirmation_token: z.string().optional(),
1855
+ },
1856
+ annotations: {
1857
+ readOnlyHint: false,
1858
+ destructiveHint: true,
1859
+ idempotentHint: true,
1860
+ openWorldHint: true,
1861
+ },
1862
+ }, async ({ channel_id, group_id, confirmation_token }) => {
1863
+ const blocked = await requireWrite("wabery_delete_group", confirmation_token, "delete_group");
1864
+ if (blocked)
1865
+ return blocked;
1866
+ return apiResult(client.delete(groupPath(channel_id, group_id)));
1867
+ });
1868
+ registerTool("wabery_send_group_message", {
1869
+ title: "Send WhatsApp group message",
1870
+ description: "Use this when you need to send a supported WhatsApp message to a group_id through POST /messages. group_id is an opaque Meta ID and is mutually exclusive with conversation_id/to. Groups support text, media, and text/media templates, but not commerce or interactive messages. Explicit confirmation is required.",
1871
+ inputSchema: {
1872
+ channel_id: z.string().min(1),
1873
+ group_id: groupIdSchema,
1874
+ body: groupMessageBodySchema.describe("Exactly one supported group content field plus a stable idempotency_key that must be reused for retries. reply_to is optional."),
1875
+ confirmation_token: z.string().optional(),
1876
+ },
1877
+ annotations: {
1878
+ readOnlyHint: false,
1879
+ destructiveHint: true,
1880
+ idempotentHint: false,
1881
+ openWorldHint: true,
1882
+ },
1883
+ }, async ({ channel_id, group_id, body, confirmation_token }) => {
1884
+ const blocked = await requireWrite("wabery_send_group_message", confirmation_token, "send_group_message");
1885
+ if (blocked)
1886
+ return blocked;
1887
+ const idempotencyKey = body.idempotency_key;
1888
+ return apiResult(client.post("/messages", { ...body, channel_id, group_id, idempotency_key: idempotencyKey }, idempotencyKey));
1889
+ });
1512
1890
  registerTool("wabery_send_message", {
1513
1891
  title: "Send WhatsApp message",
1514
- description: "Use this when you need to send one real WhatsApp text, approved template, media, or interactive message. Do not use it to send a Flow; use wabery_send_flow. The body must identify channel and recipient using ids from list tools and should include idempotency_key; explicit confirmation is required.",
1892
+ description: "Use this when you need to send one real WhatsApp text, approved template, media, or interactive message. Do not use it to send a Flow; use wabery_send_flow. The body must identify channel and exactly one recipient: conversation_id, to, or opaque group_id. group_id is not a phone number; use wabery_send_group_message for the dedicated group-safe path. Include idempotency_key; explicit confirmation is required.",
1515
1893
  inputSchema: {
1516
1894
  body: z
1517
1895
  .record(z.string(), z.unknown())
1518
- .describe("POST /messages body. Include channel_id plus contact_id, conversation_id, or to; include type-specific content and an idempotency_key for safe retries."),
1896
+ .describe("POST /messages body. Include channel_id plus exactly one of conversation_id, to, or group_id; include type-specific content and an idempotency_key for safe retries."),
1519
1897
  confirmation_token: z
1520
1898
  .string()
1521
1899
  .optional()
@@ -1531,11 +1909,21 @@ export function createWaberyMcpServer(client = new WaberyApiClient(), options =
1531
1909
  const blocked = await requireWrite("wabery_send_message", confirmation_token, "send_message");
1532
1910
  if (blocked)
1533
1911
  return blocked;
1534
- return apiResult(client.post("/messages", body));
1912
+ const groupIdempotencyKey = typeof body.group_id === "string"
1913
+ ? typeof body.idempotency_key === "string" && body.idempotency_key
1914
+ ? body.idempotency_key
1915
+ : null
1916
+ : undefined;
1917
+ if (groupIdempotencyKey === null) {
1918
+ throw new Error("WhatsApp group sends require a stable idempotency_key that is reused for retries.");
1919
+ }
1920
+ return apiResult(client.post("/messages", groupIdempotencyKey
1921
+ ? { ...body, idempotency_key: groupIdempotencyKey }
1922
+ : body, groupIdempotencyKey));
1535
1923
  });
1536
1924
  registerTool("wabery_get_message", {
1537
1925
  title: "Get Wabery message",
1538
- description: "Use this when you need queued, provider, delivery, or failure status for a message_id returned by wabery_send_message or message history. An accepted send is not proof of delivery. Inbound type unsupported means Cloud API delivered Meta's official unsupported envelope instead of the original body.",
1926
+ description: `Use this when you need queued, provider, delivery, or failure status for a message_id returned by wabery_send_message or message history. An accepted send is not proof of delivery. ${MESSAGE_MUTATION_GUIDANCE}`,
1539
1927
  inputSchema: {
1540
1928
  message_id: z.string().min(1),
1541
1929
  },
@@ -1904,6 +2292,24 @@ async function waitUntilTemplateApproved(client, templateId, { timeoutMs, interv
1904
2292
  await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, Math.max(0, deadline - Date.now()))));
1905
2293
  }
1906
2294
  }
2295
+ let cachedPublicMcpTools = null;
2296
+ export function listPublicMcpTools() {
2297
+ if (cachedPublicMcpTools)
2298
+ return cachedPublicMcpTools;
2299
+ const tools = [];
2300
+ createWaberyMcpServer(new WaberyApiClient({
2301
+ apiKey: "public-catalog",
2302
+ baseUrl: "https://api.wabery.com/v1",
2303
+ }), {
2304
+ writePolicy: "enabled",
2305
+ serverVersion: "catalog",
2306
+ onToolRegistered: (tool) => {
2307
+ tools.push(tool);
2308
+ },
2309
+ });
2310
+ cachedPublicMcpTools = tools;
2311
+ return tools;
2312
+ }
1907
2313
  export async function runMcpServer(client = new WaberyApiClient()) {
1908
2314
  const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
1909
2315
  const server = createWaberyMcpServer(client);