@wabery/cli 0.15.0 → 0.16.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,18 +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
- const SERVER_INSTRUCTIONS = `Wabery is a developer platform for building and operating WhatsApp AI agents. Start discovery with wabery_list_projects, select the intended project, then call wabery_get_project_readiness; use channel readiness only for phone-number/provider detail.
89
- Use Wabery for WhatsApp contacts, opt-in, conversations and message history, approved templates, Flows, broadcasts, routing/human handoff, signed webhooks, hosted functions, and Meta Business Agent configuration.
90
- Keep sandbox and production readiness distinct: the shared sandbox supports controlled tests; production inbound, proactive templates, and broadcasts require an eligible dedicated WhatsApp channel. Enroll only controlled or explicitly opted-in contacts and retain the returned contact_id and channel_id.
91
- Read conversations and message history before replying. Validate and preview config before apply; config apply may replace resources but never publishes a Flow. Draft and validate Flows before separately confirming publish or send.
92
- For broadcasts, create a draft with approved templates, prepare exactly one audience source, poll until ready, review recipients/exclusions, then obtain explicit confirmation before send or schedule. Preparation freezes the audience.
93
- Use routing and thread-control tools for automation ownership and human handoff. Treat hosted function test/invoke as potentially side-effecting code.
94
- Obtain explicit user confirmation immediately before any external or irreversible action, including sends, publishes, deletions, secret rotation, provider updates, or function execution. After sending, call wabery_get_message, wabery_get_dispatch, or wabery_get_broadcast to report provider status; acceptance is not delivery.`;
159
+ // Claude Code caps MCP server instructions at 2KB and silently truncates from
160
+ // the end (worse with multiple servers). Keep this text + WRITE_NOTE under
161
+ // 1900 UTF-8 bytes as a buffer, and put invariants first. Per-tool playbooks
162
+ // belong on tool descriptions, not in this handshake.
163
+ 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.
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.`;
95
168
  // The write gate differs by transport, and the model has to be told the truth
96
169
  // about the session it is actually in: a hosted OAuth client (ChatGPT, Claude)
97
170
  // cannot set an env var, so advertising the env gate there makes it refuse to
@@ -208,6 +281,12 @@ export function createWaberyMcpServer(client = new WaberyApiClient(), options =
208
281
  outputSchema: toolOutputSchema,
209
282
  ...config,
210
283
  }, callback);
284
+ const groupPath = (channelId, groupId) => {
285
+ const base = `/channels/${encodeURIComponent(channelId)}/whatsapp-groups`;
286
+ return groupId === undefined
287
+ ? base
288
+ : `${base}/${encodeURIComponent(groupId)}`;
289
+ };
211
290
  const requireWrite = async (action, token, expectedToken) => {
212
291
  const modeBlocked = checkWriteMode(action, undefined, undefined, writePolicy);
213
292
  if (modeBlocked)
@@ -401,7 +480,7 @@ export function createWaberyMcpServer(client = new WaberyApiClient(), options =
401
480
  }, async () => jsonResult(await client.get("/config")));
402
481
  registerTool("wabery_validate_config", {
403
482
  title: "Validate Wabery config",
404
- description: "Use this when you need to validate a complete declarative config without changing resources. Use wabery_preview_config_diff next to inspect effects.",
483
+ description: "Use this when you need to validate a complete declarative config without changing resources. Hosted-agent fields are Draft-only; Live cannot be edited here. Use wabery_preview_config_diff next to inspect effects.",
405
484
  inputSchema: {
406
485
  config: configSchema.describe("The full wabery.config.json object."),
407
486
  },
@@ -414,7 +493,7 @@ export function createWaberyMcpServer(client = new WaberyApiClient(), options =
414
493
  }, async ({ config }) => apiResult(client.post("/config/validate", config)));
415
494
  registerTool("wabery_preview_config_diff", {
416
495
  title: "Preview Wabery config diff",
417
- description: "Use this when you need to preview what a complete config would create, update, delete, or leave unchanged without applying it. Use wabery_apply_config only after reviewing this diff.",
496
+ description: "Use this when you need to preview what a complete config would create, update, delete, or leave unchanged without applying it. Hosted-agent changes land on Draft only; Live stays unchanged until someone uses Review & publish in Wabery. Use wabery_apply_config only after reviewing this diff.",
418
497
  inputSchema: {
419
498
  config: configSchema.describe("The full wabery.config.json object."),
420
499
  },
@@ -427,7 +506,7 @@ export function createWaberyMcpServer(client = new WaberyApiClient(), options =
427
506
  }, async ({ config }) => apiResult(client.post("/config/diff", config)));
428
507
  registerTool("wabery_apply_config", {
429
508
  title: "Apply Wabery config",
430
- description: "Use this when you need to apply a reviewed complete config, creating, overwriting, or removing Wabery Flows, automations, webhooks, and eligible Business Agent resources. Do not use it for one resource update when a narrow update tool fits. It never publishes Flows; use wabery_publish_flow separately.",
509
+ description: "Use this when you need to apply a reviewed complete config, creating or updating the hosted-agent Draft plus Flows, automations, webhooks, and eligible Business Agent resources. It cannot edit Live or publish the hosted agent to customers. Do not use it for one resource update when a narrow update tool fits. It never publishes Flows; use wabery_publish_flow separately. Hosted-agent publish happens in the Wabery AI builder after Sandbox testing.",
431
510
  inputSchema: {
432
511
  config: configSchema.describe("The full wabery.config.json object."),
433
512
  confirmation_token: z
@@ -927,13 +1006,17 @@ export function createWaberyMcpServer(client = new WaberyApiClient(), options =
927
1006
  }, async ({ project_id }) => jsonResult(await client.get(`/projects/${encodeURIComponent(project_id)}`)));
928
1007
  registerTool("wabery_update_project", {
929
1008
  title: "Update a Wabery project",
930
- 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.",
1009
+ 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.",
931
1010
  inputSchema: {
932
1011
  project_id: z.string().min(1),
933
1012
  name: z.string().min(1).max(100).optional(),
934
1013
  description: z.string().max(500).nullable().optional(),
935
1014
  routing_mode: z.enum(["NONE", "FLOWS", "EXTERNAL"]).optional(),
936
1015
  require_opt_in: z.boolean().optional(),
1016
+ group_webhook_events_enabled: z
1017
+ .boolean()
1018
+ .optional()
1019
+ .describe("Explicitly opt into the four new WhatsApp group management event names; defaults false."),
937
1020
  webhook_url: z.string().url().nullable().optional(),
938
1021
  webhook_signing_enabled: z.boolean().optional(),
939
1022
  webhook_secret: z.string().min(16).max(256).optional(),
@@ -1463,6 +1546,10 @@ export function createWaberyMcpServer(client = new WaberyApiClient(), options =
1463
1546
  inputSchema: {
1464
1547
  contact_id: z.string().optional(),
1465
1548
  active: z.boolean().optional(),
1549
+ recipient_type: z
1550
+ .enum(["individual", "group", "all"])
1551
+ .optional()
1552
+ .describe("Defaults to individual for backward compatibility; use group or all explicitly."),
1466
1553
  limit: z.number().int().min(1).max(100).optional(),
1467
1554
  starting_after: z.string().optional(),
1468
1555
  },
@@ -1481,6 +1568,7 @@ export function createWaberyMcpServer(client = new WaberyApiClient(), options =
1481
1568
  description: "Use this when you need one organization-scoped WhatsApp conversation by conversation_id from wabery_list_conversations.",
1482
1569
  inputSchema: {
1483
1570
  conversation_id: z.string().min(1),
1571
+ recipient_type: z.enum(["individual", "group"]).optional(),
1484
1572
  },
1485
1573
  annotations: {
1486
1574
  readOnlyHint: true,
@@ -1488,12 +1576,13 @@ export function createWaberyMcpServer(client = new WaberyApiClient(), options =
1488
1576
  idempotentHint: true,
1489
1577
  openWorldHint: false,
1490
1578
  },
1491
- }, async ({ conversation_id }) => jsonResult(await client.get(`/conversations/${conversation_id}`)));
1579
+ }, async ({ conversation_id, ...query }) => jsonResult(await client.get(`/conversations/${conversation_id}`, query)));
1492
1580
  registerTool("wabery_list_conversation_messages", {
1493
1581
  title: "List Wabery conversation messages",
1494
- 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.",
1582
+ 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.`,
1495
1583
  inputSchema: {
1496
1584
  conversation_id: z.string().min(1),
1585
+ recipient_type: z.enum(["individual", "group"]).optional(),
1497
1586
  limit: z.number().int().min(1).max(100).optional(),
1498
1587
  starting_after: z.string().optional(),
1499
1588
  order: z.enum(["asc", "desc"]).optional(),
@@ -1505,13 +1594,298 @@ export function createWaberyMcpServer(client = new WaberyApiClient(), options =
1505
1594
  openWorldHint: false,
1506
1595
  },
1507
1596
  }, async ({ conversation_id, ...query }) => jsonResult(await client.get(`/conversations/${conversation_id}/messages`, query)));
