conduyt-mcp 4.90.0 → 4.91.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 +4 -2
- package/dist/tools/bulk.js +4 -2
- package/dist/tools/contacts.js +22 -3
- package/package.json +1 -1
- package/scripts/coverage-matrix.json +16 -4
package/README.md
CHANGED
|
@@ -15,10 +15,12 @@ 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; `hasOutboundEmail` = the same for email |
|
|
19
|
-
| `conduyt_create_contact` | Create a new contact (with `callId`: the lead for a contact-less inbound call, linked in the same transaction; the server derives source "Inbound Call" and the owner) |
|
|
18
|
+
| `conduyt_get_contact` | Get full contact details with timeline; `hasOutboundSms` = the workspace has ever provably texted the contact; `hasOutboundEmail` = the same for email; `duplicateProposals` and `heldIdentifiers` = the open possible-duplicate lines and held identifiers |
|
|
19
|
+
| `conduyt_create_contact` | Create a new contact (a 409 with `existingId`, `rule`, `ruleVersion`, `matchedOn`, `reason` means the identity policy identified the incoming person with that record: confirm the identity (name, company) before updating it, a shared phone or email is not proof, and the server creates (with a passive duplicate proposal) a different person on a shared identifier; 409 `identifier_changed` / `owner_busy` and 503 `IDENTITY_INDEX_NOT_READY` are retryable, nothing written; with `callId`: the lead for a contact-less inbound call, linked in the same transaction; the server derives source "Inbound Call" and the owner) |
|
|
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
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 |
|
|
22
|
+
| `conduyt_list_duplicate_proposals` | List a contact's possible duplicates (`GET /contacts/{id}/duplicate-proposals`): open and not-sure proposals (`id`, `status`, `rule`, `other`, `raisedHere`) and the identifiers held from it (value-free); a record you cannot view has no id |
|
|
23
|
+
| `conduyt_decide_duplicate_proposal` | Decide one proposal (`POST /contacts/{id}/duplicate-proposals/{proposalId}`, contacts:edit on both records): `not_sure` keeps both visible, `different` is final (never shown again), `merge` records a pair ALREADY merged (this tool never merges: use `conduyt_merge_contacts` first, else 409 `not_merged`); 409 `already_decided`, 403 `other_record_not_editable`; never retried |
|
|
22
24
|
| `conduyt_add_contact_collaborator` | Add a team member as a collaborator (`POST /contacts/{id}/collaborators`); optional `expectedRevision`, 409 `contact_changed` |
|
|
23
25
|
| `conduyt_remove_contact_collaborator` | Remove a collaborator (`DELETE /contacts/{id}/collaborators`, body); optional `expectedRevision`, 409 `contact_changed`, 404 when not a collaborator |
|
|
24
26
|
| `conduyt_add_tags` | Add tags to a contact |
|
package/dist/tools/bulk.js
CHANGED
|
@@ -4,7 +4,7 @@ import { INVALID_NUMBER_REFUSALS } from "./contacts.js";
|
|
|
4
4
|
/** (#136, Codex r1 m2) POST /bulk/contacts/update is NOT atomic and answers per row: an impossible phone is a per-row success:false inside HTTP 200 (no code) while other rows commit; the route takes no country */
|
|
5
5
|
export const BULK_UPDATE_REFUSALS = " Results are PER ROW inside an HTTP 200 (not atomic: some rows can be saved while others fail): read data.summary { total, succeeded, failed } and data.results, each { id, success, error? }. An impossible US/Canada number (area code or exchange starting 0 or 1) fails only that row, success: false with the error text (no phone_invalid code, no top-level 400), and nothing is saved for that row. This route does not take country: a country-only row fails as a row, and a country sent beside another field is ignored while that field is applied (use conduyt_bulk_edit_contacts for a country; it refuses a null or blank country 400 country_invalid).";
|
|
6
6
|
export function registerBulkTools(server, client) {
|
|
7
|
-
server.tool("conduyt_ai_import_contacts", "AI-powered bulk contact import. Accepts up to 1000 contacts with optional auto-tagging, smart list creation, and automation enrollment. Deduplicates by email, phone, or both.", {
|
|
7
|
+
server.tool("conduyt_ai_import_contacts", "AI-powered bulk contact import. Accepts up to 1000 contacts with optional auto-tagging, smart list creation, and automation enrollment. Deduplicates by email, phone, or both. A workspace whose identifier index is still being built answers 503 `IDENTITY_INDEX_NOT_READY` (nothing imported; retry later).", {
|
|
8
8
|
contacts: z.array(z.object({
|
|
9
9
|
firstName: z.string().optional(),
|
|
10
10
|
lastName: z.string().optional(),
|
|
@@ -152,7 +152,7 @@ export function registerBulkTools(server, client) {
|
|
|
152
152
|
const result = await client.post("/api/v1/bulk/deals/update", params);
|
|
153
153
|
return formatResult(result);
|
|
154
154
|
});
|
|
155
|
-
server.tool("conduyt_import_preflight", "Pre-check a contact import BEFORE running it — returns criticalWarnings (phone-less list bound for SMS, heavy create-only duplicate skips, double-mapped columns), duplicate/existing-match estimates, invalid emails, DNC/litigator hits, SMS-provider readiness, and plan-limit impact. Same engine the import wizard uses; call this before conduyt_ai_import_contacts on any sizeable list.", {
|
|
155
|
+
server.tool("conduyt_import_preflight", "Pre-check a contact import BEFORE running it — returns criticalWarnings (phone-less list bound for SMS, heavy create-only duplicate skips, double-mapped columns), duplicate/existing-match estimates, invalid emails, DNC/litigator hits, SMS-provider readiness, and plan-limit impact. Same engine the import wizard uses; call this before conduyt_ai_import_contacts on any sizeable list. A workspace whose identifier index is still being built answers 503 `IDENTITY_INDEX_NOT_READY` (nothing checked; retry later).", {
|
|
156
156
|
objectType: z.enum(["contacts", "opportunities", "companies"]).default("contacts").describe("What the import creates"),
|
|
157
157
|
importMode: z.enum(["create_and_update", "create_only", "update_only"]).optional().describe("Duplicate handling mode the import will use"),
|
|
158
158
|
totalRows: z.number().describe("Total rows in the file/payload"),
|
|
@@ -160,6 +160,8 @@ export function registerBulkTools(server, client) {
|
|
|
160
160
|
phones: z.array(z.string()).optional().describe("All phone values from the file (order-free)"),
|
|
161
161
|
mappedFields: z.array(z.string()).optional().describe("The Conduyt field names your columns map to, e.g. ['firstName','phone']"),
|
|
162
162
|
removeDuplicates: z.boolean().optional().describe("Whether explicit dedupe is on"),
|
|
163
|
+
ambiguousMatchMode: z.enum(["proposal", "hold", "create_new"]).nullish().describe("What the import does with a row whose email matches one record and phone another (identity v2): 'proposal' (the platform default: the row is created and carries a duplicate proposal naming the similar records) or 'hold' (held for review). 'create_new' is RETIRED for new imports (the server answers 400); it is accepted here only so a preview of an old job still validates."),
|
|
164
|
+
enrollAdditionalCampaign: z.boolean().optional().describe("true = the import's Add mode: a contested email or phone slot value is stripped and the row is still created (estimated the way the import will run it); every other mode skips such a row as secondary_identifier_conflict"),
|
|
163
165
|
duplicateCheckFields: z.array(z.enum(["email", "phone"])).optional().describe("Identifiers explicit dedupe checks"),
|
|
164
166
|
rowIdentifiers: z.array(z.object({
|
|
165
167
|
email: z.string().nullable().optional(),
|
package/dist/tools/contacts.js
CHANGED
|
@@ -6,6 +6,11 @@ import { strictInput } from "./strict.js";
|
|
|
6
6
|
* create that carries callId must not carry either field. Refused here, after every field is merged, with the rule named. */
|
|
7
7
|
export const CALL_DERIVED_REFUSAL = "callId cannot be combined with source or assignedTo: for a lead created from a call the server derives both (source is 'Inbound Call', the owner is the rep who took the call). Omit them.";
|
|
8
8
|
export const callDerivedFieldsConflict = (v) => v.callId !== undefined && (v.source !== undefined || v.assignedTo !== undefined);
|
|
9
|
+
/** Identity rules v2 (board #69, app main 61bf61876): what a create answers when the email or phone already belongs to a record. */
|
|
10
|
+
const IDENTITY_409_MEANING = "A 409 with { existingId, rule, ruleVersion, matchedOn, reason } means the server's identity policy identified the incoming person WITH the record existingId (rule and matchedOn say how). It is not proof by itself: the policy deliberately allows two different people on one shared identifier when their full names conflict (rule R6; shared phone lines exist), and that case is NOT refused: the server creates the record and shows a passive duplicate proposal (conduyt_list_duplicate_proposals). The server itself refuses creates it identifies as the same person, so never create a copy. Before you update existingId, CONFIRM the identity (name, company); never treat a shared phone or email as proof that it is the same person.";
|
|
11
|
+
const IDENTITY_CREATE_ANSWERS = "Identity rules (v2): " + IDENTITY_409_MEANING + " 409 with code `identifier_changed` or `owner_busy` = another save for the same email, phone or owner was in progress, nothing was written: wait a moment and send the same request again (retryable). 503 with code `IDENTITY_INDEX_NOT_READY` = the workspace's identifier index is still being built at this size, nothing was written: retry later. A possible duplicate that is not an exact match is created and shown as a duplicate proposal on the record (conduyt_list_duplicate_proposals).";
|
|
12
|
+
const IDENTITY_SAVE_RETRY = "Identity rules (v2): " + IDENTITY_409_MEANING + " 409 with code `identifier_changed` = another save for this email or phone was in progress (or the record's email or phone changed while this saved), nothing was written: reload the contact and send the change again (retryable). 409 with code `owner_busy` = another save for the same owner was in progress, nothing was written: wait a moment and send the same request again (retryable). 503 with code `IDENTITY_INDEX_NOT_READY` = the workspace's identifier index is still being built at this size, nothing was written: retry later.";
|
|
13
|
+
const PROPOSAL_DECISIONS = ["not_sure", "different", "merge"];
|
|
9
14
|
export const CONTACT_REVISION_MAX = 2147483647;
|
|
10
15
|
const CONTACT_CHANGED = "A stale pin is refused with 409 and code contact_changed (nothing written): the error text carries `code`, the contact's CURRENT `revision` and the current `contact`; reload, decide, and call again with that revision (never retry the same pin). A malformed expectedRevision is a 400; only an omitted field means unpinned.";
|
|
11
16
|
const expectedRevision = () => z.number().int().min(0).max(CONTACT_REVISION_MAX).optional().describe("Optimistic-concurrency pin: the contact `revision` as you last read it (conduyt_get_contact / conduyt_list_contacts / a previous change). Omit for last-write-wins per field.");
|
|
@@ -95,13 +100,13 @@ export function registerContactTools(server, client) {
|
|
|
95
100
|
const result = await client.get(`/api/v1/contacts?${params}`);
|
|
96
101
|
return formatResult(result);
|
|
97
102
|
});
|
|
98
|
-
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.", {
|
|
103
|
+
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. Also includes `duplicateProposals` (the open and not-sure possible-duplicate lines on this record, each naming the other record as far as your key may see it) and `heldIdentifiers` (emails or phones held from this record because another record owns them, value-free); list or decide them with conduyt_list_duplicate_proposals / conduyt_decide_duplicate_proposal.", {
|
|
99
104
|
id: z.string().describe("Contact UUID"),
|
|
100
105
|
}, async ({ id }) => {
|
|
101
106
|
const result = await client.get(`/api/v1/contacts/${id}`);
|
|
102
107
|
return formatResult(result);
|
|
103
108
|
});
|
|
104
|
-
const createContactTool = server.tool("conduyt_create_contact", "Create a new contact in the CRM. Email or phone is recommended but not strictly required. With callId (a lead made from a contact-less inbound call) you must NOT send source or assignedTo: the server derives both from the call, and the request is refused if either is present." + INVALID_NUMBER_REFUSALS, {
|
|
109
|
+
const createContactTool = server.tool("conduyt_create_contact", "Create a new contact in the CRM. Email or phone is recommended but not strictly required. With callId (a lead made from a contact-less inbound call) you must NOT send source or assignedTo: the server derives both from the call, and the request is refused if either is present." + INVALID_NUMBER_REFUSALS + " " + IDENTITY_CREATE_ANSWERS, {
|
|
105
110
|
email: z.string().optional().describe("Contact email address"),
|
|
106
111
|
firstName: z.string().optional().describe("First name"),
|
|
107
112
|
lastName: z.string().optional().describe("Last name"),
|
|
@@ -131,7 +136,7 @@ export function registerContactTools(server, client) {
|
|
|
131
136
|
created.inputSchema = Object.assign(base.superRefine((v, ctx) => { if (callDerivedFieldsConflict(v))
|
|
132
137
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: CALL_DERIVED_REFUSAL, path: ["callId"] }); }), { shape: base.shape });
|
|
133
138
|
}
|
|
134
|
-
strictInput(server.tool("conduyt_update_contact", "Update an existing contact's fields — name, email, phone, company, custom fields. customFields merge into existing values (omit a key to leave it unchanged). Pass expectedRevision (the contact's `revision` as you read it) to refuse the save when the contact changed since." + INVALID_NUMBER_REFUSALS + " " + CONTACT_CHANGED, {
|
|
139
|
+
strictInput(server.tool("conduyt_update_contact", "Update an existing contact's fields — name, email, phone, company, custom fields. customFields merge into existing values (omit a key to leave it unchanged). Pass expectedRevision (the contact's `revision` as you read it) to refuse the save when the contact changed since." + INVALID_NUMBER_REFUSALS + " " + CONTACT_CHANGED + " " + IDENTITY_SAVE_RETRY, {
|
|
135
140
|
id: z.string().describe("Contact UUID"),
|
|
136
141
|
email: z.string().optional().describe("Updated email"),
|
|
137
142
|
firstName: z.string().optional().describe("Updated first name"),
|
|
@@ -235,4 +240,18 @@ export function registerContactTools(server, client) {
|
|
|
235
240
|
mergePreviews.drop(key);
|
|
236
241
|
return formatResult(result);
|
|
237
242
|
}));
|
|
243
|
+
strictInput(server.tool("conduyt_list_duplicate_proposals", "List the possible duplicates of one contact (GET /api/v1/contacts/{contactId}/duplicate-proposals, identity rules v2; requires contacts:view). Answers { data: { proposals, heldIdentifiers } }. Each proposal: { id (the proposalId for conduyt_decide_duplicate_proposal), status ('open' or 'not_sure': the only two shown), rule, ruleVersion, source, createdAt, decidedAt, raisedHere (true = this record is the newer arrival the proposal was raised on), other: { id, name, createdAt, visible, editable } }. When your key cannot view the other record it is described only as 'a contact you cannot view' with id and createdAt null and visible false: you cannot decide that pair. heldIdentifiers (up to 20) are the emails or phones this record was NOT given because another record owns them (rule 4), value-free: { id, kind ('email' or 'phone'), rule, source, createdAt, heldBy: [{ id, name, visible }] }. Read-only, nothing is changed. 404 = no such contact for you. conduyt_get_contact carries the same two lists.", {
|
|
244
|
+
contactId: z.string().uuid().describe("Contact UUID whose possible duplicates to list"),
|
|
245
|
+
}, async ({ contactId }) => {
|
|
246
|
+
const result = await client.get(`/api/v1/contacts/${encodeURIComponent(contactId)}/duplicate-proposals`);
|
|
247
|
+
return formatResult(result);
|
|
248
|
+
}));
|
|
249
|
+
strictInput(server.tool("conduyt_decide_duplicate_proposal", "Record a decision on one possible-duplicate pair shown on a contact (POST /api/v1/contacts/{contactId}/duplicate-proposals/{proposalId} with { decision }; requires contacts:edit on BOTH records of the pair). Get proposalId from conduyt_list_duplicate_proposals. decision, exactly one of: 'not_sure' = keep both records visible, the pair stays listed and who looked is recorded (it can be decided again later); 'different' = the two records are different people, the pair is never shown again and the decision is FINAL; 'merge' = record that the pair is ALREADY merged: this tool NEVER merges. Merge first with conduyt_merge_contacts (preview, then confirm), then send 'merge' here; before the merge exists it is refused with 409 code `not_merged`. A pair already decided 'different' or merged is final: 409 code `already_decided` (also when someone decided it just now). Other refusals, nothing written: 404 (no such proposal on this contact: for EVERY decision; or the other record is gone: for 'not_sure' and 'different' only, while 'merge' still succeeds after the other record has been merged away), 403 code `other_record_not_editable` (you cannot edit the other record), 403 (you can view this contact but not edit it), 400 (decision missing or not one of the three), 402 billing. Answers { data: { id, status, decidedAt } } with status 'not_sure', 'different' or 'merged'. Never retried automatically.", {
|
|
250
|
+
contactId: z.string().uuid().describe("Contact UUID the proposal is shown on"),
|
|
251
|
+
proposalId: z.string().uuid().describe("The proposal id from conduyt_list_duplicate_proposals"),
|
|
252
|
+
decision: z.enum(PROPOSAL_DECISIONS).describe("'not_sure' keeps both visible; 'different' = different people, never shown again (final); 'merge' = the pair is already merged elsewhere (run conduyt_merge_contacts first; this tool never merges)"),
|
|
253
|
+
}, async ({ contactId, proposalId, decision }) => {
|
|
254
|
+
const result = await client.post(`/api/v1/contacts/${encodeURIComponent(contactId)}/duplicate-proposals/${encodeURIComponent(proposalId)}`, { decision });
|
|
255
|
+
return formatResult(result);
|
|
256
|
+
}));
|
|
238
257
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-10-16T01:
|
|
3
|
-
"toolCount":
|
|
4
|
-
"totalEndpoints":
|
|
2
|
+
"generatedAt": "2026-10-16T01:48:16.550Z",
|
|
3
|
+
"toolCount": 354,
|
|
4
|
+
"totalEndpoints": 1092,
|
|
5
5
|
"counts": {
|
|
6
|
-
"covered":
|
|
6
|
+
"covered": 313,
|
|
7
7
|
"excluded": 324,
|
|
8
8
|
"planned": 455,
|
|
9
9
|
"uncategorized": 0
|
|
@@ -1495,6 +1495,18 @@
|
|
|
1495
1495
|
"status": "planned",
|
|
1496
1496
|
"reason": "contact subroutes beyond the core CRUD/search tools"
|
|
1497
1497
|
},
|
|
1498
|
+
"GET /api/v1/contacts/:id/duplicate-proposals": {
|
|
1499
|
+
"status": "covered",
|
|
1500
|
+
"tools": [
|
|
1501
|
+
"conduyt_list_duplicate_proposals"
|
|
1502
|
+
]
|
|
1503
|
+
},
|
|
1504
|
+
"POST /api/v1/contacts/:id/duplicate-proposals/:proposalId": {
|
|
1505
|
+
"status": "covered",
|
|
1506
|
+
"tools": [
|
|
1507
|
+
"conduyt_decide_duplicate_proposal"
|
|
1508
|
+
]
|
|
1509
|
+
},
|
|
1498
1510
|
"POST /api/v1/contacts/:id/enrich": {
|
|
1499
1511
|
"status": "planned",
|
|
1500
1512
|
"reason": "contact subroutes beyond the core CRUD/search tools"
|