conduyt-mcp 4.84.0 → 4.86.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 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.2); 20 per minute per account; 400 / 403 / 413 / 429 surfaced verbatim, never retried |
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,7 +388,7 @@ 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
 
390
- The contact merge route (`POST /contacts/merge`, no tool today) 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.
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.
391
392
 
392
393
  ## Setup
393
394
 
@@ -2,6 +2,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { ConduytClient } from "../client.js";
3
3
  /** (card ownership per lane, app 10-08/09) the action options an agent can set; the catalog does not carry action config, so this is the one place they are documented */
4
4
  export declare const CARD_OWNERSHIP_ACTIONS_NOTE = " Card ownership per lane: assign_to_user config {userId | userIds (round robin) | groupId, target|applyTo: contact|deal|both, overwriteExisting, laneSource, lanePipelineId, laneStageId}: laneSource names a card lane (the Deal.source its Auto-Create step wrote, e.g. 'GSM', max 500 chars) and makes the deal leg assign the contact's newest OPEN card from that lane (inside lanePipelineId when set) instead of the run's deal; when the contact has none and lanePipelineId is set, the card is created there (laneStageId, else the pipeline's first stage) already owned by the user; lanePipelineId or laneStageId without laneSource, or laneStageId without lanePipelineId, is refused; a lane never applies to an 'Assign to: contact' step. create_deal config {pipelineId (required), stageId, dealName, dealValue, allowDuplicates, status, source, assignedTo, leaveUnassigned}: leaveUnassigned true leaves the card unassigned when no assignee is set (default false: the card goes to the lead's owner). close_other_open_deals config {lostReason (required)}: closes the contact's OTHER open cards as lost with that reason, keeping the run's own card (each moves to its pipeline's lost stage with the usual stage-change effects; cards already won/lost, in the DPP Post-Close pipeline or in a pipeline with no lost stage are skipped; a re-run closes nothing twice); it needs the run's own deal, so use a deal trigger or a find_opportunity step first.";
5
+ /** (deal nodes, app 10-13) find_opportunity v2, link_appointment_to_deal, update_deal_field systemField source; the catalog does not carry action config */
6
+ export declare const DEAL_NODES_ACTIONS_NOTE = " Deal nodes: find_opportunity config {version: 2, pipelineIds (ordered; the first pipeline holding a match wins; empty = every pipeline), status (open default | won | lost | abandoned | any), excludeSources (lead sources never matched, case-insensitive), excludeCreatedByAutomationIds (automations whose create_deal cards are skipped; cards created from 2026-10-13 on), matchField {fieldKey, value} (searches the WHOLE WORKSPACE, any contact, for the card whose deal custom field equals the value, merge fields allowed e.g. {{trigger.oes}}; a card is found when its field holds the searched value (trimmed) as the same text exactly (no case folding); a value written with separators such as dashes or spaces also matches its digits as text; it matches a stored number only when the value (or those digits) is a plain number with no leading zero and at most 15 significant digits that a number holds exactly (007 is text and does not match the number 7; a 16-digit value, 1e-400 or 3e-324 is text only); booleans and differently formatted stored values never match; status, pipelines and exclusions still apply; exactly one match is found, several are ambiguous and take not found; a found card becomes the run's contact when the run has none; the field must exist; an empty rendered value finds nothing), resolution}: searches the run's contact's cards; FOUND makes the card the run's deal and follows nextIfTrue (else next), NOT FOUND is a normal outcome that follows nextIfFalse (else next) and leaves the run's existing deal unchanged (a later deal step on the false branch still acts on the deal the run already had); without version 2 the step keeps the original single-pipeline lookup (a miss fails the step). Prerequisite: a version 2 find needs step cursors on this workspace, and publishing refuses (find_needs_step_cursors) with \"Find opportunity needs step cursors on this workspace; ask support\"; every version 2 find must hold its own live step cursor or it hard-stops the run without selecting or routing. A version 2 find cannot be retried on its own: retrying a failed one alone answers 409, so re-trigger the automation for the contact. An automation definition that touches deals needs the `deals` scope on an API key: without it the write answers 403. link_appointment_to_deal config {stageId, pipelineId (needs stageId), lostReason}: in an appointment-triggered run, links the trigger's appointment to the run's deal (its appointment fields follow it; a newer booking is never displaced; the appointment must be the deal's contact's), then optionally moves the card; it needs an appointment trigger and the run's deal (find_opportunity first, or a booking made with a deal id). update_deal_field config {systemField: \"source\", value, overwriteExisting}: sets the deal's own lead source instead of a custom field (a value only, trimmed, at most 500 characters; overwriteExisting false writes only while the source is the Unknown label).";
5
7
  /** (#124, automation unknown keys / types) the refusal codes the write paths answer; surfaced verbatim by the client (details JSON object last), never retried */
