@ararahq/mcp 5.0.0 → 6.0.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.
- package/README.md +40 -12
- package/build/cli.js +2 -0
- package/build/config.js +2 -2
- package/build/lib/admin.js +23 -0
- package/build/lib/api.js +1 -0
- package/build/lib/errors.js +13 -8
- package/build/lib/operator-access.js +26 -0
- package/build/lib/recipients.js +67 -0
- package/build/lib/schemas.js +106 -73
- package/build/lib/templates.js +27 -0
- package/build/prompts/index.js +3 -4
- package/build/resources/index.js +20 -22
- package/build/server/create.js +4 -0
- package/build/tools/campaigns.js +215 -0
- package/build/tools/contacts.js +84 -0
- package/build/tools/index.js +27 -270
- package/build/tools/operator.js +229 -0
- package/build/tools/register.js +46 -0
- package/build/tools/send.js +150 -0
- package/build/tools/templates.js +63 -0
- package/build/ui/broadcast.html +81 -0
- package/build/ui/campaign.html +81 -0
- package/build/ui/conversation.html +81 -0
- package/build/ui/model/campaign.js +38 -0
- package/build/ui/model/envelope.js +21 -0
- package/build/ui/model/format.js +27 -0
- package/build/ui/model/status.js +48 -0
- package/build/ui/resources.js +25 -0
- package/build/ui/status.html +81 -0
- package/package.json +12 -8
package/README.md
CHANGED
|
@@ -1,19 +1,44 @@
|
|
|
1
1
|
# AraraHQ MCP
|
|
2
2
|
|
|
3
|
-
Official Model Context Protocol server for
|
|
3
|
+
Official Model Context Protocol server for AraraHQ: talk to your whole WhatsApp base, see what came back, answer who replied.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Node.js is the only implementation, OAuth is the authentication boundary, and both the npm scope and public repository are owned by AraraHQ: [`@ararahq/mcp`](https://www.npmjs.com/package/@ararahq/mcp) and [`ararahq/mcp`](https://github.com/ararahq/mcp).
|
|
6
6
|
|
|
7
7
|
## What it exposes
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
Nine tools, on purpose. An agent works better with a few actions that accept what a person knows by heart (a name, a phone in any format) and resolve the rest.
|
|
10
|
+
|
|
11
|
+
| Tool | What it does |
|
|
12
|
+
| ------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
13
|
+
| `whoami` | Who is authenticated, which organization sends, plan and wallet balance. |
|
|
14
|
+
| `send_whatsapp` | One message to one person: free text inside the 24h window, or an approved template with variables any time. |
|
|
15
|
+
| `broadcast` | One approved template to up to 1000 people as a campaign. Dry run by default (preview and cost), A/B and scheduling. |
|
|
16
|
+
| `campaign_report` | Recent campaigns, or the full report of one: sent, delivered, read, clicked, replied, converted, blocked, cost. |
|
|
17
|
+
| `check_status` | Did it arrive? Is the 24h window open? Was the template approved? |
|
|
18
|
+
| `create_template` | Submit a template for Meta approval, with header, footer, samples and up to 2 buttons. |
|
|
19
|
+
| `save_contacts` | Create or update up to 1000 contacts so you can message by name. |
|
|
20
|
+
| `opt_out` | Record that someone asked to stop. Every later send to them is blocked. |
|
|
21
|
+
| `read_conversation` | The raw message timeline with one person, newest first, so you can judge a reply before answering. |
|
|
22
|
+
|
|
23
|
+
Resources (`arara://organization`, `arara://templates/approved`, `arara://campaigns/recent`, `arara://channels`) give read-only context without a tool call. Prompts `plan_broadcast`, `campaign_review` and `reply_to_responses` package the three everyday flows and always stop for approval before a write.
|
|
14
24
|
|
|
15
25
|
Every tool returns both human-readable content and stable structured content. Write tools carry MCP safety annotations. Credentials never appear in tool inputs or output.
|
|
16
26
|
|
|
27
|
+
Operator tools (`create_api_key`, `list_api_keys`, `revoke_api_key`, `configure_webhook_route`, `list_templates`, `delete_template`, `template_health`, `list_failures`, `get_balance`, `add_credit`, `remove_credit`, `list_transactions`) exist for the AraraHQ team only and are gated server-side by an e-mail allowlist and an admin secret.
|
|
28
|
+
|
|
29
|
+
## Panels (MCP Apps)
|
|
30
|
+
|
|
31
|
+
Four tools ship an interactive panel that hosts with MCP Apps support (Claude Desktop, claude.ai, ChatGPT, VS Code) render inline. Every panel reads the same structured content the text fallback uses, so clients without panel support lose nothing.
|
|
32
|
+
|
|
33
|
+
| Tool | Panel |
|
|
34
|
+
| ------------------- | -------------------------------------------------------------------------------------------- |
|
|
35
|
+
| `campaign_report` | Funnel with animated bars, headline numbers, block reasons, live refresh while sending. |
|
|
36
|
+
| `broadcast` | Phone mockup of the rendered message, audience and cost, approve button, then live delivery. |
|
|
37
|
+
| `check_status` | Delivery timeline (accepted, sent, delivered, read) that polls until a final state. |
|
|
38
|
+
| `read_conversation` | Chat thread with an inline reply box that calls `send_whatsapp`. |
|
|
39
|
+
|
|
40
|
+
Panels are single-file HTML bundles built by `scripts/build-ui.mjs` into `build/ui/` and served as `ui://arara/<panel>.html` resources with the `text/html;profile=mcp-app` MIME type. They call tools through the host (`callServerTool`) and hand follow-ups back to the chat (`sendMessage`); they never hold credentials.
|
|
41
|
+
|
|
17
42
|
## Local installation
|
|
18
43
|
|
|
19
44
|
Requires Node.js 20 or newer.
|
|
@@ -67,11 +92,14 @@ Protected Resource Metadata is served at:
|
|
|
67
92
|
|
|
68
93
|
AraraHQ currently issues installed-client OAuth tokens through its device authorization flow. Hosted clients must supply a valid AraraHQ OAuth bearer token; the MCP does not proxy credentials or mint tokens.
|
|
69
94
|
|
|
70
|
-
##
|
|
71
|
-
|
|
72
|
-
`whoami`, `get_today`, `find_conversations`, `get_conversation`, `reply_to_conversation`, `claim_conversation`, `close_conversation`, `list_automations`, `get_automation`, `prepare_campaign`, `publish_campaign`, `send_whatsapp`, `check_message`, `save_contacts`, `create_template`, `get_template_status`, `opt_out`.
|
|
95
|
+
## Behavior worth knowing
|
|
73
96
|
|
|
74
|
-
|
|
97
|
+
- `to` accepts a phone in any spelling or a saved contact name. Brazilian numbers get `+55` and the ninth digit when missing. An ambiguous name fails with the candidates instead of guessing.
|
|
98
|
+
- Free text outside the 24h window is refused by Meta. `send_whatsapp` turns that refusal into a list of your approved templates.
|
|
99
|
+
- `broadcast` never drops recipients silently: names that do not resolve are returned next to the campaign id.
|
|
100
|
+
- Message acceptance means queued, not delivered. Delivery is checked with `check_status`.
|
|
101
|
+
- Every mutating call carries an `Idempotency-Key`, so retries are safe.
|
|
102
|
+
- `broadcast` is a dry run unless `dryRun` is false. The preview resolves names, renders the template with the first contact's variables and calls the cost estimator, so approval happens with the real numbers.
|
|
75
103
|
|
|
76
104
|
## Development
|
|
77
105
|
|
|
@@ -84,7 +112,7 @@ npm pack --dry-run
|
|
|
84
112
|
|
|
85
113
|
The server defaults to stdio. Use `MCP_TRANSPORT=http npm start` for Streamable HTTP. Override the API only for controlled environments with `ARARA_API_URL`.
|
|
86
114
|
|
|
87
|
-
The former unscoped package `ararahq-mcp` is the frozen v4 distribution.
|
|
115
|
+
The former unscoped package `ararahq-mcp` is the frozen v4 distribution. Version 5 was an Atendimento-oriented rewrite that never matched the product; version 6 is the broadcast-first server described here.
|
|
88
116
|
|
|
89
117
|
## Security
|
|
90
118
|
|
package/build/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { requestDeviceCode, pollForToken, openBrowser } from "./auth/device-flow
|
|
|
4
4
|
import { clearToken, loadToken } from "./auth/token-store.js";
|
|
5
5
|
import { identitySchema } from "./lib/schemas.js";
|
|
6
6
|
import { TOOL_NAMES } from "./tools/index.js";
|
|
7
|
+
import { OPERATOR_TOOL_NAMES } from "./tools/operator.js";
|
|
7
8
|
const log = (message) => {
|
|
8
9
|
process.stdout.write(`${message}\n`);
|
|
9
10
|
};
|
|
@@ -61,6 +62,7 @@ export const runCli = async (command) => {
|
|
|
61
62
|
}
|
|
62
63
|
if (command === "tools") {
|
|
63
64
|
TOOL_NAMES.forEach(log);
|
|
65
|
+
OPERATOR_TOOL_NAMES.forEach(log);
|
|
64
66
|
return true;
|
|
65
67
|
}
|
|
66
68
|
if (command === "--version" || command === "-v") {
|
package/build/config.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export const SERVER_NAME = "ararahq-mcp";
|
|
2
|
-
export const SERVER_VERSION = "
|
|
2
|
+
export const SERVER_VERSION = "6.0.0";
|
|
3
3
|
export const DEFAULT_API_BASE_URL = "https://api.ararahq.com/api";
|
|
4
4
|
export const OAUTH_CLIENT_ID = "ararahq-mcp";
|
|
5
|
-
export const OAUTH_SCOPE = "openid profile organization:read
|
|
5
|
+
export const OAUTH_SCOPE = "openid profile organization:read messages:write campaigns:read campaigns:write contacts:write templates:write";
|
|
6
6
|
export const API_TIMEOUT_MS = 10_000;
|
|
7
7
|
export const MAX_RETRIES = 2;
|
|
8
8
|
export const MAX_PAGE_SIZE = 100;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { apiRequest } from "./api.js";
|
|
2
|
+
import { AraraError } from "./errors.js";
|
|
3
|
+
const requireAdminSecret = () => {
|
|
4
|
+
const secret = process.env.ARARA_ADMIN_SECRET;
|
|
5
|
+
if (typeof secret !== "string" || secret.length === 0) {
|
|
6
|
+
throw new AraraError("ADMIN_NOT_CONFIGURED", "Admin-gated tools require ARARA_ADMIN_SECRET in the MCP server environment.", 403, false);
|
|
7
|
+
}
|
|
8
|
+
return secret;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Thin wrapper over apiRequest for /v1/admin/** endpoints: the session Bearer token
|
|
12
|
+
* satisfies the hasRole(ADMIN) gate in SecurityConfig, and X-Admin-Secret is the
|
|
13
|
+
* second layer checked by the controller. The secret lives only in the server
|
|
14
|
+
* process environment and is attached as a header — never in inputs, outputs or
|
|
15
|
+
* error messages. Retry/backoff and Idempotency-Key come from apiRequest.
|
|
16
|
+
*/
|
|
17
|
+
export const adminRequest = async (path, options) => apiRequest(path, {
|
|
18
|
+
body: options.body,
|
|
19
|
+
schema: options.schema,
|
|
20
|
+
extraHeaders: { "X-Admin-Secret": requireAdminSecret() },
|
|
21
|
+
...(options.method === undefined ? {} : { method: options.method }),
|
|
22
|
+
...(options.idempotencyKey === undefined ? {} : { idempotencyKey: options.idempotencyKey }),
|
|
23
|
+
});
|
package/build/lib/api.js
CHANGED
package/build/lib/errors.js
CHANGED
|
@@ -13,6 +13,17 @@ export class AraraError extends Error {
|
|
|
13
13
|
this.name = "AraraError";
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
+
const DEFAULT_CODES = {
|
|
17
|
+
401: "UNAUTHENTICATED",
|
|
18
|
+
403: "FORBIDDEN",
|
|
19
|
+
404: "NOT_FOUND",
|
|
20
|
+
429: "RATE_LIMITED",
|
|
21
|
+
};
|
|
22
|
+
const DEFAULT_MESSAGES = {
|
|
23
|
+
401: "OAuth authentication is required or has expired.",
|
|
24
|
+
403: "This account is not allowed to do that.",
|
|
25
|
+
404: "The requested resource does not exist in this organization.",
|
|
26
|
+
};
|
|
16
27
|
const parseRetryAfter = (value) => {
|
|
17
28
|
if (typeof value !== "string" || value.length === 0)
|
|
18
29
|
return undefined;
|
|
@@ -34,16 +45,10 @@ export const toAraraError = (error) => {
|
|
|
34
45
|
const nested = typeof candidate.error === "object" && candidate.error !== null
|
|
35
46
|
? candidate.error
|
|
36
47
|
: candidate;
|
|
37
|
-
const code = typeof nested.code === "string"
|
|
38
|
-
? nested.code
|
|
39
|
-
: status === 429
|
|
40
|
-
? "RATE_LIMITED"
|
|
41
|
-
: "UPSTREAM_ERROR";
|
|
48
|
+
const code = typeof nested.code === "string" ? nested.code : (DEFAULT_CODES[status] ?? "UPSTREAM_ERROR");
|
|
42
49
|
const message = typeof nested.message === "string"
|
|
43
50
|
? nested.message
|
|
44
|
-
: status
|
|
45
|
-
? "OAuth authentication is required or has expired."
|
|
46
|
-
: "AraraHQ API request failed.";
|
|
51
|
+
: (DEFAULT_MESSAGES[status] ?? "AraraHQ API request failed.");
|
|
47
52
|
const retryAfterSeconds = parseRetryAfter(error.response?.headers["retry-after"]);
|
|
48
53
|
return new AraraError(code, message, status, status === 429 || status >= 500, retryAfterSeconds);
|
|
49
54
|
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { apiRequest } from "./api.js";
|
|
2
|
+
import { AraraError } from "./errors.js";
|
|
3
|
+
import { identitySchema } from "./schemas.js";
|
|
4
|
+
/**
|
|
5
|
+
* Operator tools are gated by an e-mail allowlist held only in the server process
|
|
6
|
+
* environment (ARARA_OPERATOR_EMAILS, comma-separated). Fail closed: with the
|
|
7
|
+
* variable unset or empty, every operator tool is refused.
|
|
8
|
+
*
|
|
9
|
+
* The identity is fetched per call, never cached at module level: the HTTP
|
|
10
|
+
* transport serves concurrent requests from different accounts in one process,
|
|
11
|
+
* so a process-wide cache would let one caller ride another caller's identity.
|
|
12
|
+
*/
|
|
13
|
+
export const getAllowedOperatorEmails = (raw = process.env.ARARA_OPERATOR_EMAILS) => (raw ?? "")
|
|
14
|
+
.split(",")
|
|
15
|
+
.map((email) => email.trim().toLowerCase())
|
|
16
|
+
.filter((email) => email.length > 0);
|
|
17
|
+
export const assertOperatorAllowed = async () => {
|
|
18
|
+
const allowed = getAllowedOperatorEmails();
|
|
19
|
+
if (allowed.length === 0) {
|
|
20
|
+
throw new AraraError("OPERATOR_NOT_CONFIGURED", "Operator tools require ARARA_OPERATOR_EMAILS in the MCP server environment.", 403, false);
|
|
21
|
+
}
|
|
22
|
+
const identity = await apiRequest("/auth/me", { schema: identitySchema });
|
|
23
|
+
if (!allowed.includes(identity.email.toLowerCase())) {
|
|
24
|
+
throw new AraraError("OPERATOR_NOT_ALLOWED", "The authenticated account is not allowed to use operator tools.", 403, false);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { apiRequest } from "./api.js";
|
|
2
|
+
import { AraraError } from "./errors.js";
|
|
3
|
+
import { contactsListSchema } from "./schemas.js";
|
|
4
|
+
const BRAZIL_COUNTRY_CODE = "55";
|
|
5
|
+
const BRAZIL_LANDLINE_LENGTH = 10;
|
|
6
|
+
const BRAZIL_MOBILE_LENGTH = 11;
|
|
7
|
+
const BRAZIL_MOBILE_PREFIX = "9";
|
|
8
|
+
const CONTACT_LOOKUP_SIZE = 5;
|
|
9
|
+
const PHONE_PATTERN = /^[+(\d][\d\s().-]{7,}$/;
|
|
10
|
+
export const E164_PATTERN = /^\+[1-9]\d{6,14}$/;
|
|
11
|
+
export const looksLikePhone = (value) => PHONE_PATTERN.test(value.trim());
|
|
12
|
+
/**
|
|
13
|
+
* Normalizes any human phone spelling into E.164. Brazilian numbers without the
|
|
14
|
+
* country code get +55, and 10-digit Brazilian mobiles get the ninth digit.
|
|
15
|
+
*/
|
|
16
|
+
export const normalizePhone = (raw) => {
|
|
17
|
+
const digits = raw.replace(/\D/g, "");
|
|
18
|
+
const hasCountryCode = raw.trim().startsWith("+") || digits.length >= 12;
|
|
19
|
+
if (hasCountryCode)
|
|
20
|
+
return withBrazilianNinthDigit(digits);
|
|
21
|
+
if (digits.length === BRAZIL_LANDLINE_LENGTH || digits.length === BRAZIL_MOBILE_LENGTH) {
|
|
22
|
+
return `+${BRAZIL_COUNTRY_CODE}${digits}`;
|
|
23
|
+
}
|
|
24
|
+
return `+${digits}`;
|
|
25
|
+
};
|
|
26
|
+
const withBrazilianNinthDigit = (digits) => {
|
|
27
|
+
if (!digits.startsWith(BRAZIL_COUNTRY_CODE))
|
|
28
|
+
return `+${digits}`;
|
|
29
|
+
const local = digits.slice(BRAZIL_COUNTRY_CODE.length);
|
|
30
|
+
if (local.length !== BRAZIL_LANDLINE_LENGTH)
|
|
31
|
+
return `+${digits}`;
|
|
32
|
+
const areaCode = local.slice(0, 2);
|
|
33
|
+
const rest = local.slice(2);
|
|
34
|
+
return `+${BRAZIL_COUNTRY_CODE}${areaCode}${BRAZIL_MOBILE_PREFIX}${rest}`;
|
|
35
|
+
};
|
|
36
|
+
export const recipientLabel = (recipient) => recipient.name === undefined ? recipient.phone : `${recipient.phone} (${recipient.name})`;
|
|
37
|
+
/**
|
|
38
|
+
* Resolves a phone in any format or a saved contact name into one E.164 recipient.
|
|
39
|
+
* Ambiguous names fail loudly with the candidates instead of guessing.
|
|
40
|
+
*/
|
|
41
|
+
export const resolveRecipient = async (to) => {
|
|
42
|
+
const trimmed = to.trim();
|
|
43
|
+
if (looksLikePhone(trimmed)) {
|
|
44
|
+
const phone = normalizePhone(trimmed);
|
|
45
|
+
if (!E164_PATTERN.test(phone)) {
|
|
46
|
+
throw new AraraError("INVALID_PHONE", `'${trimmed}' is not a valid phone number.`, 400, false);
|
|
47
|
+
}
|
|
48
|
+
return { phone };
|
|
49
|
+
}
|
|
50
|
+
const params = new URLSearchParams({ q: trimmed, page: "0", size: String(CONTACT_LOOKUP_SIZE) });
|
|
51
|
+
const result = await apiRequest(`/v1/contacts?${params.toString()}`, {
|
|
52
|
+
schema: contactsListSchema,
|
|
53
|
+
});
|
|
54
|
+
const matches = result.contacts.filter((contact) => contact.phone.length > 0);
|
|
55
|
+
if (matches.length === 0) {
|
|
56
|
+
throw new AraraError("CONTACT_NOT_FOUND", `No saved contact named '${trimmed}'. Pass the phone number or save the contact first.`, 404, false);
|
|
57
|
+
}
|
|
58
|
+
if (matches.length > 1) {
|
|
59
|
+
const options = matches.map((contact) => `${contact.name} (${contact.phone})`).join(", ");
|
|
60
|
+
throw new AraraError("CONTACT_AMBIGUOUS", `'${trimmed}' matches more than one contact: ${options}. Use the phone number.`, 409, false);
|
|
61
|
+
}
|
|
62
|
+
const [match] = matches;
|
|
63
|
+
if (match === undefined) {
|
|
64
|
+
throw new AraraError("CONTACT_NOT_FOUND", `No saved contact named '${trimmed}'.`, 404, false);
|
|
65
|
+
}
|
|
66
|
+
return { phone: normalizePhone(match.phone), name: match.name };
|
|
67
|
+
};
|
package/build/lib/schemas.js
CHANGED
|
@@ -11,109 +11,142 @@ export const identitySchema = z
|
|
|
11
11
|
.object({ name: z.string(), email: z.string().email() })
|
|
12
12
|
.passthrough();
|
|
13
13
|
export const planSchema = z.record(jsonValueSchema);
|
|
14
|
+
export const balanceSchema = z.record(jsonValueSchema);
|
|
15
|
+
export const mutationSchema = z.record(jsonValueSchema);
|
|
14
16
|
export const paginationSchema = z.object({
|
|
15
17
|
page: z.number().int().nonnegative(),
|
|
16
18
|
size: z.number().int().positive(),
|
|
17
19
|
totalElements: z.number().int().nonnegative(),
|
|
18
20
|
totalPages: z.number().int().nonnegative(),
|
|
19
21
|
});
|
|
20
|
-
export const
|
|
22
|
+
export const templateSchema = z
|
|
21
23
|
.object({
|
|
22
24
|
id: z.string(),
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
name: z.string(),
|
|
26
|
+
formattedName: z.string().optional(),
|
|
27
|
+
category: z.string(),
|
|
28
|
+
language: z.string(),
|
|
29
|
+
providerStatus: z.string(),
|
|
30
|
+
rejectionReason: z.string().nullable().optional(),
|
|
31
|
+
availableForSending: z.boolean(),
|
|
32
|
+
bodyPreview: z.string().nullable().optional(),
|
|
33
|
+
structureJson: jsonValueSchema.optional(),
|
|
34
|
+
})
|
|
35
|
+
.passthrough();
|
|
36
|
+
export const templatesSchema = z.array(templateSchema);
|
|
37
|
+
export const pagedTemplatesSchema = z
|
|
38
|
+
.object({ data: templatesSchema, pagination: paginationSchema })
|
|
39
|
+
.transform(({ data }) => data);
|
|
40
|
+
export const templateStatusSchema = z
|
|
41
|
+
.object({
|
|
25
42
|
status: z.string(),
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
isWindowOpen: z.boolean(),
|
|
29
|
-
leadSummary: z.string().nullable().optional(),
|
|
30
|
-
originatingCampaignId: z.string().nullable().optional(),
|
|
31
|
-
routineKey: z.string(),
|
|
32
|
-
stage: z.string().nullable().optional(),
|
|
33
|
-
nextStep: z.string().nullable().optional(),
|
|
34
|
-
slaDueAt: z.string().nullable().optional(),
|
|
35
|
-
overdue: z.boolean(),
|
|
36
|
-
ownerId: z.string().nullable().optional(),
|
|
37
|
-
ownerName: z.string().nullable().optional(),
|
|
38
|
-
claimedAt: z.string().nullable().optional(),
|
|
39
|
-
lastMessagePreview: z.string().nullable().optional(),
|
|
40
|
-
lastMessageDirection: z.string().nullable().optional(),
|
|
43
|
+
rejectionReason: z.string().nullable().optional(),
|
|
44
|
+
category: z.string().nullable().optional(),
|
|
41
45
|
})
|
|
42
46
|
.passthrough();
|
|
43
|
-
export const
|
|
47
|
+
export const messageSchema = z
|
|
44
48
|
.object({
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
waiting: z.number().int().nonnegative(),
|
|
51
|
-
attention: z.number().int().nonnegative(),
|
|
52
|
-
unassigned: z.number().int().nonnegative(),
|
|
49
|
+
id: z.string().nullable(),
|
|
50
|
+
status: z.string(),
|
|
51
|
+
receiver: z.string(),
|
|
52
|
+
cost: z.number().nullable().optional(),
|
|
53
|
+
reason: z.string().nullable().optional(),
|
|
53
54
|
})
|
|
54
|
-
.transform(({ content, page, size, totalElements, totalPages, waiting, attention, unassigned }) => ({
|
|
55
|
-
data: content,
|
|
56
|
-
pagination: { page, size, totalElements, totalPages },
|
|
57
|
-
summary: { waiting, attention, unassigned },
|
|
58
|
-
}));
|
|
59
|
-
export const todaySchema = z.record(jsonValueSchema);
|
|
60
|
-
export const coverageSchema = z.record(jsonValueSchema);
|
|
61
|
-
export const routineSchema = z
|
|
62
|
-
.object({ key: z.string(), name: z.string().optional(), published: z.boolean() })
|
|
63
55
|
.passthrough();
|
|
64
|
-
export const
|
|
65
|
-
|
|
66
|
-
|
|
56
|
+
export const windowStatusSchema = z.object({
|
|
57
|
+
results: z.array(z
|
|
58
|
+
.object({
|
|
59
|
+
phone: z.string(),
|
|
60
|
+
isWindowOpen: z.boolean(),
|
|
61
|
+
hoursRemaining: z.number().nullable().optional(),
|
|
62
|
+
})
|
|
63
|
+
.passthrough()),
|
|
64
|
+
});
|
|
65
|
+
export const contactSchema = z.object({ name: z.string(), phone: z.string() }).passthrough();
|
|
66
|
+
export const contactsListSchema = z
|
|
67
|
+
.object({ contacts: z.array(contactSchema), total: z.number().int().nonnegative() })
|
|
68
|
+
.passthrough();
|
|
69
|
+
export const contactsBatchSchema = z
|
|
67
70
|
.object({
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
created: z.number().int().nonnegative(),
|
|
72
|
+
updated: z.number().int().nonnegative(),
|
|
73
|
+
skipped: z.number().int().nonnegative(),
|
|
74
|
+
errors: z
|
|
75
|
+
.array(z
|
|
76
|
+
.object({
|
|
77
|
+
index: z.number().int(),
|
|
78
|
+
phone: z.string().nullable().optional(),
|
|
79
|
+
reason: z.string(),
|
|
80
|
+
})
|
|
81
|
+
.passthrough())
|
|
82
|
+
.default([]),
|
|
73
83
|
})
|
|
74
|
-
.
|
|
75
|
-
|
|
76
|
-
pagination: { page, size, totalElements, totalPages },
|
|
77
|
-
}));
|
|
78
|
-
export const mutationSchema = z.record(jsonValueSchema);
|
|
79
|
-
export const automationSchema = z
|
|
84
|
+
.passthrough();
|
|
85
|
+
export const conversationMessageSchema = z
|
|
80
86
|
.object({
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
steps: z.array(z.object({ type: z.string(), config: jsonValueSchema }).passthrough()),
|
|
87
|
-
costPerRunBrl: z.number(),
|
|
88
|
-
stats: jsonValueSchema.optional(),
|
|
89
|
-
webhookUrl: z.string().nullable().optional(),
|
|
87
|
+
direction: z.string(),
|
|
88
|
+
status: z.string(),
|
|
89
|
+
templateName: z.string().nullable().optional(),
|
|
90
|
+
body: z.string().nullable().optional(),
|
|
91
|
+
createdAt: z.string(),
|
|
90
92
|
})
|
|
91
93
|
.passthrough();
|
|
92
|
-
export const
|
|
93
|
-
|
|
94
|
+
export const conversationSchema = z
|
|
95
|
+
.object({
|
|
96
|
+
phone: z.string(),
|
|
97
|
+
total: z.number().int().nonnegative(),
|
|
98
|
+
messages: z.array(conversationMessageSchema),
|
|
99
|
+
})
|
|
100
|
+
.passthrough();
|
|
101
|
+
export const campaignSchema = z
|
|
94
102
|
.object({
|
|
95
103
|
id: z.string(),
|
|
96
104
|
name: z.string(),
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
availableForSending: z.boolean(),
|
|
105
|
+
status: z.string(),
|
|
106
|
+
totalMessages: z.number().int().nonnegative(),
|
|
107
|
+
totalCost: z.number(),
|
|
108
|
+
scheduledAt: z.string().nullable().optional(),
|
|
102
109
|
})
|
|
103
110
|
.passthrough();
|
|
104
|
-
export const
|
|
111
|
+
export const campaignListItemSchema = campaignSchema.extend({
|
|
112
|
+
templateName: z.string(),
|
|
113
|
+
sentCount: z.number().int().nonnegative(),
|
|
114
|
+
deliveredCount: z.number().int().nonnegative(),
|
|
115
|
+
readCount: z.number().int().nonnegative(),
|
|
116
|
+
failedCount: z.number().int().nonnegative(),
|
|
117
|
+
createdAt: z.string().nullable().optional(),
|
|
118
|
+
});
|
|
119
|
+
export const campaignListSchema = z
|
|
120
|
+
.object({
|
|
121
|
+
content: z.array(campaignListItemSchema),
|
|
122
|
+
totalPages: z.number().int().nonnegative(),
|
|
123
|
+
totalElements: z.number().int().nonnegative(),
|
|
124
|
+
})
|
|
125
|
+
.transform(({ content, totalPages, totalElements }) => ({
|
|
126
|
+
data: content,
|
|
127
|
+
pagination: { totalPages, totalElements },
|
|
128
|
+
}));
|
|
129
|
+
export const campaignDetailSchema = campaignListItemSchema.extend({
|
|
130
|
+
clickedCount: z.number().int().nonnegative(),
|
|
131
|
+
replyCount: z.number().int().nonnegative().default(0),
|
|
132
|
+
convertedCount: z.number().int().nonnegative(),
|
|
133
|
+
convertedValue: z.number(),
|
|
134
|
+
holdoutCount: z.number().int().nonnegative().default(0),
|
|
135
|
+
blockedCount: z.number().int().nonnegative().default(0),
|
|
136
|
+
blockReasons: z.array(z.object({ motivo: z.string(), quantidade: z.number() })).default([]),
|
|
137
|
+
refundCount: z.number().int().nonnegative().default(0),
|
|
138
|
+
refundValue: z.number().default(0),
|
|
139
|
+
});
|
|
105
140
|
export const numberSchema = z.record(jsonValueSchema);
|
|
106
141
|
export const numbersSchema = z.object({
|
|
107
142
|
numbers: z.array(numberSchema),
|
|
108
143
|
slot: jsonValueSchema.nullable().optional(),
|
|
109
144
|
});
|
|
110
|
-
export const
|
|
145
|
+
export const campaignEstimateSchema = z
|
|
111
146
|
.object({
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
totalMessages: z.number().int().nonnegative(),
|
|
147
|
+
templateCategory: z.string(),
|
|
148
|
+
recipientCount: z.number().int().nonnegative(),
|
|
149
|
+
unitPrice: z.number(),
|
|
116
150
|
totalCost: z.number(),
|
|
117
|
-
scheduledAt: z.string().nullable().optional(),
|
|
118
151
|
})
|
|
119
152
|
.passthrough();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const URL_ACTION = "URL";
|
|
2
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
3
|
+
const firstTypeContent = (structure) => {
|
|
4
|
+
if (!isRecord(structure) || !isRecord(structure.types))
|
|
5
|
+
return undefined;
|
|
6
|
+
const first = Object.values(structure.types)[0];
|
|
7
|
+
return isRecord(first) ? first : undefined;
|
|
8
|
+
};
|
|
9
|
+
/** Button labels of a template, in order, read from the provider structure JSON. */
|
|
10
|
+
export const templateButtonLabels = (structure) => {
|
|
11
|
+
const content = firstTypeContent(structure);
|
|
12
|
+
const actions = content?.actions;
|
|
13
|
+
if (!Array.isArray(actions))
|
|
14
|
+
return [];
|
|
15
|
+
return actions.flatMap((action) => isRecord(action) && typeof action.title === "string" ? [action.title] : []);
|
|
16
|
+
};
|
|
17
|
+
/** True when at least one button carries a dynamic URL that needs a variable. */
|
|
18
|
+
export const hasDynamicUrlButton = (structure) => {
|
|
19
|
+
const content = firstTypeContent(structure);
|
|
20
|
+
const actions = content?.actions;
|
|
21
|
+
if (!Array.isArray(actions))
|
|
22
|
+
return false;
|
|
23
|
+
return actions.some((action) => isRecord(action) &&
|
|
24
|
+
action.type === URL_ACTION &&
|
|
25
|
+
typeof action.url === "string" &&
|
|
26
|
+
/\{\{\d+\}\}/.test(action.url));
|
|
27
|
+
};
|
package/build/prompts/index.js
CHANGED
|
@@ -2,8 +2,7 @@ const prompt = (text) => ({
|
|
|
2
2
|
messages: [{ role: "user", content: { type: "text", text } }],
|
|
3
3
|
});
|
|
4
4
|
export const registerAllPrompts = (server) => {
|
|
5
|
-
server.registerPrompt("
|
|
6
|
-
server.registerPrompt("
|
|
7
|
-
server.registerPrompt("
|
|
8
|
-
server.registerPrompt("close_case_review", { description: "Check whether a conversation is ready to close." }, () => prompt("Load the conversation timeline and its metadata. Confirm owner, outcome, next step and any promised follow-up. Recommend closure only when no unresolved obligation remains. Do not call close_conversation without explicit confirmation."));
|
|
5
|
+
server.registerPrompt("plan_broadcast", { description: "Prepare a broadcast and ask for approval before sending." }, () => prompt("Call whoami to confirm the organization and balance. Read arara://templates/approved and pick the template that fits the goal, or propose one with create_template if none fits. Call broadcast in its default dry-run mode to render the message, resolve the audience and estimate the cost, and show that preview. Only call broadcast with dryRun=false after the user explicitly approves."));
|
|
6
|
+
server.registerPrompt("campaign_review", { description: "Read the results of a campaign and say what to do next." }, () => prompt("Call campaign_report without an id to find the campaign, then with its id for the full report. Summarize delivery, reads, clicks, replies and conversions as percentages of the audience, explain blocked and refunded rows, and recommend one next action: reply to responders, re-send to the unread with another template, or stop."));
|
|
7
|
+
server.registerPrompt("reply_to_responses", { description: "Answer people who replied to a broadcast." }, () => prompt("Use read_conversation to read what the person wrote. Draft a short reply consistent with the campaign and the conversation so far. If they asked to stop, call opt_out instead of replying. Do not call send_whatsapp until the user approves the exact text."));
|
|
9
8
|
};
|
package/build/resources/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { apiRequest } from "../lib/api.js";
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import { balanceSchema, campaignListSchema, identitySchema, numbersSchema, pagedTemplatesSchema, planSchema, } from "../lib/schemas.js";
|
|
3
|
+
const RECENT_CAMPAIGNS_SIZE = 10;
|
|
4
|
+
const APPROVED_STATUS = "APPROVED";
|
|
4
5
|
const json = (value) => JSON.stringify(value, null, 2);
|
|
5
|
-
const routinesResponseSchema = z.object({ data: z.array(routineSchema) });
|
|
6
6
|
const registerJsonResource = (server, name, uri, title, description, loader) => {
|
|
7
7
|
server.registerResource(name, uri, { title, description, mimeType: "application/json" }, async (resourceUri) => {
|
|
8
8
|
try {
|
|
@@ -32,29 +32,27 @@ const registerJsonResource = (server, name, uri, title, description, loader) =>
|
|
|
32
32
|
});
|
|
33
33
|
};
|
|
34
34
|
export const registerAllResources = (server) => {
|
|
35
|
-
registerJsonResource(server, "organization", "arara://organization", "Organization", "Authenticated identity and
|
|
36
|
-
const [identity, plan] = await Promise.all([
|
|
35
|
+
registerJsonResource(server, "organization", "arara://organization", "Organization", "Authenticated identity, current plan and wallet balance.", async () => {
|
|
36
|
+
const [identity, plan, balance] = await Promise.all([
|
|
37
37
|
apiRequest("/auth/me", { schema: identitySchema }),
|
|
38
38
|
apiRequest("/v1/organizations/me/plan", { schema: planSchema }),
|
|
39
|
+
apiRequest("/dashboard/wallet/balance", { schema: balanceSchema }),
|
|
39
40
|
]);
|
|
40
|
-
return { identity, plan };
|
|
41
|
+
return { identity, plan, balance };
|
|
41
42
|
});
|
|
42
|
-
registerJsonResource(server, "
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return {
|
|
49
|
-
today,
|
|
50
|
-
channels: numbers,
|
|
51
|
-
publishedRoutines: routines.data.filter((routine) => routine.published),
|
|
52
|
-
};
|
|
43
|
+
registerJsonResource(server, "approved_templates", "arara://templates/approved", "Approved templates", "Templates Meta currently allows for sending. Use their names in broadcast and send_whatsapp.", async () => {
|
|
44
|
+
const params = new URLSearchParams({ status: APPROVED_STATUS, size: "100" });
|
|
45
|
+
const templates = await apiRequest(`/v1/templates?${params.toString()}`, {
|
|
46
|
+
schema: pagedTemplatesSchema,
|
|
47
|
+
});
|
|
48
|
+
return templates.filter((template) => template.availableForSending);
|
|
53
49
|
});
|
|
54
|
-
registerJsonResource(server, "
|
|
55
|
-
const
|
|
56
|
-
|
|
50
|
+
registerJsonResource(server, "recent_campaigns", "arara://campaigns/recent", "Recent campaigns", "Latest broadcasts with status, counts and cost.", async () => {
|
|
51
|
+
const params = new URLSearchParams({ page: "0", size: String(RECENT_CAMPAIGNS_SIZE) });
|
|
52
|
+
const result = await apiRequest(`/v1/campaigns?${params.toString()}`, {
|
|
53
|
+
schema: campaignListSchema,
|
|
54
|
+
});
|
|
55
|
+
return result.data;
|
|
57
56
|
});
|
|
58
|
-
registerJsonResource(server, "channels", "arara://channels", "WhatsApp
|
|
59
|
-
registerJsonResource(server, "coverage", "arara://coverage", "Service coverage", "Working hours and after-hours policy for Atendimento.", () => apiRequest("/v1/operation/coverage", { schema: coverageSchema }));
|
|
57
|
+
registerJsonResource(server, "channels", "arara://channels", "WhatsApp numbers", "Configured sending numbers and slot state for this organization.", () => apiRequest("/v1/organizations/me/numbers", { schema: numbersSchema }));
|
|
60
58
|
};
|
package/build/server/create.js
CHANGED
|
@@ -3,10 +3,14 @@ import { SERVER_NAME, SERVER_VERSION } from "../config.js";
|
|
|
3
3
|
import { registerAllPrompts } from "../prompts/index.js";
|
|
4
4
|
import { registerAllResources } from "../resources/index.js";
|
|
5
5
|
import { registerAllTools } from "../tools/index.js";
|
|
6
|
+
import { registerOperatorTools } from "../tools/operator.js";
|
|
7
|
+
import { registerUiResources } from "../ui/resources.js";
|
|
6
8
|
export const createServer = () => {
|
|
7
9
|
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
|
|
8
10
|
registerAllTools(server);
|
|
11
|
+
registerOperatorTools(server);
|
|
9
12
|
registerAllResources(server);
|
|
13
|
+
registerUiResources(server);
|
|
10
14
|
registerAllPrompts(server);
|
|
11
15
|
return server;
|
|
12
16
|
};
|