conduyt-mcp 4.83.0 → 4.85.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 +5 -2
- package/dist/client.d.ts +7 -0
- package/dist/client.js +57 -5
- package/dist/tools/contacts.d.ts +5 -0
- package/dist/tools/contacts.js +72 -2
- package/dist/tools/messaging.js +1 -1
- package/package.json +1 -1
- package/scripts/coverage-matrix.json +8 -6
package/README.md
CHANGED
|
@@ -15,9 +15,10 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
|
|
|
15
15
|
| Tool | Description |
|
|
16
16
|
|------|-------------|
|
|
17
17
|
| `conduyt_list_contacts` | List and search contacts with filters |
|
|
18
|
-
| `conduyt_get_contact` | Get full contact details with timeline; `hasOutboundSms` = the workspace has ever provably texted the contact |
|
|
18
|
+
| `conduyt_get_contact` | Get full contact details with timeline; `hasOutboundSms` = the workspace has ever provably texted the contact; `hasOutboundEmail` = the same for email |
|
|
19
19
|
| `conduyt_create_contact` | Create a new contact |
|
|
20
20
|
| `conduyt_update_contact` | Update contact fields; optional `expectedRevision` (0..2147483647) refuses a stale save with 409 `contact_changed` (code + current revision in the error) |
|
|
21
|
+
| `conduyt_merge_contacts` | DESTRUCTIVE: merge duplicates into a survivor (`POST /contacts/merge`, owner/admin); the losers are deleted. Two steps: without `confirmImpact` a preview (`counts`, `guardCounts`, `identifierOverflow`, `blockers`); with `confirmImpact: true` plus the preview's `expectedCounts`, `expectedGuardCounts` (and `expectedIdentifiers`) the merge; the confirm is sent only against a preview this server returned in the last 10 minutes (else refused locally); the server pins the record counts, guard counts and identifier plan, not other field edits made after the preview; 75 s timeout. Moved counts 409, 503 `merge_contacts_busy` / `merge_budget_exhausted` + Retry-After surfaced verbatim, never retried |
|
|
21
22
|
| `conduyt_add_contact_collaborator` | Add a team member as a collaborator (`POST /contacts/{id}/collaborators`); optional `expectedRevision`, 409 `contact_changed` |
|
|
22
23
|
| `conduyt_remove_contact_collaborator` | Remove a collaborator (`DELETE /contacts/{id}/collaborators`, body); optional `expectedRevision`, 409 `contact_changed`, 404 when not a collaborator |
|
|
23
24
|
| `conduyt_add_tags` | Add tags to a contact |
|
|
@@ -110,7 +111,7 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
|
|
|
110
111
|
| `conduyt_whatsapp_templates` | The account's WhatsApp templates (Twilio Content) with Meta's approval verdict: sid, text with {{n}} placeholders, variables, approval |
|
|
111
112
|
| `conduyt_send_sms` | Send SMS to a contact (max 1600 chars); transport twilio, project_blue, or whatsapp (an approved template by Content SID, or free-form within the 24-hour window) |
|
|
112
113
|
| `conduyt_send_email` | Send a single email to a contact |
|
|
113
|
-
| `conduyt_check_message` | Grade a draft text or email before sending (`POST /messages/check`): score, grade, verdict, flagged words with safer alternatives (Unicode code-point offsets), checks, a text's segment count. Educates, never blocks; nothing stored or sent. `context` template / first / conversation (pick from `hasOutboundSms`); `provider: "twilio"` (text only; the notices in Twilio's wording; an email with a provider is a 400) and `clean: true` (adds the cleaner's proposal); the answer carries `breakdown`, `notices`, `unicodeCulprits` and `engine.version` (2.1
|
|
114
|
+
| `conduyt_check_message` | Grade a draft text or email before sending (`POST /messages/check`): score, grade, verdict, flagged words with safer alternatives (Unicode code-point offsets), checks, a text's segment count. Educates, never blocks; nothing stored or sent. `context` template / first / conversation (pick from `hasOutboundSms`); `provider: "twilio"` (text only; the notices in Twilio's wording; an email with a provider is a 400) and `clean: true` (adds the cleaner's proposal); the answer carries `breakdown`, `notices`, `unicodeCulprits`, `englishDictionary` (`loaded` / `not_loaded`: the final grade is only meaningful when it is `loaded`) and `engine.version` (2.2.1); 20 per minute per account; 400 / 403 / 413 / 429 surfaced verbatim, never retried |
|
|
114
115
|
| `conduyt_list_sms_provider_options` | The workspace's active SMS providers, redacted (`GET /sms-providers/template-options`): `{ data: [{ id, name, color }] }`, nothing else. Needs `email_templates.view` or `settings.view` (scopes emails / sms / messages / settings); read-only |
|
|
115
116
|
| `conduyt_get_message_check_minimum_grade` | The admin's Message check minimum grade to send (`settings.messageCheck.minimumGrade`): `enabled`, per-channel `sms` / `email` minimum (letter A-F, score 0-100 or null) and `adminOverride`. Groundwork: stored and read back, not enforced yet. Owner / admin only (anyone else gets `visible: false`); settings scope |
|
|
116
117
|
| `conduyt_update_message_check_minimum_grade` | Change it (`PATCH /settings`): `enabled`, `smsMinimum`, `emailMinimum`, `adminOverride`; sends the whole block with the settings revision it read. `enabled: true` is refused 400 `minimum_grade_not_available_yet`; a non-owner / non-admin gets 403 `forbidden`; 422 for E, fractions, out-of-range scores; all verbatim, never retried |
|
|
@@ -387,6 +388,8 @@ object too large for the bound is left out and named in `_omitted`. Every HTTP r
|
|
|
387
388
|
Conduyt API 503: Contact merge in progress [Retry-After: 5s] {"code":"merge_in_progress"}
|
|
388
389
|
```
|
|
389
390
|
|
|
391
|
+
The contact merge route (`POST /contacts/merge`, tool `conduyt_merge_contacts`) answers a retryable 503 with `Retry-After` and the code `merge_contacts_busy` (a contact stayed busy for the whole lock budget) or `merge_budget_exhausted` (the request's time budget ran out), nothing changed. Any tool that reaches it surfaces the refusal in this grammar and never retries it (one API job per key); the caller retries after the stated seconds.
|
|
392
|
+
|
|
390
393
|
## Setup
|
|
391
394
|
|
|
392
395
|
```bash
|
package/dist/client.d.ts
CHANGED
|
@@ -37,6 +37,13 @@ export interface RequestOptions {
|
|
|
37
37
|
* ALWAYS_WHOLE_KEYS; a nested object that does not fit is left out and named in `_omitted`. JSON is never cut mid-object.
|
|
38
38
|
*/
|
|
39
39
|
export declare function renderErrorDetails(body: Record<string, unknown>): string;
|
|
40
|
+
/**
|
|
41
|
+
* The ONE Retry-After parser. RFC 9110: delay-seconds (digits; a value too long for a number saturates at 2^31-1) or an
|
|
42
|
+
* HTTP-date in one of the three §5.6.7 forms, parsed explicitly and always as UTC (never the host timezone): IMF-fixdate,
|
|
43
|
+
* RFC 850 (two-digit year: more than 50 years ahead of `nowMs` means the previous century) and asctime. A date becomes the
|
|
44
|
+
* remaining seconds, floored at 0. Absent, blank or anything else (ISO timestamps, other zones, "-3") is null.
|
|
45
|
+
*/
|
|
46
|
+
export declare function parseRetryAfter(raw: string | null | undefined, nowMs?: number): number | null;
|
|
40
47
|
export declare class ConduytClient {
|
|
41
48
|
private baseUrl;
|
|
42
49
|
private apiKey;
|
package/dist/client.js
CHANGED
|
@@ -104,6 +104,58 @@ export function renderErrorDetails(body) {
|
|
|
104
104
|
out._omitted = omitted;
|
|
105
105
|
return JSON.stringify(out);
|
|
106
106
|
}
|
|
107
|
+
const RA_MAX_SECONDS = 2_147_483_647; // delay-seconds is unbounded 1*DIGIT: a longer value saturates here instead of becoming null
|
|
108
|
+
const RA_MONTHS = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
|
|
109
|
+
const RA_MON = "Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec";
|
|
110
|
+
const RA_IMF = new RegExp(`^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\\d{2}) (${RA_MON}) (\\d{4}) (\\d{2}):(\\d{2}):(\\d{2}) GMT$`);
|
|
111
|
+
const RA_850 = new RegExp(`^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\\d{2})-(${RA_MON})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2}) GMT$`);
|
|
112
|
+
const RA_ASC = new RegExp(`^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (${RA_MON}) ([ \\d]\\d) (\\d{2}):(\\d{2}):(\\d{2}) (\\d{4})$`);
|
|
113
|
+
/** A UTC timestamp for the parts, or NaN when any part is out of range (31 Feb, 25:00, ...). */
|
|
114
|
+
function raUtc(y, mo, d, h, mi, s) {
|
|
115
|
+
if (y < 1900 || h > 23 || mi > 59 || s > 60)
|
|
116
|
+
return NaN; // RFC 5322: years below 1900 are not dates
|
|
117
|
+
const t = Date.UTC(y, mo, d, h, mi, Math.min(s, 59));
|
|
118
|
+
const back = new Date(t);
|
|
119
|
+
return back.getUTCFullYear() === y && back.getUTCMonth() === mo && back.getUTCDate() === d ? t : NaN;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The ONE Retry-After parser. RFC 9110: delay-seconds (digits; a value too long for a number saturates at 2^31-1) or an
|
|
123
|
+
* HTTP-date in one of the three §5.6.7 forms, parsed explicitly and always as UTC (never the host timezone): IMF-fixdate,
|
|
124
|
+
* RFC 850 (two-digit year: more than 50 years ahead of `nowMs` means the previous century) and asctime. A date becomes the
|
|
125
|
+
* remaining seconds, floored at 0. Absent, blank or anything else (ISO timestamps, other zones, "-3") is null.
|
|
126
|
+
*/
|
|
127
|
+
export function parseRetryAfter(raw, nowMs = Date.now()) {
|
|
128
|
+
if (raw === null || raw === undefined)
|
|
129
|
+
return null;
|
|
130
|
+
const v = raw.trim();
|
|
131
|
+
if (v === "")
|
|
132
|
+
return null;
|
|
133
|
+
if (/^\d+(\.\d+)?$/.test(v)) {
|
|
134
|
+
const n = Number(v);
|
|
135
|
+
return Number.isNaN(n) ? null : Math.min(n, RA_MAX_SECONDS);
|
|
136
|
+
}
|
|
137
|
+
let t = NaN;
|
|
138
|
+
let m;
|
|
139
|
+
if ((m = RA_IMF.exec(v)))
|
|
140
|
+
t = raUtc(Number(m[3]), RA_MONTHS[m[2]], Number(m[1]), Number(m[4]), Number(m[5]), Number(m[6]));
|
|
141
|
+
else if ((m = RA_850.exec(v))) {
|
|
142
|
+
const century = Math.floor(new Date(nowMs).getUTCFullYear() / 100) * 100;
|
|
143
|
+
const mo = RA_MONTHS[m[2]], d = Number(m[1]), h = Number(m[4]), mi = Number(m[5]), sec = Number(m[6]);
|
|
144
|
+
t = raUtc(century + Number(m[3]), mo, d, h, mi, sec);
|
|
145
|
+
// RFC 9110: a timestamp more than 50 years in the future (the same instant 50 calendar years on, UTC) is the previous century
|
|
146
|
+
// (the day is clamped to the month's last day: 29 Feb + 50 years is 28 Feb, never 1 Mar)
|
|
147
|
+
const n = new Date(nowMs);
|
|
148
|
+
const ly = n.getUTCFullYear() + 50, lm = n.getUTCMonth();
|
|
149
|
+
const limit = Date.UTC(ly, lm, Math.min(n.getUTCDate(), new Date(Date.UTC(ly, lm + 1, 0)).getUTCDate()), n.getUTCHours(), n.getUTCMinutes(), n.getUTCSeconds(), n.getUTCMilliseconds());
|
|
150
|
+
if (!Number.isNaN(t) && t > limit)
|
|
151
|
+
t = raUtc(century - 100 + Number(m[3]), mo, d, h, mi, sec);
|
|
152
|
+
}
|
|
153
|
+
else if ((m = RA_ASC.exec(v)))
|
|
154
|
+
t = raUtc(Number(m[6]), RA_MONTHS[m[1]], Number(m[2].trim()), Number(m[3]), Number(m[4]), Number(m[5]));
|
|
155
|
+
if (Number.isNaN(t))
|
|
156
|
+
return null;
|
|
157
|
+
return Math.max(0, Math.ceil((t - nowMs) / 1000));
|
|
158
|
+
}
|
|
107
159
|
export class ConduytClient {
|
|
108
160
|
baseUrl;
|
|
109
161
|
apiKey;
|
|
@@ -240,9 +292,9 @@ export class ConduytClient {
|
|
|
240
292
|
* until the rest of the text is valid JSON (a server message may itself contain a brace).
|
|
241
293
|
*/
|
|
242
294
|
httpError(res, text) {
|
|
243
|
-
const
|
|
244
|
-
const retryAfter =
|
|
245
|
-
const retryAfterOk =
|
|
295
|
+
const retryAfterParsed = parseRetryAfter(res.headers.get("retry-after"));
|
|
296
|
+
const retryAfter = retryAfterParsed ?? NaN;
|
|
297
|
+
const retryAfterOk = retryAfterParsed !== null;
|
|
246
298
|
const raTag = retryAfterOk ? ` [Retry-After: ${retryAfter}s]` : "";
|
|
247
299
|
let msg = res.statusText;
|
|
248
300
|
try {
|
|
@@ -275,8 +327,8 @@ export class ConduytClient {
|
|
|
275
327
|
});
|
|
276
328
|
}
|
|
277
329
|
retryDelayMs(res, attemptIndex) {
|
|
278
|
-
const retryAfter =
|
|
279
|
-
if (
|
|
330
|
+
const retryAfter = parseRetryAfter(res?.headers.get("retry-after") ?? null);
|
|
331
|
+
if (retryAfter !== null) {
|
|
280
332
|
return Math.min(retryAfter * 1000, MAX_RETRY_AFTER_MS);
|
|
281
333
|
}
|
|
282
334
|
return Math.min(this.retryBaseMs * 2 ** attemptIndex, MAX_RETRY_AFTER_MS);
|
package/dist/tools/contacts.d.ts
CHANGED
|
@@ -4,4 +4,9 @@ import { ConduytClient } from "../client.js";
|
|
|
4
4
|
export declare const CONTACT_REVISION_MAX = 2147483647;
|
|
5
5
|
/** (#136, app 10-10) the refusals the contact writes answer for a number or a country the API cannot accept: surfaced verbatim, never retried */
|
|
6
6
|
export declare const INVALID_NUMBER_REFUSALS = " An impossible US/Canada number (area code or exchange starting 0 or 1) is refused 400 phone_invalid and nothing is saved; a country is never null or blank (400 country_invalid on an update, 2-letter code, min 1 character). Both are surfaced verbatim and never retried.";
|
|
7
|
+
/** the merge route's own budget is 45 s: the client's 30 s default could abort while the server commits (the call is then ambiguous) */
|
|
8
|
+
export declare const MERGE_TIMEOUT_MS = 75000;
|
|
9
|
+
export declare const LOSER_ORDER_NOTE = "losers are merged in the order given: for each field the survivor lacks, the first loser in this order with a value wins; reorder to change precedence; the dry run's filledFields shows which fields will be filled";
|
|
10
|
+
export declare const MERGE_PREVIEW_MAX = 50;
|
|
11
|
+
export declare const MERGE_PREVIEW_TTL_MS: number;
|
|
7
12
|
export declare function registerContactTools(server: McpServer, client: ConduytClient): void;
|
package/dist/tools/contacts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { formatResult } from "../client.js";
|
|
2
|
+
import { formatResult, formatToolError } from "../client.js";
|
|
3
3
|
import { strictInput } from "./strict.js";
|
|
4
4
|
/** The contact save revision pin (catalog bounds: integer 0..2147483647). */
|
|
5
5
|
export const CONTACT_REVISION_MAX = 2147483647;
|
|
@@ -22,7 +22,38 @@ function resolveCustomFields(camel, snake) {
|
|
|
22
22
|
}
|
|
23
23
|
/** (#136, app 10-10) the refusals the contact writes answer for a number or a country the API cannot accept: surfaced verbatim, never retried */
|
|
24
24
|
export const INVALID_NUMBER_REFUSALS = " An impossible US/Canada number (area code or exchange starting 0 or 1) is refused 400 phone_invalid and nothing is saved; a country is never null or blank (400 country_invalid on an update, 2-letter code, min 1 character). Both are surfaced verbatim and never retried.";
|
|
25
|
+
/** the merge route's own budget is 45 s: the client's 30 s default could abort while the server commits (the call is then ambiguous) */
|
|
26
|
+
export const MERGE_TIMEOUT_MS = 75_000;
|
|
27
|
+
export const LOSER_ORDER_NOTE = "losers are merged in the order given: for each field the survivor lacks, the first loser in this order with a value wins; reorder to change precedence; the dry run's filledFields shows which fields will be filled";
|
|
28
|
+
export const MERGE_PREVIEW_MAX = 50;
|
|
29
|
+
export const MERGE_PREVIEW_TTL_MS = 10 * 60_000;
|
|
30
|
+
const stable = (v) => JSON.stringify(v, (_k, x) => (x && typeof x === "object" && !Array.isArray(x) ? Object.fromEntries(Object.entries(x).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : x));
|
|
31
|
+
const mergePreviewKey = (primaryId, secondaryIds) => `${primaryId.toLowerCase()}|${secondaryIds.map((x) => x.toLowerCase()).join(",")}`;
|
|
32
|
+
/** bounded, per-server (so per-account: one server holds one API key) record of the previews this tool returned; a confirm is sent only against one */
|
|
33
|
+
function createMergePreviewStore() {
|
|
34
|
+
const rows = new Map();
|
|
35
|
+
return {
|
|
36
|
+
record(key, p) {
|
|
37
|
+
rows.delete(key);
|
|
38
|
+
rows.set(key, { ...p, at: Date.now() });
|
|
39
|
+
while (rows.size > MERGE_PREVIEW_MAX)
|
|
40
|
+
rows.delete(rows.keys().next().value);
|
|
41
|
+
},
|
|
42
|
+
get(key) {
|
|
43
|
+
const r = rows.get(key);
|
|
44
|
+
if (!r)
|
|
45
|
+
return undefined;
|
|
46
|
+
if (Date.now() - r.at > MERGE_PREVIEW_TTL_MS) {
|
|
47
|
+
rows.delete(key);
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
return r;
|
|
51
|
+
},
|
|
52
|
+
drop(key) { rows.delete(key); },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
25
55
|
export function registerContactTools(server, client) {
|
|
56
|
+
const mergePreviews = createMergePreviewStore();
|
|
26
57
|
server.tool("conduyt_list_contacts", "List and search contacts with filtering. Returns paginated results with name, email, phone, tags, source, and assignee. Each row carries `revision` (integer save revision; pass it back as expectedRevision on conduyt_update_contact or the collaborator tools).", {
|
|
27
58
|
search: z.string().optional().describe("Search by name, email, or phone number"),
|
|
28
59
|
tag: z.string().optional().describe("Filter by tag name"),
|
|
@@ -53,7 +84,7 @@ export function registerContactTools(server, client) {
|
|
|
53
84
|
const result = await client.get(`/api/v1/contacts?${params}`);
|
|
54
85
|
return formatResult(result);
|
|
55
86
|
});
|
|
56
|
-
server.tool("conduyt_get_contact", "Get a single contact with full details — profile, tags, custom fields, timeline, deals, and activity history. Includes `revision` (integer save revision): pass it as expectedRevision on conduyt_update_contact or the collaborator tools so a newer change is never overwritten. Includes `hasOutboundSms` (boolean): true when this workspace has ever PROVABLY texted the contact (a proven send only: not a draft, a queued row or a pre-send failure); use it to pick the `context` for conduyt_check_message (false = 'first', true = 'conversation').", {
|
|
87
|
+
server.tool("conduyt_get_contact", "Get a single contact with full details — profile, tags, custom fields, timeline, deals, and activity history. Includes `revision` (integer save revision): pass it as expectedRevision on conduyt_update_contact or the collaborator tools so a newer change is never overwritten. Includes `hasOutboundSms` (boolean): true when this workspace has ever PROVABLY texted the contact (a proven send only: not a draft, a queued row or a pre-send failure); use it to pick the `context` for conduyt_check_message (false = 'first', true = 'conversation'). Also includes `hasOutboundEmail` (boolean): the same for email (true when at least one outbound email to the contact was proven sent, so a genuine \"Re:\" reply is a conversation and not flagged); use it to pick the `context` for an email check.", {
|
|
57
88
|
id: z.string().describe("Contact UUID"),
|
|
58
89
|
}, async ({ id }) => {
|
|
59
90
|
const result = await client.get(`/api/v1/contacts/${id}`);
|
|
@@ -142,4 +173,43 @@ export function registerContactTools(server, client) {
|
|
|
142
173
|
const result = await client.post("/api/v1/contacts/verify-line-type", params);
|
|
143
174
|
return formatResult(result);
|
|
144
175
|
});
|
|
176
|
+
strictInput(server.tool("conduyt_merge_contacts", "DESTRUCTIVE and not reversible through the API: merge duplicate contacts (POST /api/v1/contacts/merge; owner or admin). The survivor (`primaryId`) keeps its id and receives every related record of the losers (`secondaryIds`: tags, deals, messages, activities, tasks, appointments and so on) plus any field it lacks; each loser is then DELETED (soft-deleted, gone from lists and searches, no undelete). A forgotten (anonymized) contact cannot be merged. TWO STEPS, always in this order. STEP 1, preview: call with only primaryId + secondaryIds (confirmImpact absent or false); nothing is changed and the answer is { data: { dryRun: true, primary, secondaries, counts, totalRecords, guardCounts, filledFields, identifierOverflow: { intoPrimary, intoSlots, unstored }, blockers: { outbox } } } (the standard envelope: read the fields inside `data`). Show the user (or decide on) what will move and what will be deleted; `unstored` lists emails or phones with nowhere to go (they would be lost); `blockers.outbox` lists queued sends that must drain first. STEP 2, apply: call again with confirmImpact: true and pass back EXACTLY what the preview answered: expectedCounts = data.counts, expectedGuardCounts = data.guardCounts (both required), and expectedIdentifiers = data.identifierOverflow (recommended: without it the merge refuses only when a value would be dropped). The tool binds the confirm to the preview it returned: a confirm whose values do not equal a preview this server returned for the same survivor and losers in the last 10 minutes is refused locally (\"run the dry run first (no matching preview)\") and nothing is sent. LOSER ORDER: losers are merged in the order given: for each field the survivor lacks, the first loser in this order with a value wins; reorder to change precedence; the dry run's filledFields shows which fields will be filled. WHAT IS PINNED: the server refuses if the record counts, guard counts or identifier plan moved; other field edits made after this preview (a loser's name, company, source, address) are not detected. The server re-counts inside the merge: if the counts, guard counts or identifier plan moved since the preview it answers 409 (for example \"Merge impact changed. Refresh duplicate scan and try again.\"); that text is surfaced verbatim and nothing was merged: preview again and decide again, never reuse the old numbers. Other refusals, surfaced verbatim and never retried: 400 (primaryId missing, secondaryIds empty, primaryId inside secondaryIds, malformed expected values), 404 (a contact not found or not yours), 409 (queued work on the contacts being merged, a drip or sequence send in flight, a deal publication in flight, a refused forgotten contact). 503 with `code` merge_contacts_busy (a contact stayed busy for the whole lock budget: a delivery in flight, another merge or an erasure of the same contact) or merge_budget_exhausted (the time budget ran out): everything was rolled back, nothing changed; the error text carries the code and `Retry-After: <N>s`. This tool NEVER retries (one API job per key): wait at least that many seconds, then run the preview again. The apply answers { data: the merged survivor contact plus identifierOverflow }.", {
|
|
177
|
+
primaryId: z.string().uuid().describe("The surviving contact UUID: it keeps its id and receives the losers' records"),
|
|
178
|
+
secondaryIds: z.array(z.string().uuid()).min(1).max(50).describe("The duplicate contact UUIDs to merge into the survivor and DELETE (not the survivor itself). Order matters: " + LOSER_ORDER_NOTE),
|
|
179
|
+
confirmImpact: z.boolean().optional().describe("Omit or false = STEP 1, the preview (nothing changes). true = STEP 2, apply the merge, with expectedCounts and expectedGuardCounts from the preview."),
|
|
180
|
+
expectedCounts: z.record(z.number().min(0)).optional().describe("The preview's `counts`, passed back unchanged (required when confirmImpact is true)"),
|
|
181
|
+
expectedGuardCounts: z.record(z.number().min(0)).optional().describe("The preview's `guardCounts`, passed back unchanged (required when confirmImpact is true)"),
|
|
182
|
+
expectedIdentifiers: z.object({
|
|
183
|
+
intoPrimary: z.object({ email: z.string().optional(), phone: z.string().optional() }).optional(),
|
|
184
|
+
intoSlots: z.record(z.string()),
|
|
185
|
+
unstored: z.array(z.object({ kind: z.string(), value: z.string() })),
|
|
186
|
+
}).optional().describe("The preview's `identifierOverflow` ({ intoPrimary, intoSlots, unstored }), passed back unchanged (recommended on apply)"),
|
|
187
|
+
}, async ({ primaryId, secondaryIds, confirmImpact, expectedCounts, expectedGuardCounts, expectedIdentifiers }) => {
|
|
188
|
+
const key = mergePreviewKey(primaryId, secondaryIds);
|
|
189
|
+
if (confirmImpact !== true) {
|
|
190
|
+
const preview = await client.post("/api/v1/contacts/merge", { primaryId, secondaryIds, dryRun: true }, { timeoutMs: MERGE_TIMEOUT_MS });
|
|
191
|
+
const d = (preview?.data ?? preview);
|
|
192
|
+
if (d && typeof d === "object" && d.counts && d.guardCounts) {
|
|
193
|
+
mergePreviews.record(key, { counts: d.counts, guardCounts: d.guardCounts, identifiers: d.identifierOverflow });
|
|
194
|
+
}
|
|
195
|
+
return formatResult(preview);
|
|
196
|
+
}
|
|
197
|
+
const seen = mergePreviews.get(key);
|
|
198
|
+
if (!seen) {
|
|
199
|
+
return formatToolError("Run the dry run first (no matching preview): this server holds no recent preview of this survivor and these losers (previews are kept for 10 minutes, the last 50). Call again WITHOUT confirmImpact, then confirm with exactly the values it answered. Nothing was sent.");
|
|
200
|
+
}
|
|
201
|
+
if (!expectedCounts || !expectedGuardCounts || stable(expectedCounts) !== stable(seen.counts) || stable(expectedGuardCounts) !== stable(seen.guardCounts) || (expectedIdentifiers !== undefined && stable(expectedIdentifiers) !== stable(seen.identifiers))) {
|
|
202
|
+
return formatToolError("Run the dry run first (no matching preview): expectedCounts, expectedGuardCounts and expectedIdentifiers must equal, unchanged, what the preview of this survivor and these losers answered (data.counts, data.guardCounts, data.identifierOverflow). Nothing was sent.");
|
|
203
|
+
}
|
|
204
|
+
const result = await client.post("/api/v1/contacts/merge", {
|
|
205
|
+
primaryId,
|
|
206
|
+
secondaryIds,
|
|
207
|
+
confirmImpact: true,
|
|
208
|
+
expectedCounts: seen.counts,
|
|
209
|
+
expectedGuardCounts: seen.guardCounts,
|
|
210
|
+
...(seen.identifiers ? { expectedIdentifiers: seen.identifiers } : {}),
|
|
211
|
+
}, { timeoutMs: MERGE_TIMEOUT_MS });
|
|
212
|
+
mergePreviews.drop(key);
|
|
213
|
+
return formatResult(result);
|
|
214
|
+
}));
|
|
145
215
|
}
|
package/dist/tools/messaging.js
CHANGED
|
@@ -19,7 +19,7 @@ export function checkMessageError(err) {
|
|
|
19
19
|
throw err;
|
|
20
20
|
}
|
|
21
21
|
export function registerMessagingTools(server, client) {
|
|
22
|
-
strictInput(server.tool("conduyt_check_message", "Grade a DRAFT text or email before it is sent (POST /api/v1/messages/check): the same check the contact composers run as the rep types (the conduyt.com checker engine). Answers { data: { score 0-100, grade A-F, verdict (e.g. 'Good to send', 'Edit these words first', \"Don't send\"), vclass, hits[], subjectHits[] (email), checks[], sms (a text's chars / segments / unicode / perSeg; null for an email), context, notices[] (SMS; informational), breakdown[] (the score's bands, offsets in code points like the hits), unicodeCulprits[] (SMS; the characters that force Unicode, positions in code points), cleaned (only when clean is true), engine { version, updatedAt } } } (engine 2.1
|
|
22
|
+
strictInput(server.tool("conduyt_check_message", "Grade a DRAFT text or email before it is sent (POST /api/v1/messages/check): the same check the contact composers run as the rep types (the conduyt.com checker engine). Answers { data: { score 0-100, grade A-F, verdict (e.g. 'Good to send', 'Edit these words first', \"Don't send\"), vclass, hits[], subjectHits[] (email), checks[], sms (a text's chars / segments / unicode / perSeg; null for an email), context, notices[] (SMS; informational), breakdown[] (the score's bands, offsets in code points like the hits), unicodeCulprits[] (SMS; the characters that force Unicode, positions in code points), cleaned (only when clean is true), englishDictionary ('loaded' | 'not_loaded', engine 2.2.1+), engine { version, updatedAt } } } (engine 2.2.1 today; any further field is additive). The final grade is only meaningful when englishDictionary is 'loaded' (the server embeds the dictionary, so it is): a result with any other value, or without the field, is provisional (the 'no recognizable English words' check did not run): show it as unavailable, never as a grade. Each hit: { start, end, word, level (r likely blocked, a risky, y caution, n heads up), label, cat, why, alts[] (safer wording; some entries are advice rather than a swap), restricted (prohibited in US business texting: no rewording makes it deliverable), rule }. start/end are Unicode CODE-POINT offsets into the submitted text (the body for hits, the subject for subjectHits), end exclusive; an emoji counts as one, so [...text].slice(start, end) in JavaScript or text[start:end] in Python is the word (not a UTF-16 index). The check EDUCATES and never blocks: nothing is stored and nothing is sent, it only suggests; merge tokens such as {{contact.firstName}} are never flagged. context: 'template' (default) grades it as a reusable message, 'first' is the first text to a contact, 'conversation' is a reply in an existing thread (engine 2.2.1: 'conversation' skips the SMS opt-out line and who-is-texting checks and the email fake \"Re:\" / \"Fwd:\" subject check, because a reply in an existing thread legitimately lacks them; 'template' and 'first' run all of them). To pick first vs conversation for a real contact read hasOutboundSms on conduyt_get_contact (hasOutboundEmail for an email). subject is email only (a subject on a text is refused 400 invalid_body). provider: 'twilio' (text only) words the notices in Twilio's own wording, as the composers show them; an email check with a provider is refused 400 invalid_body, and so is any other provider value (list the workspace's SMS providers with conduyt_list_sms_provider_options; only 'twilio' is a check wording today). clean: true adds `cleaned` { text, subject (email), changes[], skipped[] } or null: the cleaner's PROPOSAL, never stored or applied. Limits: body 10,000 characters, subject 300, request 48 KB; 20 checks per minute per account. Refusals are surfaced verbatim and never retried: 400 invalid_body, 403 forbidden, 413 message_too_long / body_too_large, 429 rate_limited (with Retry-After). Requires " +
|
|
23
23
|
CHECK_MESSAGE_REQUIREMENT +
|
|
24
24
|
".", {
|
|
25
25
|
channel: z.enum(["sms", "email"]).describe("sms = a text, email = an email"),
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-10-
|
|
3
|
-
"toolCount":
|
|
2
|
+
"generatedAt": "2026-10-11T02:07:04.091Z",
|
|
3
|
+
"toolCount": 350,
|
|
4
4
|
"totalEndpoints": 1077,
|
|
5
5
|
"counts": {
|
|
6
|
-
"covered":
|
|
6
|
+
"covered": 309,
|
|
7
7
|
"excluded": 321,
|
|
8
|
-
"planned":
|
|
8
|
+
"planned": 447,
|
|
9
9
|
"uncategorized": 0
|
|
10
10
|
},
|
|
11
11
|
"matrix": {
|
|
@@ -1562,8 +1562,10 @@
|
|
|
1562
1562
|
"reason": "contact subroutes beyond the core CRUD/search tools"
|
|
1563
1563
|
},
|
|
1564
1564
|
"POST /api/v1/contacts/merge": {
|
|
1565
|
-
"status": "
|
|
1566
|
-
"
|
|
1565
|
+
"status": "covered",
|
|
1566
|
+
"tools": [
|
|
1567
|
+
"conduyt_merge_contacts"
|
|
1568
|
+
]
|
|
1567
1569
|
},
|
|
1568
1570
|
"GET /api/v1/contacts/sources": {
|
|
1569
1571
|
"status": "planned",
|