6
8
  export declare const AUTOMATION_KEY_REFUSALS_NOTE = " Unknown action keys are refused, never ignored: an action config key the action does not read (a typo such as `laneSoruce`) answers code `unknown_action_config_key` with the node, the key and, when one is close, a suggestion (\"Did you mean `laneSource`?\"); an action `type` no registry entry defines answers `unknown_action_type` (with 'Did you mean' when a registered type is close). Create and update answer 400 (an update that does not write `actions` is never refused for a key stored earlier), conduyt_workflow_validate answers 200 with valid false and the `errors` list (nodeId, field, code, message), and publish / a version restore answer 422 (the preflight refusal wraps `data.message`, `data.errors`, `data.warnings`). Read the suggestion, fix the key and send again; a stored workflow is never refused on read.";
7
9
  /** GET /automations/:id answers { data: { ...automation, configRevision } } (jsonResponse wraps): the revision lives under `data` */
@@ -2,6 +2,8 @@ import { z } from "zod";
2
2
  import { formatResult, formatToolError } from "../client.js";
3
3
  /** (card ownership per lane, app 10-08/09) the action options an agent can set; the catalog does not carry action config, so this is the one place they are documented */
4
4
  export const CARD_OWNERSHIP_ACTIONS_NOTE = " Card ownership per lane: assign_to_user config {userId | userIds (round robin) | groupId, target|applyTo: contact|deal|both, overwriteExisting, laneSource, lanePipelineId, laneStageId}: laneSource names a card lane (the Deal.source its Auto-Create step wrote, e.g. 'GSM', max 500 chars) and makes the deal leg assign the contact's newest OPEN card from that lane (inside lanePipelineId when set) instead of the run's deal; when the contact has none and lanePipelineId is set, the card is created there (laneStageId, else the pipeline's first stage) already owned by the user; lanePipelineId or laneStageId without laneSource, or laneStageId without lanePipelineId, is refused; a lane never applies to an 'Assign to: contact' step. create_deal config {pipelineId (required), stageId, dealName, dealValue, allowDuplicates, status, source, assignedTo, leaveUnassigned}: leaveUnassigned true leaves the card unassigned when no assignee is set (default false: the card goes to the lead's owner). close_other_open_deals config {lostReason (required)}: closes the contact's OTHER open cards as lost with that reason, keeping the run's own card (each moves to its pipeline's lost stage with the usual stage-change effects; cards already won/lost, in the DPP Post-Close pipeline or in a pipeline with no lost stage are skipped; a re-run closes nothing twice); it needs the run's own deal, so use a deal trigger or a find_opportunity step first.";
