conduyt-mcp 4.80.0 → 4.81.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,7 +15,7 @@ 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 |
18
+ | `conduyt_get_contact` | Get full contact details with timeline; `hasOutboundSms` = the workspace has ever provably texted the contact |
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
21
  | `conduyt_add_contact_collaborator` | Add a team member as a collaborator (`POST /contacts/{id}/collaborators`); optional `expectedRevision`, 409 `contact_changed` |
@@ -110,6 +110,7 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
110
110
  | `conduyt_whatsapp_templates` | The account's WhatsApp templates (Twilio Content) with Meta's approval verdict: sid, text with {{n}} placeholders, variables, approval |
111
111
  | `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
112
  | `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`); 20 per minute per account; 400 / 403 / 413 / 429 surfaced verbatim, never retried |
113
114
  | `conduyt_log_message` | Record a message on a contact's timeline; an inbound SMS STOP or START records the number's opt-out or opt-in and answers smsConsent |
114
115
 
115
116
  ### Companies
@@ -168,6 +169,7 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
168
169
  ### WordPress forms
169
170
  | Tool | Description |
170
171
  |------|-------------|
172
+ | `conduyt_get_form_intake_status` | Whether any intake address is active and how many (`{ hasActive, activeCount }`, count only, `integrations` scope, read-only) |
171
173
  | `conduyt_list_form_intakes` | List the intake addresses: every active one with its full URL, then the revoked history (newest 200) |
172
174
  | `conduyt_create_form_intake` | Create an intake address for one Gravity Forms / WPForms / Elementor form (returns its full URL) |
173
175
  | `conduyt_revoke_form_intake` | Revoke an intake address (the row is kept for history) |
@@ -334,6 +336,7 @@ The rest of team chat (sending, reactions, pins, presence, uploads) is not wrapp
334
336
  |------|-------------|
335
337
  | `conduyt_get_lifecycle_settings` | Effective master statuses (defaults + customs) and intake-deals config |
336
338
  | `conduyt_update_intake_deals` | Configure automatic deal creation on live intake (webhooks, forms, bookings) |
339
+ | `conduyt_update_booking_creates_own_card` | `settings.bookingCreatesOwnCard`: a booking creates its own card owned by the host instead of reusing the contact's open intake card (strict input; `conduyt_get_lifecycle_settings` reads it) |
337
340
  | `conduyt_update_master_statuses` | Set the account's custom lead lifecycle statuses |
338
341
 
339
342
  ### Reply Capture (inbound email)
package/dist/client.js CHANGED
@@ -37,6 +37,42 @@ const ERROR_DETAILS_BOUND = 1000;
37
37
  /** Error-body keys that are the actionable state itself and are always rendered WHOLE: HealthFixRefusal.row (the deal's
38
38
  * current health row, which a caller needs to choose again). The response size cap bounds it. */
39
39
  const ALWAYS_WHOLE_KEYS = new Set(["row"]);
40
+ /** Bounded scheme for a refusal's error LISTS (the automation write refusals: AutomationActionConfigKeyError.errors, the 422
41
+ * AutomationPreflightRefusal.data { message, errors[], warnings[] }) when they do not fit ERROR_DETAILS_BOUND: keep the first
42
+ * MAX_LISTED_ERRORS entries (warnings: MAX_LISTED_WARNINGS) with every string field capped at MAX_FIELD_CHARS, and close the list with
43
+ * { "_more": K } for the K entries dropped, so each kept error still carries its code, nodeId, field and suggestion text. */
44
+ const MAX_LISTED_ERRORS = 25;
45
+ const MAX_LISTED_WARNINGS = 10;
46
+ const MAX_FIELD_CHARS = 400;
47
+ const capText = (s) => (s.length > MAX_FIELD_CHARS ? s.slice(0, MAX_FIELD_CHARS) + "...[shortened]" : s);
48
+ const capEntry = (e) => {
49
+ if (typeof e === "string")
50
+ return capText(e);
51
+ if (e && typeof e === "object" && !Array.isArray(e))
52
+ return Object.fromEntries(Object.entries(e).map(([k, v]) => [k, typeof v === "string" ? capText(v) : v]));
53
+ return e;
54
+ };
55
+ const capList = (list, max) => [...list.slice(0, max).map(capEntry), ...(list.length > max ? [{ _more: list.length - max }] : [])];
56
+ function compactRefusalList(key, value) {
57
+ if (key === "errors" && Array.isArray(value))
58
+ return capList(value, MAX_LISTED_ERRORS);
59
+ if (key === "data" && value && typeof value === "object" && !Array.isArray(value)) {
60
+ const d = value;
61
+ const out = {};
62
+ for (const [k, v] of Object.entries(d)) {
63
+ if (k === "message" && typeof v === "string")
64
+ out[k] = capText(v);
65
+ else if (k === "errors" && Array.isArray(v))
66
+ out[k] = capList(v, MAX_LISTED_ERRORS);
67
+ else if (k === "warnings" && Array.isArray(v))
68
+ out[k] = capList(v, MAX_LISTED_WARNINGS);
69
+ else if (v === null || typeof v !== "object")
70
+ out[k] = v;
71
+ }
72
+ return out;
73
+ }
74
+ return undefined;
75
+ }
40
76
  /**
41
77
  * Render an error body's details (everything but `error`) as ONE valid JSON object: scalars first (code, revision; a very
42
78
  * long string is shortened inside its quotes), then nested objects whole while they fit ERROR_DETAILS_BOUND, plus the
@@ -52,10 +88,17 @@ export function renderErrorDetails(body) {
52
88
  out[k] = typeof v === "string" && v.length > ERROR_DETAILS_BOUND ? v.slice(0, ERROR_DETAILS_BOUND) + "...[shortened]" : v;
53
89
  const omitted = [];
54
90
  for (const [k, v] of entries.filter(([, v]) => !isScalar(v))) {
55
- if (ALWAYS_WHOLE_KEYS.has(k) || JSON.stringify({ ...out, [k]: v }).length <= ERROR_DETAILS_BOUND)
91
+ // an EMPTY list / object (the import refusal's `warnings: []`) always fits: it costs two characters and is never "omitted"
92
+ if (ALWAYS_WHOLE_KEYS.has(k) || JSON.stringify(v).length <= 2 || JSON.stringify({ ...out, [k]: v }).length <= ERROR_DETAILS_BOUND)
56
93
  out[k] = v;
57
- else
58
- omitted.push(k);
94
+ else {
95
+ // a refusal's error list never vanishes into `_omitted`: it is kept in the bounded scheme above (codes and suggestions intact)
96
+ const compact = compactRefusalList(k, v);
97
+ if (compact !== undefined)
98
+ out[k] = compact;
99
+ else
100
+ omitted.push(k);
101
+ }
59
102
  }
60
103
  if (omitted.length > 0)
61
104
  out._omitted = omitted;
@@ -0,0 +1,11 @@
1
+ export type ActionConfigTypo = {
2
+ actionType: string;
3
+ key: string;
4
+ suggestion: string;
5
+ };
6
+ /** The first misspelled option key in an actions graph (v1 array of {type, config}, or v2 { nodes: [{ action: {type, config} }] }), or null */
7
+ export declare function findActionConfigTypo(graph: unknown): ActionConfigTypo | null;
8
+ /** The refusal text; the details object is the final token */
9
+ export declare function actionConfigTypoMessage(t: ActionConfigTypo): string;
10
+ /** Throws (a plain Error) when the graph carries a misspelled option */
11
+ export declare function assertNoActionConfigTypo(graph: unknown): void;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Local typo guard for automation action configs (card ownership per lane, Codex r1/r2). The server validator is the authority
3
+ * and there the option names are not enforced yet, so a typoed `laneSoruce` on assign_to_user would silently assign the run's
4
+ * deal instead of the lane card. Only the options below are known here.
5
+ *
6
+ * Traversal rule: ONLY the real action positions of the graph are visited, exactly as the graph is shaped: a v1 `actions`
7
+ * array's elements `{type, config}`, and a v2 graph's `nodes[].action` `{type, config}`. For each action of a known type the
8
+ * IMMEDIATE keys of `config` are checked; the config VALUES are opaque and never descended into (a webhook payload may hold any
9
+ * object, including one shaped like an action), and nothing outside those positions (request metadata, other node fields) is
10
+ * scanned. A key that is not a known option but is a case-insensitive match or ONE edit (substitution, insertion, deletion or
11
+ * adjacent transposition) from one is refused; keys further away, and every other action type, pass through.
12
+ * Source of the names: lib/automation-action-registry.ts in the app (assign_to_user, create_deal, close_other_open_deals).
13
+ */
14
+ const KNOWN_ACTION_OPTIONS = {
15
+ assign_to_user: ["runWindow", "mode", "userId", "userIds", "groupId", "target", "applyTo", "overwriteExisting", "laneSource", "lanePipelineId", "laneStageId"],
16
+ create_deal: ["pipelineId", "stageId", "dealName", "dealValue", "allowDuplicates", "status", "source", "assignedTo", "leaveUnassigned"],
17
+ close_other_open_deals: ["lostReason"],
18
+ };
19
+ /** Damerau-Levenshtein (optimal string alignment) distance, case-insensitive */
20
+ function distance(a, b) {
21
+ const x = a.toLowerCase(), y = b.toLowerCase();
22
+ const d = Array.from({ length: x.length + 1 }, (_, i) => [i, ...Array(y.length).fill(0)]);
23
+ for (let j = 1; j <= y.length; j++)
24
+ d[0][j] = j;
25
+ for (let i = 1; i <= x.length; i++) {
26
+ for (let j = 1; j <= y.length; j++) {
27
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (x[i - 1] === y[j - 1] ? 0 : 1));
28
+ if (i > 1 && j > 1 && x[i - 1] === y[j - 2] && x[i - 2] === y[j - 1])
29
+ d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
30
+ }
31
+ }
32
+ return d[x.length][y.length];
33
+ }
34
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
35
+ function checkAction(action) {
36
+ if (!isObject(action) || typeof action.type !== "string" || !isObject(action.config))
37
+ return null;
38
+ const known = KNOWN_ACTION_OPTIONS[action.type];
39
+ if (!known)
40
+ return null;
41
+ for (const key of Object.keys(action.config)) {
42
+ if (known.includes(key))
43
+ continue;
44
+ const near = known.find((k) => distance(key, k) <= 1);
45
+ if (near)
46
+ return { actionType: action.type, key, suggestion: near };
47
+ }
48
+ return null;
49
+ }
50
+ /** The first misspelled option key in an actions graph (v1 array of {type, config}, or v2 { nodes: [{ action: {type, config} }] }), or null */
51
+ export function findActionConfigTypo(graph) {
52
+ if (Array.isArray(graph)) {
53
+ for (const action of graph) {
54
+ const t = checkAction(action);
55
+ if (t)
56
+ return t;
57
+ }
58
+ return null;
59
+ }
60
+ if (isObject(graph) && Array.isArray(graph.nodes)) {
61
+ for (const node of graph.nodes) {
62
+ const t = isObject(node) ? checkAction(node.action) : null;
63
+ if (t)
64
+ return t;
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+ /** The refusal text; the details object is the final token */
70
+ export function actionConfigTypoMessage(t) {
71
+ return `Unknown option "${t.key}" in ${t.actionType} config: did you mean "${t.suggestion}"? Nothing was sent. ${JSON.stringify(t)}`;
72
+ }
73
+ /** Throws (a plain Error) when the graph carries a misspelled option */
74
+ export function assertNoActionConfigTypo(graph) {
75
+ const t = findActionConfigTypo(graph);
76
+ if (t)
77
+ throw new Error(actionConfigTypoMessage(t));
78
+ }
@@ -1,3 +1,9 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { ConduytClient } from "../client.js";
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
+ 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
+ /** (#124, automation unknown keys / types) the refusal codes the write paths answer; surfaced verbatim by the client (details JSON object last), never retried */
6
+ 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
+ /** GET /automations/:id answers { data: { ...automation, configRevision } } (jsonResponse wraps): the revision lives under `data` */
8
+ export declare function configRevisionOf(raw: unknown): number | undefined;
3
9
  export declare function registerAutomationTools(server: McpServer, client: ConduytClient): void;
@@ -1,5 +1,15 @@
1
1
  import { z } from "zod";
2
- import { formatResult } from "../client.js";
2
+ import { formatResult, formatToolError } from "../client.js";
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
+ 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
+ /** (#124, automation unknown keys / types) the refusal codes the write paths answer; surfaced verbatim by the client (details JSON object last), never retried */
6
+ 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
+ /** GET /automations/:id answers { data: { ...automation, configRevision } } (jsonResponse wraps): the revision lives under `data` */
8
+ export function configRevisionOf(raw) {
9
+ const row = raw && typeof raw === "object" && "data" in raw ? raw.data : raw;
10
+ const rev = row?.configRevision;
11
+ return typeof rev === "number" ? rev : undefined;
12
+ }
3
13
  export function registerAutomationTools(server, client) {
4
14
  server.tool("conduyt_list_automations", "List all automations (workflows) in the account. Returns name, trigger event, status, and execution counts.", {
5
15
  page: z.number().optional().describe("Page number (default 1)"),
@@ -19,14 +29,14 @@ export function registerAutomationTools(server, client) {
19
29
  const result = await client.get(`/api/v1/automations/${id}`);
20
30
  return formatResult(result);
21
31
  });
22
- server.tool("conduyt_create_automation", "Create a new automation. Requires a name and trigger event. Use conduyt_resolve_names to convert human-readable names to UUIDs before building actions.", {
32
+ server.tool("conduyt_create_automation", "Create a new automation. Requires a name and trigger event. Use conduyt_resolve_names to convert human-readable names to UUIDs before building actions." + AUTOMATION_KEY_REFUSALS_NOTE, {
23
33
  name: z.string().describe("Automation name"),
24
34
  description: z.string().optional().describe("Description of what this automation does"),
25
35
  triggerEvent: z.string().describe("Trigger event (e.g. 'contact.created', 'deal.stage_changed', 'tag.added', 'scheduled', or 'message.understood' — fires when the copilot has read an inbound text/email and understood its intent; list them all with conduyt_list_automation_events)"),
26
36
  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'."),
27
37
  n8nWorkflowId: z.string().optional().describe("n8n workflow ID (required when kind='n8n')"),
28
38
  n8nWebhookUrl: z.string().optional().describe("n8n webhook URL (required when kind='n8n')"),
29
- 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)."),
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 + ""),
30
40
  graphVersion: z.number().optional().describe("Graph version — 1 (legacy array) or 2 (node graph). Default 1"),
31
41
  schedule: z.string().optional().describe("5-field cron expression (required if triggerEvent='scheduled')"),
32
42
  scheduleTimezone: z.string().optional().describe("IANA timezone for scheduled triggers (e.g. 'America/Los_Angeles')"),
@@ -38,7 +48,7 @@ export function registerAutomationTools(server, client) {
38
48
  const result = await client.post("/api/v1/automations", params);
39
49
  return formatResult(result);
40
50
  });
41
- server.tool("conduyt_update_automation", "Update an existing automation — change trigger, actions, schedule, or activation status.", {
51
+ server.tool("conduyt_update_automation", "Update an existing automation — change trigger, actions, schedule, or activation status." + AUTOMATION_KEY_REFUSALS_NOTE, {
42
52
  id: z.string().describe("Automation UUID"),
43
53
  name: z.string().optional().describe("Updated name"),
44
54
  description: z.string().optional().describe("Updated description"),
@@ -48,7 +58,8 @@ export function registerAutomationTools(server, client) {
48
58
  .optional()
49
59
  .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.)"),
50
60
  isActive: z.boolean().optional().describe("Enable or disable the automation"),
51
- 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)."),
61
+ 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 + ""),
52
63
  schedule: z.string().optional().describe("Updated cron schedule"),
53
64
  scheduleTimezone: z.string().optional().describe("Updated timezone"),
54
65
  stopOnResponse: z.boolean().optional().describe("Stop drip if contact responds"),
@@ -57,18 +68,15 @@ export function registerAutomationTools(server, client) {
57
68
  .optional()
58
69
  .describe("What a NEW trigger does when the contact is already mid-sequence: 'keep' (default — skip, existing run keeps its schedule) or 'restart' (cancel the parked run and re-enroll from step 1)"),
59
70
  }, async ({ id, ...updates }) => {
60
- // (Conduyt Sentry r58–r60 parity) a save that replaces the actions of a LIVE workflow must carry the
61
- // configRevision it was based on — fetch it first so the server can refuse a stale overwrite (409)
62
- let body = updates;
63
- if (updates.actions !== undefined && body.expectedConfigRevision === undefined) {
64
- const current = (await client.get(`/api/v1/automations/${id}`));
65
- if (typeof current?.configRevision === "number")
66
- body = { ...body, expectedConfigRevision: current.configRevision };
71
+ // (Codex r5) replacing the actions must be bound to the revision the CALLER read before preparing the graph: a revision fetched
72
+ // here would be whatever is newest, so a concurrent save between the caller's read and this call would be silently overwritten
73
+ if (updates.actions !== undefined && updates.expectedConfigRevision === undefined) {
74
+ return formatToolError("Replacing `actions` needs expectedConfigRevision: read the automation first (conduyt_get_automation) and pass its configRevision. Nothing was sent.");
67
75
  }
68
- const result = await client.patch(`/api/v1/automations/${id}`, body);
76
+ const result = await client.patch(`/api/v1/automations/${id}`, updates);
69
77
  return formatResult(result);
70
78
  });
71
- server.tool("conduyt_publish_automation", "Publish an automation — copies the draft actions to the live published version. The automation must have actions defined. The publish is bound to the draft revision you reviewed (fetched automatically unless you pass expectedConfigRevision).", {
79
+ server.tool("conduyt_publish_automation", "Publish an automation — copies the draft actions to the live published version. The automation must have actions defined. The publish is bound to the draft revision you reviewed (fetched automatically unless you pass expectedConfigRevision)." + AUTOMATION_KEY_REFUSALS_NOTE, {
72
80
  id: z.string().describe("Automation UUID"),
73
81
  expectedConfigRevision: z.number().int().nonnegative().optional().describe("The configRevision of the draft you reviewed (from conduyt_get_automation); fetched automatically when omitted"),
74
82
  }, async ({ id, expectedConfigRevision }) => {
@@ -76,8 +84,7 @@ export function registerAutomationTools(server, client) {
76
84
  // review is refused (409) instead of going live unreviewed
77
85
  let revision = expectedConfigRevision;
78
86
  if (revision === undefined) {
79
- const current = (await client.get(`/api/v1/automations/${id}`));
80
- revision = typeof current?.configRevision === "number" ? current.configRevision : undefined;
87
+ revision = configRevisionOf(await client.get(`/api/v1/automations/${id}`));
81
88
  }
82
89
  const result = await client.post(`/api/v1/automations/${id}/publish`, { expectedConfigRevision: revision });
83
90
  return formatResult(result);
@@ -51,7 +51,7 @@ export function registerContactTools(server, client) {
51
51
  const result = await client.get(`/api/v1/contacts?${params}`);
52
52
  return formatResult(result);
53
53
  });
54
- 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.", {
54
+ 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').", {
55
55
  id: z.string().describe("Contact UUID"),
56
56
  }, async ({ id }) => {
57
57
  const result = await client.get(`/api/v1/contacts/${id}`);
@@ -10,6 +10,7 @@ import { ConduytClient } from "../client.js";
10
10
  */
11
11
  /** What each tool's key must hold: the API names only the FIRST missing scope, so a 403 is surfaced with all of it. */
12
12
  export declare const LIST_AND_CREATE_REQUIREMENT = "the form-intakes AND contacts scopes on a write or admin tier key (a member: settings.edit, contacts.create and contacts.edit)";
13
+ export declare const STATUS_REQUIREMENT = "the integrations scope (read-only; a member: settings.view)";
13
14
  export declare const REVOKE_REQUIREMENT = "the form-intakes scope on an admin tier key (a member: settings.edit)";
14
15
  /** A 403 names the complete requirement; a busy 503 says when to retry (never replayed here: create is a mutation). Annotations go BEFORE the
15
16
  * client's message, so its details JSON object stays the final token (README, Error text grammar). */
@@ -10,6 +10,7 @@ import { ConduytApiError, formatResult, formatToolError } from "../client.js";
10
10
  */
11
11
  /** What each tool's key must hold: the API names only the FIRST missing scope, so a 403 is surfaced with all of it. */
12
12
  export const LIST_AND_CREATE_REQUIREMENT = "the form-intakes AND contacts scopes on a write or admin tier key (a member: settings.edit, contacts.create and contacts.edit)";
13
+ export const STATUS_REQUIREMENT = "the integrations scope (read-only; a member: settings.view)";
13
14
  export const REVOKE_REQUIREMENT = "the form-intakes scope on an admin tier key (a member: settings.edit)";
14
15
  /** A 403 names the complete requirement; a busy 503 says when to retry (never replayed here: create is a mutation). Annotations go BEFORE the
15
16
  * client's message, so its details JSON object stays the final token (README, Error text grammar). */
@@ -24,6 +25,17 @@ export function intakeToolError(err, requirement) {
24
25
  throw err;
25
26
  }
26
27
  export function registerFormIntakeTools(server, client) {
28
+ server.tool("conduyt_get_form_intake_status", "Whether the account has any ACTIVE WordPress forms intake address, and how many: { data: { hasActive, activeCount } } (GET /api/v1/integrations/wordpress-forms/status). A boolean and a count only, never an intake, URL or token: use conduyt_list_form_intakes for the addresses (that needs the stronger form-intakes authority). The Connections page's WordPress forms row reads this. Read-only; requires " +
29
+ STATUS_REQUIREMENT +
30
+ "; a 403 is surfaced with that requirement.", {}, async () => {
31
+ try {
32
+ const result = await client.get("/api/v1/integrations/wordpress-forms/status");
33
+ return formatResult(result);
34
+ }
35
+ catch (err) {
36
+ return intakeToolError(err, STATUS_REQUIREMENT);
37
+ }
38
+ });
27
39
  server.tool("conduyt_list_form_intakes", "List the account's WordPress form intake addresses: every active intake (newest first), then the revoked history (the newest 200 revocations): name, defaultTag, source, assignee, status, receivedCount, lastReceivedAt and `url`: the full address of every ACTIVE intake (the credential, to paste into the form plugin); null for a revoked one. Requires " +
28
40
  LIST_AND_CREATE_REQUIREMENT +
29
41
  " (the URLs are credentials that create and edit contacts).", {}, async () => {
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { ConduytApiError, formatResult } from "../client.js";
3
+ import { strictInput } from "./strict.js";
3
4
  /** (default columns r18, Codex r17 h1) every general-settings PATCH names the revision it loaded: read it, save, and on a 409 hand
4
5
  * the conflict back with the current settings so the caller re-derives its change (tri-surface parity with the browser) */
5
6
  export async function patchGeneralSettings(client, body) {
@@ -98,6 +99,14 @@ export function registerLifecycleTools(server, client) {
98
99
  ? "null = not chosen yet (legacy account) — 'pipeline': every inbound contact gets a deal card (intakeDeals required); 'status': the contact is the opportunity"
99
100
  : undefined,
100
101
  lostPropagation,
102
+ // (card ownership per lane, 10-09) false = a booking uses the contact's existing open card in the intake pipeline. A stored value
103
+ // is shown as is; absent in a FULL snapshot (never saved; the runtime treats it as false) is false; absent in a REDUCED snapshot
104
+ // (a caller without settings.view) is null + note. The full view always carries `accountId` and the reduced one never does.
105
+ ...(typeof raw.bookingCreatesOwnCard === "boolean"
106
+ ? { bookingCreatesOwnCard: raw.bookingCreatesOwnCard }
107
+ : typeof raw.accountId === "string"
108
+ ? { bookingCreatesOwnCard: false }
109
+ : { bookingCreatesOwnCard: null, bookingCreatesOwnCardNote: "not visible to this role (needs settings.view)" }),
101
110
  masterStatuses: {
102
111
  defaults: DEFAULT_MASTER_STATUSES,
103
112
  customs,
@@ -128,6 +137,9 @@ export function registerLifecycleTools(server, client) {
128
137
  server.tool("conduyt_update_lost_propagation", "Turn LOST propagation on or off for this tenant (Lifecycle v3 P2). When on: a contact's masterStatus flips to 'lost' within a minute of its LAST open deal card closing lost (a per-minute resolver applies the rule on the committed state) (carrying that card's lostReason into masterStatusReason), a new open card on a lost/abandoned/disqualified contact reopens it, and a won contact never flips. Forward-only from the first enable (enabledAt is stamped server-side): existing history never flips by itself. A terminal masterStatus silences every drip, sequence and playbook for that contact, so read the nightly shadow report first (job run 'lost-propagation-shadow': contacts that would flip, their reasons, and the automations that would go quiet) before enabling.", {
129
138
  enabled: z.boolean().describe("true = flips happen live from now on; false = shadow report only"),
130
139
  }, async ({ enabled }) => patchGeneralSettings(client, { lostPropagation: { enabled } }));
140
+ strictInput(server.tool("conduyt_update_booking_creates_own_card", "Set settings.bookingCreatesOwnCard (card ownership per lane, PATCH /api/v1/settings). true: a booking creates a NEW card in the intake-deals pipeline and stage (conduyt_update_intake_deals), owned by the booking's host, instead of using the contact's existing open card there. false (the default): the contact's existing open card is used. Read the current value with conduyt_get_lifecycle_settings (bookingCreatesOwnCard). Requires settings:edit; a stale settings revision is handed back as a conflict with the current settings.", {
141
+ enabled: z.boolean().describe("true = each booking creates its own card owned by the host; false = reuse the contact's open intake card (default)"),
142
+ }, async ({ enabled }) => patchGeneralSettings(client, { bookingCreatesOwnCard: enabled })));
131
143
  server.tool("conduyt_update_disposition_status_map", "Set which lead status each call disposition sets when it is picked (by a rep after a call or by the AI auto-disposition): e.g. {\"not-interested\": \"lost\", \"sale-closed\": \"won\", \"interested\": \"working\"}. Keys are disposition values (built-ins like interested, not-interested, callback-requested, appointment-set, sale-closed, dnc-requested, voicemail, no-answer, wrong-number, or the account's custom outcomes); values are master statuses (open, won, lost, abandoned, disqualified, or the account's customs from conduyt_update_master_statuses). The map REPLACES the stored map (send the full map; {} clears every mapping); the rest of the account's disposition settings are untouched. An unknown disposition or status is a 422. A lead a deal has WON is never moved by an outcome; mapping to a terminal status (won/lost/abandoned/disqualified) closes the lead and stops its automations. Read the current map with conduyt_get_lifecycle_settings (dispositionStatusMap).", {
132
144
  statusMap: z
133
145
  .record(z.string().trim().min(1).max(40), z.string().trim().min(1).max(40))
@@ -1,3 +1,17 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { ConduytClient } from "../client.js";
3
+ /** What the message check needs of an API key's member (a read-tier key may call it): the API names only the route, so a 403 is surfaced with all of it. */
4
+ export declare const CHECK_MESSAGE_REQUIREMENT = "a key with the contacts, emails, sms or messages scope (a read-tier key too) whose member may view contacts (contacts.view) or email templates (email_templates.view)";
5
+ /**
6
+ * The check's refusals, surfaced verbatim and never replayed (the client retries GETs only). 403 forbidden names the full requirement; 429
7
+ * rate_limited says when to retry (20 per minute per account). 400 invalid_body and 413 message_too_long / body_too_large pass through
8
+ * untouched. Annotations go BEFORE the client's message, so its details JSON object stays the final token (README, Error text grammar).
9
+ */
10
+ export declare function checkMessageError(err: unknown): {
11
+ content: Array<{
12
+ type: "text";
13
+ text: string;
14
+ }>;
15
+ isError: true;
16
+ };
3
17
  export declare function registerMessagingTools(server: McpServer, client: ConduytClient): void;
@@ -1,6 +1,46 @@
1
1
  import { z } from "zod";
2
- import { formatResult } from "../client.js";
2
+ import { ConduytApiError, formatResult, formatToolError } from "../client.js";
3
+ import { strictInput } from "./strict.js";
4
+ /** What the message check needs of an API key's member (a read-tier key may call it): the API names only the route, so a 403 is surfaced with all of it. */
5
+ export const CHECK_MESSAGE_REQUIREMENT = "a key with the contacts, emails, sms or messages scope (a read-tier key too) whose member may view contacts (contacts.view) or email templates (email_templates.view)";
6
+ /**
7
+ * The check's refusals, surfaced verbatim and never replayed (the client retries GETs only). 403 forbidden names the full requirement; 429
8
+ * rate_limited says when to retry (20 per minute per account). 400 invalid_body and 413 message_too_long / body_too_large pass through
9
+ * untouched. Annotations go BEFORE the client's message, so its details JSON object stays the final token (README, Error text grammar).
10
+ */
11
+ export function checkMessageError(err) {
12
+ if (err instanceof ConduytApiError) {
13
+ if (err.status === 403)
14
+ return formatToolError(`This tool needs ${CHECK_MESSAGE_REQUIREMENT}. ${err.message}`);
15
+ if (err.status === 429) {
16
+ return formatToolError(`${err.retryAfterSeconds !== null ? `rate limited (20 checks per minute per account); retry after ${err.retryAfterSeconds} s` : "rate limited (20 checks per minute per account)"}: ${err.message}`);
17
+ }
18
+ }
19
+ throw err;
20
+ }
3
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[] (when the engine carries them; informational), engine { version, updatedAt } } }. 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). 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
+ CHECK_MESSAGE_REQUIREMENT +
24
+ ".", {
25
+ channel: z.enum(["sms", "email"]).describe("sms = a text, email = an email"),
26
+ subject: z.string().max(300).optional().describe("Email only (300 characters at most); refused on a text"),
27
+ body: z.string().max(10000).describe("The draft text (10,000 characters at most)"),
28
+ context: z.enum(["template", "first", "conversation"]).optional().describe("Where the message sits: template (default), first (first text to a contact) or conversation (a reply in an existing thread)"),
29
+ }, async ({ channel, subject, body, context }) => {
30
+ try {
31
+ // the body as ONE literal: the contract gate reads the request fields from it
32
+ const result = await client.post("/api/v1/messages/check", {
33
+ channel,
34
+ ...(subject !== undefined ? { subject } : {}),
35
+ body,
36
+ ...(context !== undefined ? { context } : {}),
37
+ });
38
+ return formatResult(result);
39
+ }
40
+ catch (err) {
41
+ return checkMessageError(err);
42
+ }
43
+ }));
4
44
  server.tool("conduyt_sms_transports", "What a workflow Send SMS step can speak through TODAY (GET /api/v1/sms/transports): twilio {connected, numbers[] = the account number + every active rep's assigned line, each a valid send_sms config.fromNumber}, projectBlue {connected, lines[] = Project Blue iPhone lines held by active reps, each lineId a valid config.projectBlueLineId for smsMode project_blue}, whatsapp {connected, sender = the account's WhatsApp sender for smsMode whatsapp; templates come from conduyt_whatsapp_templates}, voiceDrop {connected = Twilio is connected, recordings {hasDefault, templates[] = names valid as config.voiceDropRecording for smsMode voice_drop}}, webhook {providers = active webhook providers for smsMode webhook}, fromk {status connected | setup, connected, lines[] = the FromK.ai workspace's send-ready lines, each phoneNumberId a valid config.fromkPhoneNumberId for smsMode fromk}. Use it before building or editing a send_sms step so you never configure a transport that is not connected. Requires automations:view.", {}, async () => {
5
45
  const result = await client.get("/api/v1/sms/transports");
6
46
  return formatResult(result);
@@ -8,7 +8,7 @@ function unwrap(response) {
8
8
  }
9
9
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
10
10
  export function registerWorkflowSandboxTools(server, client) {
11
- server.tool("conduyt_workflow_validate", "Validate a workflow graph WITHOUT saving it. Returns errors and warnings so you can fix issues before creating or publishing. Always call this before conduyt_workflow_promote.", {
11
+ server.tool("conduyt_workflow_validate", "Validate a workflow graph WITHOUT saving it. Returns errors and warnings so you can fix issues before creating or publishing. Always call this before conduyt_workflow_promote. An action config key the action does not read, or an unregistered action type, comes back as an error with code `unknown_action_config_key` / `unknown_action_type` and a \"Did you mean\" suggestion.", {
12
12
  triggerEvent: z.string().describe("Trigger event (e.g. 'contact.created', 'deal.stage_changed', 'tag.added', 'scheduled')"),
13
13
  graph: z.object({
14
14
  nodes: z.array(z.object({
@@ -40,7 +40,7 @@ export function registerWorkflowSandboxTools(server, client) {
40
40
  const result = await client.post(`/api/v1/automations/${automationId}/dry-run`, body);
41
41
  return formatResult(result);
42
42
  });
43
- server.tool("conduyt_workflow_promote", "Safely publish a workflow. First validates the current graph — if there are errors, returns them and refuses to publish. If validation passes, publishes the draft to live. This is the recommended way to go live — it prevents broken workflows from reaching contacts.", {
43
+ server.tool("conduyt_workflow_promote", "Safely publish a workflow. First validates the current graph — if there are errors, returns them and refuses to publish. If validation passes, publishes the draft to live. This is the recommended way to go live — it prevents broken workflows from reaching contacts. A validation error (including `unknown_action_config_key` / `unknown_action_type`, with a suggestion) is returned and nothing is published; a publish preflight refusal (422) is surfaced verbatim.", {
44
44
  automationId: z.string().describe("Automation UUID to promote to live"),
45
45
  allowWarnings: z.boolean().optional().describe("Publish even if there are warnings (default true). Set false to block on warnings too."),
46
46
  }, async ({ automationId, allowWarnings }) => {
@@ -77,9 +77,12 @@ export function registerWorkflowSandboxTools(server, client) {
77
77
  message: "Workflow has warnings and allowWarnings is false. Fix warnings or set allowWarnings to true.",
78
78
  });
79
79
  }
80
- // (Conduyt Sentry r59 parity) the publish carries the revision of the draft that was just validated
81
- const current = (await client.get(`/api/v1/automations/${automationId}`));
82
- const published = await client.post(`/api/v1/automations/${automationId}/publish`, { expectedConfigRevision: typeof current?.configRevision === "number" ? current.configRevision : undefined });
80
+ // (Conduyt Sentry r59 parity) the publish carries the configRevision of the SAME snapshot that was just validated (the GET
81
+ // above, already unwrapped from { data }): no second read, so no save can slip between the review and the publish
82
+ if (typeof automation.configRevision !== "number") {
83
+ return formatResult({ promoted: false, error: "The automation read carried no configRevision, so the publish cannot be bound to the draft that was validated. Nothing was published." });
84
+ }
85
+ const published = await client.post(`/api/v1/automations/${automationId}/publish`, { expectedConfigRevision: automation.configRevision });
83
86
  return formatResult({
84
87
  promoted: true,
85
88
  warnings: hasWarnings ? validation.warnings : [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt-mcp",
3
- "version": "4.80.0",
3
+ "version": "4.81.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,9 +1,9 @@
1
1
  {
2
- "generatedAt": "2026-10-09T06:41:49.430Z",
3
- "toolCount": 336,
4
- "totalEndpoints": 1066,
2
+ "generatedAt": "2026-10-09T16:41:23.942Z",
3
+ "toolCount": 339,
4
+ "totalEndpoints": 1068,
5
5
  "counts": {
6
- "covered": 299,
6
+ "covered": 301,
7
7
  "excluded": 319,
8
8
  "planned": 448,
9
9
  "uncategorized": 0
@@ -637,7 +637,6 @@
637
637
  "status": "covered",
638
638
  "tools": [
639
639
  "conduyt_get_automation",
640
- "conduyt_update_automation",
641
640
  "conduyt_publish_automation",
642
641
  "conduyt_workflow_promote"
643
642
  ]
@@ -3095,6 +3094,12 @@
3095
3094
  "conduyt_revoke_form_intake"
3096
3095
  ]
3097
3096
  },
3097
+ "GET /api/v1/integrations/wordpress-forms/status": {
3098
+ "status": "covered",
3099
+ "tools": [
3100
+ "conduyt_get_form_intake_status"
3101
+ ]
3102
+ },
3098
3103
  "GET /api/v1/integrations/zapier/sample/:event": {
3099
3104
  "status": "planned",
3100
3105
  "reason": "integration management"
@@ -3203,6 +3208,12 @@
3203
3208
  "status": "planned",
3204
3209
  "reason": "message surfaces beyond send tools"
3205
3210
  },
3211
+ "POST /api/v1/messages/check": {
3212
+ "status": "covered",
3213
+ "tools": [
3214
+ "conduyt_check_message"
3215
+ ]
3216
+ },
3206
3217
  "GET /api/v1/messages/sms/:id": {
3207
3218
  "status": "planned",
3208
3219
  "reason": "message surfaces beyond send tools"