1597
+ registerTool("wabery_list_groups", {
1598
+ title: "List WhatsApp groups",
1599
+ 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.",
1600
+ inputSchema: {
1601
+ channel_id: z.string().min(1),
1602
+ limit: z.number().int().min(1).max(1024).optional(),
1603
+ starting_after: z.string().optional(),
1604
+ ending_before: z.string().optional(),
1605
+ after: z.string().optional(),
1606
+ before: z.string().optional(),
1607
+ },
1608
+ annotations: {
1609
+ readOnlyHint: true,
1610
+ destructiveHint: false,
1611
+ idempotentHint: true,
1612
+ openWorldHint: false,
1613
+ },
1614
+ }, async ({ channel_id, ...query }) => jsonResult(await client.get(groupPath(channel_id), query)));
1615
+ registerTool("wabery_get_group", {
1616
+ title: "Get WhatsApp group",
1617
+ 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.",
1618
+ inputSchema: {
1619
+ channel_id: z.string().min(1),
1620
+ group_id: groupIdSchema,
1621
+ },
1622
+ annotations: {
1623
+ readOnlyHint: true,
1624
+ destructiveHint: false,
1625
+ idempotentHint: true,
1626
+ openWorldHint: false,
1627
+ },
1628
+ }, async ({ channel_id, group_id }) => jsonResult(await client.get(groupPath(channel_id, group_id))));
1629
+ registerTool("wabery_create_group", {
1630
+ title: "Create WhatsApp group",
1631
+ 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.",
1632
+ inputSchema: {
1633
+ channel_id: z.string().min(1),
1634
+ subject: z.string().min(1).max(128),
1635
+ description: z.string().max(2048).optional(),
1636
+ join_approval_mode: z
1637
+ .enum(["auto_approve", "approval_required"])
1638
+ .optional(),
1639
+ idempotency_key: z
1640
+ .string()
1641
+ .trim()
1642
+ .min(1)
1643
+ .max(200)
1644
+ .describe("Stable caller-generated key reused for every retry of this group creation."),
1645
+ confirmation_token: z.string().optional(),
1646
+ },
1647
+ annotations: {
1648
+ readOnlyHint: false,
1649
+ destructiveHint: true,
1650
+ idempotentHint: true,
1651
+ openWorldHint: true,
1652
+ },
1653
+ }, async ({ channel_id, confirmation_token, idempotency_key, ...body }) => {
1654
+ const blocked = await requireWrite("wabery_create_group", confirmation_token, "create_group");
1655
+ if (blocked)
1656
+ return blocked;
1657
+ return apiResult(client.post(groupPath(channel_id), body, idempotency_key));
1658
+ });
1659
+ registerTool("wabery_update_group_settings", {
1660
+ title: "Update WhatsApp group settings",
1661
+ 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.",
1662
+ inputSchema: {
1663
+ channel_id: z.string().min(1),
1664
+ group_id: groupIdSchema,
1665
+ subject: z.string().min(1).max(128).optional(),
1666
+ description: z.string().max(2048).optional(),
1667
+ confirmation_token: z.string().optional(),
1668
+ },
1669
+ annotations: {
1670
+ readOnlyHint: false,
1671
+ destructiveHint: true,
1672
+ idempotentHint: true,
1673
+ openWorldHint: true,
1674
+ },
1675
+ }, async ({ channel_id, group_id, confirmation_token, ...body }) => {
1676
+ const blocked = await requireWrite("wabery_update_group_settings", confirmation_token, "update_group_settings");
1677
+ if (blocked)
1678
+ return blocked;
1679
+ return apiResult(client.post(groupPath(channel_id, group_id), body));
1680
+ });
1681
+ registerTool("wabery_update_group_profile_picture", {
1682
+ title: "Update WhatsApp group profile picture",
1683
+ 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.",
1684
+ inputSchema: {
1685
+ channel_id: z.string().min(1),
1686
+ group_id: groupIdSchema,
1687
+ jpeg_base64: z.string().min(4).max(8_000_000),
1688
+ filename: z.string().min(1).max(240).optional(),
1689
+ subject: z.string().min(1).max(128).optional(),
1690
+ description: z.string().max(2048).optional(),
1691
+ confirmation_token: z.string().optional(),
1692
+ },
1693
+ annotations: {
1694
+ readOnlyHint: false,
1695
+ destructiveHint: true,
1696
+ idempotentHint: true,
1697
+ openWorldHint: true,
1698
+ },
1699
+ }, async ({ channel_id, group_id, jpeg_base64, filename, subject, description, confirmation_token, }) => {
1700
+ const blocked = await requireWrite("wabery_update_group_profile_picture", confirmation_token, "update_group_profile_picture");
1701
+ if (blocked)
1702
+ return blocked;
1703
+ const bytes = Buffer.from(jpeg_base64, "base64");
1704
+ if (bytes.length < 3 ||
1705
+ bytes[0] !== 0xff ||
1706
+ bytes[1] !== 0xd8 ||
1707
+ bytes[2] !== 0xff) {
1708
+ throw new Error("jpeg_base64 must decode to a JPEG file");
1709
+ }
1710
+ const form = new FormData();
1711
+ form.set("profile_picture_file", new Blob([bytes], { type: "image/jpeg" }), filename ?? "profile.jpg");
1712
+ if (subject !== undefined)
1713
+ form.set("subject", subject);
1714
+ if (description !== undefined)
1715
+ form.set("description", description);
1716
+ return apiResult(client.postForm(groupPath(channel_id, group_id), form));
1717
+ });
1718
+ registerTool("wabery_get_group_invite_link", {
1719
+ title: "Get WhatsApp group invite link",
1720
+ 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.",
1721
+ inputSchema: {
1722
+ channel_id: z.string().min(1),
1723
+ group_id: groupIdSchema,
1724
+ },
1725
+ annotations: {
1726
+ readOnlyHint: true,
1727
+ destructiveHint: false,
1728
+ idempotentHint: true,
1729
+ openWorldHint: false,
1730
+ },
1731
+ }, async ({ channel_id, group_id }) => jsonResult(await client.get(`${groupPath(channel_id, group_id)}/invite-link`)));
1732
+ registerTool("wabery_reset_group_invite_link", {
1733
+ title: "Reset WhatsApp group invite link",
1734
+ 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.",
1735
+ inputSchema: {
1736
+ channel_id: z.string().min(1),
1737
+ group_id: groupIdSchema,
1738
+ confirmation_token: z.string().optional(),
1739
+ },
1740
+ annotations: {
1741
+ readOnlyHint: false,
1742
+ destructiveHint: true,
1743
+ idempotentHint: false,
1744
+ openWorldHint: true,
1745
+ },
1746
+ }, async ({ channel_id, group_id, confirmation_token }) => {
1747
+ const blocked = await requireWrite("wabery_reset_group_invite_link", confirmation_token, "reset_group_invite_link");
1748
+ if (blocked)
1749
+ return blocked;
1750
+ return apiResult(client.post(`${groupPath(channel_id, group_id)}/invite-link`));
1751
+ });
1752
+ registerTool("wabery_list_group_join_requests", {
1753
+ title: "List WhatsApp group join requests",
1754
+ 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.",
1755
+ inputSchema: {
1756
+ channel_id: z.string().min(1),
1757
+ group_id: groupIdSchema,
1758
+ limit: z.number().int().min(1).max(1024).optional(),
1759
+ starting_after: z.string().optional(),
1760
+ ending_before: z.string().optional(),
1761
+ after: z.string().optional(),
1762
+ before: z.string().optional(),
1763
+ },
1764
+ annotations: {
1765
+ readOnlyHint: true,
1766
+ destructiveHint: false,
1767
+ idempotentHint: true,
1768
+ openWorldHint: false,
1769
+ },
1770
+ }, async ({ channel_id, group_id, ...query }) => jsonResult(await client.get(`${groupPath(channel_id, group_id)}/join-requests`, query)));
1771
+ registerTool("wabery_approve_group_join_requests", {
1772
+ title: "Approve WhatsApp group join requests",
1773
+ 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.",
1774
+ inputSchema: {
1775
+ channel_id: z.string().min(1),
1776
+ group_id: groupIdSchema,
1777
+ join_request_ids: groupJoinRequestIdsSchema,
1778
+ confirmation_token: z.string().optional(),
1779
+ },
1780
+ annotations: {
1781
+ readOnlyHint: false,
1782
+ destructiveHint: true,
1783
+ idempotentHint: true,
1784
+ openWorldHint: true,
1785
+ },
1786
+ }, async ({ channel_id, group_id, join_request_ids, confirmation_token }) => {
1787
+ const blocked = await requireWrite("wabery_approve_group_join_requests", confirmation_token, "approve_group_join_requests");
1788
+ if (blocked)
1789
+ return blocked;
1790
+ return apiResult(client.post(`${groupPath(channel_id, group_id)}/join-requests`, {
1791
+ join_requests: join_request_ids,
1792
+ }));
1793
+ });
1794
+ registerTool("wabery_reject_group_join_requests", {
1795
+ title: "Reject WhatsApp group join requests",
1796
+ 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.",
1797
+ inputSchema: {
1798
+ channel_id: z.string().min(1),
1799
+ group_id: groupIdSchema,
1800
+ join_request_ids: groupJoinRequestIdsSchema,
1801
+ confirmation_token: z.string().optional(),
1802
+ },
1803
+ annotations: {
1804
+ readOnlyHint: false,
1805
+ destructiveHint: true,
1806
+ idempotentHint: true,
1807
+ openWorldHint: true,
1808
+ },
1809
+ }, async ({ channel_id, group_id, join_request_ids, confirmation_token }) => {
1810
+ const blocked = await requireWrite("wabery_reject_group_join_requests", confirmation_token, "reject_group_join_requests");
1811
+ if (blocked)
1812
+ return blocked;
1813
+ return apiResult(client.delete(`${groupPath(channel_id, group_id)}/join-requests`, {
1814
+ join_requests: join_request_ids,
1815
+ }));
1816
+ });
1817
+ registerTool("wabery_remove_group_participants", {
1818
+ title: "Remove WhatsApp group participants",
1819
+ 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.",
1820
+ inputSchema: {
1821
+ channel_id: z.string().min(1),
1822
+ group_id: groupIdSchema,
1823
+ participants: z.array(groupParticipantSchema).min(1).max(8),
1824
+ confirmation_token: z.string().optional(),
1825
+ },
1826
+ annotations: {
1827
+ readOnlyHint: false,
1828
+ destructiveHint: true,
1829
+ idempotentHint: true,
1830
+ openWorldHint: true,
1831
+ },
1832
+ }, async ({ channel_id, group_id, participants, confirmation_token }) => {
1833
+ const blocked = await requireWrite("wabery_remove_group_participants", confirmation_token, "remove_group_participants");
1834
+ if (blocked)
1835
+ return blocked;
1836
+ return apiResult(client.delete(`${groupPath(channel_id, group_id)}/participants`, {
1837
+ participants: participants.map(({ user }) => user),
1838
+ }));
1839
+ });
1840
+ registerTool("wabery_delete_group", {
1841
+ title: "Delete WhatsApp group",
1842
+ description: "Use this when you need to permanently delete a Wabery-managed WhatsApp group. This is irreversible at the provider and requires explicit confirmation.",
1843
+ inputSchema: {
1844
+ channel_id: z.string().min(1),
1845
+ group_id: groupIdSchema,
1846
+ confirmation_token: z.string().optional(),
1847
+ },
1848
+ annotations: {
1849
+ readOnlyHint: false,
1850
+ destructiveHint: true,
1851
+ idempotentHint: true,
1852
+ openWorldHint: true,
1853
+ },
1854
+ }, async ({ channel_id, group_id, confirmation_token }) => {
1855
+ const blocked = await requireWrite("wabery_delete_group", confirmation_token, "delete_group");
1856
+ if (blocked)
1857
+ return blocked;
1858
+ return apiResult(client.delete(groupPath(channel_id, group_id)));
1859
+ });
1860
+ registerTool("wabery_send_group_message", {
1861
+ title: "Send WhatsApp group message",
1862
+ 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.",
1863
+ inputSchema: {
1864
+ channel_id: z.string().min(1),
1865
+ group_id: groupIdSchema,
1866
+ body: groupMessageBodySchema.describe("Exactly one supported group content field plus a stable idempotency_key that must be reused for retries. reply_to is optional."),
1867
+ confirmation_token: z.string().optional(),
1868
+ },
1869
+ annotations: {
1870
+ readOnlyHint: false,
1871
+ destructiveHint: true,
1872
+ idempotentHint: false,
1873
+ openWorldHint: true,
1874
+ },
1875
+ }, async ({ channel_id, group_id, body, confirmation_token }) => {
1876
+ const blocked = await requireWrite("wabery_send_group_message", confirmation_token, "send_group_message");
1877
+ if (blocked)
1878
+ return blocked;
1879
+ const idempotencyKey = body.idempotency_key;
1880
+ return apiResult(client.post("/messages", { ...body, channel_id, group_id, idempotency_key: idempotencyKey }, idempotencyKey));
1881
+ });
1508
1882
  registerTool("wabery_send_message", {
1509
1883
  title: "Send WhatsApp message",
1510
- 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.",
1884
+ 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.",
1511
1885
  inputSchema: {
1512
1886
  body: z
1513
1887
  .record(z.string(), z.unknown())
1514
- .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."),
1888
+ .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."),
1515
1889
  confirmation_token: z
1516
1890
  .string()
1517
1891
  .optional()
@@ -1527,11 +1901,21 @@ export function createWaberyMcpServer(client = new WaberyApiClient(), options =
1527
1901
  const blocked = await requireWrite("wabery_send_message", confirmation_token, "send_message");
1528
1902
  if (blocked)
1529
1903
  return blocked;
1530
- return apiResult(client.post("/messages", body));
1904
+ const groupIdempotencyKey = typeof body.group_id === "string"
1905
+ ? typeof body.idempotency_key === "string" && body.idempotency_key
1906
+ ? body.idempotency_key
1907
+ : null
1908
+ : undefined;
1909
+ if (groupIdempotencyKey === null) {
1910
+ throw new Error("WhatsApp group sends require a stable idempotency_key that is reused for retries.");
1911
+ }
1912
+ return apiResult(client.post("/messages", groupIdempotencyKey
1913
+ ? { ...body, idempotency_key: groupIdempotencyKey }
1914
+ : body, groupIdempotencyKey));
1531
1915
  });
1532
1916
  registerTool("wabery_get_message", {
1533
1917
  title: "Get Wabery message",
1534
- 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.",
1918
+ 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}`,
1535
1919
  inputSchema: {
1536
1920
  message_id: z.string().min(1),
1537
1921
  },