5
+ /** (deal nodes, app 10-13) find_opportunity v2, link_appointment_to_deal, update_deal_field systemField source; the catalog does not carry action config */
6
+ export const DEAL_NODES_ACTIONS_NOTE = " Deal nodes: find_opportunity config {version: 2, pipelineIds (ordered; the first pipeline holding a match wins; empty = every pipeline), status (open default | won | lost | abandoned | any), excludeSources (lead sources never matched, case-insensitive), excludeCreatedByAutomationIds (automations whose create_deal cards are skipped; cards created from 2026-10-13 on), matchField {fieldKey, value} (searches the WHOLE WORKSPACE, any contact, for the card whose deal custom field equals the value, merge fields allowed e.g. {{trigger.oes}}; a card is found when its field holds the searched value (trimmed) as the same text exactly (no case folding); a value written with separators such as dashes or spaces also matches its digits as text; it matches a stored number only when the value (or those digits) is a plain number with no leading zero and at most 15 significant digits that a number holds exactly (007 is text and does not match the number 7; a 16-digit value, 1e-400 or 3e-324 is text only); booleans and differently formatted stored values never match; status, pipelines and exclusions still apply; exactly one match is found, several are ambiguous and take not found; a found card becomes the run's contact when the run has none; the field must exist; an empty rendered value finds nothing), resolution}: searches the run's contact's cards; FOUND makes the card the run's deal and follows nextIfTrue (else next), NOT FOUND is a normal outcome that follows nextIfFalse (else next) and leaves the run's existing deal unchanged (a later deal step on the false branch still acts on the deal the run already had); without version 2 the step keeps the original single-pipeline lookup (a miss fails the step). Prerequisite: a version 2 find needs step cursors on this workspace, and publishing refuses (find_needs_step_cursors) with \"Find opportunity needs step cursors on this workspace; ask support\"; every version 2 find must hold its own live step cursor or it hard-stops the run without selecting or routing. A version 2 find cannot be retried on its own: retrying a failed one alone answers 409, so re-trigger the automation for the contact. An automation definition that touches deals needs the `deals` scope on an API key: without it the write answers 403. link_appointment_to_deal config {stageId, pipelineId (needs stageId), lostReason}: in an appointment-triggered run, links the trigger's appointment to the run's deal (its appointment fields follow it; a newer booking is never displaced; the appointment must be the deal's contact's), then optionally moves the card; it needs an appointment trigger and the run's deal (find_opportunity first, or a booking made with a deal id). update_deal_field config {systemField: \"source\", value, overwriteExisting}: sets the deal's own lead source instead of a custom field (a value only, trimmed, at most 500 characters; overwriteExisting false writes only while the source is the Unknown label).";
5
7
  /** (#124, automation unknown keys / types) the refusal codes the write paths answer; surfaced verbatim by the client (details JSON object last), never retried */
6
8
  export const AUTOMATION_KEY_REFUSALS_NOTE = " Unknown action keys are refused, never ignored: an action config key the action does not read (a typo such as `laneSoruce`) answers code `unknown_action_config_key` with the node, the key and, when one is close, a suggestion (\"Did you mean `laneSource`?\"); an action `type` no registry entry defines answers `unknown_action_type` (with 'Did you mean' when a registered type is close). Create and update answer 400 (an update that does not write `actions` is never refused for a key stored earlier), conduyt_workflow_validate answers 200 with valid false and the `errors` list (nodeId, field, code, message), and publish / a version restore answer 422 (the preflight refusal wraps `data.message`, `data.errors`, `data.warnings`). Read the suggestion, fix the key and send again; a stored workflow is never refused on read.";
7
9
  /** GET /automations/:id answers { data: { ...automation, configRevision } } (jsonResponse wraps): the revision lives under `data` */
@@ -36,7 +38,7 @@ export function registerAutomationTools(server, client) {
36
38
  kind: z.enum(["native", "n8n"]).optional().describe("Automation type — 'native' for the built-in graph engine (use `actions`), 'n8n' for an external n8n webhook (requires n8nWorkflowId + n8nWebhookUrl). For agent-built automations use 'native'."),
37
39
  n8nWorkflowId: z.string().optional().describe("n8n workflow ID (required when kind='n8n')"),
38
40
  n8nWebhookUrl: z.string().optional().describe("n8n webhook URL (required when kind='n8n')"),
39
- actions: z.any().optional().describe("Actions graph — v1: [{type, config}], v2: {nodes:[], startNodeId}. A sequence step is the action enroll_in_sequence with config {sequenceId, enrollAction: 'add' (default) | 'remove'}: add puts the contact into the sequence (the sequence must allow automation enrollment, allowAutomationEnroll), remove ends their run. A delay node waits for a fixed duration by default; waitType 'until_field_date' (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately)." + CARD_OWNERSHIP_ACTIONS_NOTE + ""),
41
+ actions: z.any().optional().describe("Actions graph — v1: [{type, config}], v2: {nodes:[], startNodeId}. A sequence step is the action enroll_in_sequence with config {sequenceId, enrollAction: 'add' (default) | 'remove'}: add puts the contact into the sequence (the sequence must allow automation enrollment, allowAutomationEnroll), remove ends their run. A delay node waits for a fixed duration by default; waitType 'until_field_date' (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately)." + CARD_OWNERSHIP_ACTIONS_NOTE + DEAL_NODES_ACTIONS_NOTE + ""),
40
42
  graphVersion: z.number().optional().describe("Graph version — 1 (legacy array) or 2 (node graph). Default 1"),
41
43
  schedule: z.string().optional().describe("5-field cron expression (required if triggerEvent='scheduled')"),
42
44
  scheduleTimezone: z.string().optional().describe("IANA timezone for scheduled triggers (e.g. 'America/Los_Angeles')"),
@@ -59,7 +61,7 @@ export function registerAutomationTools(server, client) {
59
61
  .describe("Updated trigger conditions (replaces the object). Same keys as create — e.g. { untouchedDays: 7 } on 'contact.untouched'. (#16 tri-surface parity: the UI and API could set this; MCP could not.)"),
60
62
  isActive: z.boolean().optional().describe("Enable or disable the automation"),
61
63
  expectedConfigRevision: z.number().int().nonnegative().optional().describe("REQUIRED whenever `actions` is replaced: the configRevision of the automation you read (conduyt_get_automation) before preparing the new graph. It is sent unchanged; if the automation was saved since, the API answers 409 with the current revision and nothing is overwritten, so re-read, re-apply your change and call again. Not needed for an update that leaves `actions` alone"),
62
- actions: z.any().optional().describe("Updated actions graph (same shape as create; e.g. enroll_in_sequence {sequenceId, enrollAction: 'add' | 'remove'}) A delay node waits for a fixed duration by default; waitType 'until_field_date' (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately)." + CARD_OWNERSHIP_ACTIONS_NOTE + ""),
64
+ actions: z.any().optional().describe("Updated actions graph (same shape as create; e.g. enroll_in_sequence {sequenceId, enrollAction: 'add' | 'remove'}) A delay node waits for a fixed duration by default; waitType 'until_field_date' (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately)." + CARD_OWNERSHIP_ACTIONS_NOTE + DEAL_NODES_ACTIONS_NOTE + ""),
63
65
  schedule: z.string().optional().describe("Updated cron schedule"),
64
66
  scheduleTimezone: z.string().optional().describe("Updated timezone"),
65
67
  stopOnResponse: z.boolean().optional().describe("Stop drip if contact responds"),
@@ -49,14 +49,16 @@ export function registerCalendarTools(server, client) {
49
49
  const result = await client.get(`/api/v1/appointments/${id}`);
50
50
  return formatResult(result);
51
51
  });
52
- server.tool("conduyt_create_appointment", "Create a new appointment. Times must include timezone offset (e.g. '2026-05-01T10:00:00-07:00').", {
52
+ server.tool("conduyt_create_appointment", "Create a new appointment. Times must include timezone offset (e.g. '2026-05-01T10:00:00-07:00'). "
53
+ + "contactId is looked up as a contact of your account first; when no contact has that id it is looked up as a deal (card), so a deal id may be passed there. dealId names the deal explicitly. A named deal must be open, in your account, and visible AND editable to the key (deals scope; seeing it is not enough): the appointment takes the deal's contact, is linked to the deal, and appointment.booked carries dealId; no other card is created. Errors: 400 deal_id_conflict / deal_contact_mismatch / invalid_deal_id / contact_not_found (contactId is neither a contact nor a deal you can use), 404 deal_not_found / deal_deleted, 403 deal_not_editable (you can see the deal but not change it), 409 deal_closed, 409 deal_contact_gone (the deal's contact was merged or deleted), 409 deal_changed (the deal moved to another contact while booking: retry the request).", {
53
54
  title: z.string().describe("Appointment title"),
54
55
  startTime: z.string().describe("Start time (ISO 8601 with timezone, e.g. '2026-05-01T10:00:00-07:00')"),
55
56
  endTime: z.string().describe("End time (ISO 8601 with timezone)"),
56
57
  description: z.string().optional().describe("Appointment description"),
57
58
  location: z.string().optional().describe("Location or meeting link"),
58
59
  calendarId: z.string().optional().describe("Calendar UUID"),
59
- contactId: z.string().optional().describe("Associated contact UUID"),
60
+ contactId: z.string().optional().describe("Associated contact UUID, or a deal UUID (looked up as a deal when no contact has it)"),
61
+ dealId: z.string().optional().describe("The deal (card) to book for: open, in your account, visible and editable to you; the appointment takes its contact and is linked to it"),
60
62
  assignedTo: z.string().optional().describe("Assigned team member UUID"),
61
63
  status: z.string().optional().describe("Status (e.g. 'confirmed', 'pending')"),
62
64
  notes: z.string().optional().describe("Internal notes"),
@@ -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;
@@ -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
  }
@@ -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.2 today; any further field is additive). 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 (the next engine release skips the opt-out and business-name checks for first / conversation; today every context scores the same). To pick first vs conversation for a real contact read hasOutboundSms on conduyt_get_contact. 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 " +
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,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt-mcp",
3
- "version": "4.84.0",
3
+ "version": "4.86.0",
4
4
  "mcpName": "com.conduyt/mcp",
5
5
  "description": "MCP server for Conduyt CRM — expose CRM operations as AI-accessible tools",
6
6
  "type": "module",
@@ -1,11 +1,11 @@
1
1
  {
2
- "generatedAt": "2026-10-10T17:00:26.027Z",
3
- "toolCount": 349,
4
- "totalEndpoints": 1077,
2
+ "generatedAt": "2026-10-13T14:36:32.489Z",
3
+ "toolCount": 350,
4
+ "totalEndpoints": 1080,
5
5
  "counts": {
6
- "covered": 308,
6
+ "covered": 309,
7
7
  "excluded": 321,
8
- "planned": 448,
8
+ "planned": 450,
9
9
  "uncategorized": 0
10
10
  },
11
11
  "matrix": {
@@ -869,6 +869,10 @@
869
869
  "status": "planned",
870
870
  "reason": "booking page management"
871
871
  },
872
+ "POST /api/v1/booking-pages/:id/ical-feed/reveal": {
873
+ "status": "planned",
874
+ "reason": "booking page management"
875
+ },
872
876
  "GET /api/v1/booking-pages/:id/overrides": {
873
877
  "status": "planned",
874
878
  "reason": "booking page management"
@@ -1562,6 +1566,12 @@
1562
1566
  "reason": "contact subroutes beyond the core CRUD/search tools"
1563
1567
  },
1564
1568
  "POST /api/v1/contacts/merge": {
1569
+ "status": "covered",
1570
+ "tools": [
1571
+ "conduyt_merge_contacts"
1572
+ ]
1573
+ },
1574
+ "GET /api/v1/contacts/score": {
1565
1575
  "status": "planned",
1566
1576
  "reason": "contact subroutes beyond the core CRUD/search tools"
1567
1577
  },
@@ -2242,6 +2252,10 @@
2242
2252
  "conduyt_pipeline_health"
2243
2253
  ]
2244
2254
  },
2255
+ "GET /api/v1/deals/lead-id": {
2256
+ "status": "planned",
2257
+ "reason": "deal subroutes beyond core CRUD (history, files, links)"
2258
+ },
2245
2259
  "POST /api/v1/deals/probabilities": {
2246
2260
  "status": "planned",
2247
2261
  "reason": "deal subroutes beyond core CRUD (history, files, links)"