conduyt-mcp 4.79.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` |
@@ -27,6 +27,8 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
27
27
  |------|-------------|
28
28
  | `conduyt_list_deals` | List deals by pipeline, stage, or assignee; rows carry `hasNextStep` and `nextAppointmentAt`. With `health_summary=true` it answers the Pipeline health strip for the same filtered set (`totalOpenDeals`, `atRiskValue`, `checks[]`, `deals`); `health_check`, `health_limit`, `health_offset`, `health_cursor` (the previous `nextCursor`, passed back unchanged) and `health_rows_only` page one check's deals (rows-only answers `{data:{deals:{check, offset, items, hasMore, nextCursor}}}`) |
29
29
  | `conduyt_pipeline_health` | Pipeline health for one pipeline or all visible active pipelines (`GET /deals/inspection`): the six checks over open deals by money at risk; `check` adds that check's deals, paged by `cursor` (or `offset`). Cursor 400s carry `health_cursor_invalid` / `_context` / `_blank` / `_needs_check`, `health_filter_conflict` |
30
+ | `conduyt_pipeline_health_fix_deal` | Pipeline health quick fix for a deal row (`PATCH /deals/{id}` with `checkOrigin`): `stale` = `stageId` (+ `expectedStageId`), `unassigned` = `assignedTo`, `missing_close_date` = `expectedCloseDate`, `missing_contact` = `contactId`; `expectedUpdatedAt` required. Refusals verbatim, never retried: 409 `deal_changed` / `deal_check_cleared` (with the deal's current health row), uncoded 409s, 400 `health_fix_mismatch` |
31
+ | `conduyt_pipeline_health_fix_next_action` | Pipeline health "Set next action" (`POST /tasks` with `checkOrigin` `high_value_idle` / `needs_action`, `dealId`, `expectedDealUpdatedAt`, optional `idempotencyKey` sent as the `Idempotency-Key` header). At most one non-done quick-fix task per deal; refusals verbatim, never retried: 409 `deal_changed` / `deal_has_next_action` / `deal_check_cleared`, 400 `health_fix_mismatch`, idempotency 409/503 |
30
32
  | `conduyt_get_stage_rules` / `conduyt_set_stage_rules` | Read / set one stage's rule (`requiredFields` incl. `custom:<fieldKey>`, `requiredDocuments` 0-20, lost-reason label and options, `lostReasonAllowOther`); other stages untouched; revision-aware, retried once on a 409 |
31
33
  | `conduyt_get_deal_card_fields` / `conduyt_set_deal_card_fields` | Read / replace the kanban card fields (1-8, built-ins or `custom:<fieldKey>`) |
32
34
  | `conduyt_get_settings_revision` | The account `settingsRevision` (plus stored stage rules and rot days): pass it as `expectedSettingsRevision` to `conduyt_archive_pipeline` / `conduyt_delete_stage` so a stale one is refused 409 `stale_settings_revision` |
@@ -108,6 +110,7 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
108
110
  | `conduyt_whatsapp_templates` | The account's WhatsApp templates (Twilio Content) with Meta's approval verdict: sid, text with {{n}} placeholders, variables, approval |
109
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) |
110
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 |
111
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 |
112
115
 
113
116
  ### Companies
@@ -166,6 +169,7 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
166
169
  ### WordPress forms
167
170
  | Tool | Description |
168
171
  |------|-------------|
172
+ | `conduyt_get_form_intake_status` | Whether any intake address is active and how many (`{ hasActive, activeCount }`, count only, `integrations` scope, read-only) |
169
173
  | `conduyt_list_form_intakes` | List the intake addresses: every active one with its full URL, then the revoked history (newest 200) |
170
174
  | `conduyt_create_form_intake` | Create an intake address for one Gravity Forms / WPForms / Elementor form (returns its full URL) |
171
175
  | `conduyt_revoke_form_intake` | Revoke an intake address (the row is kept for history) |
@@ -332,6 +336,7 @@ The rest of team chat (sending, reactions, pins, presence, uploads) is not wrapp
332
336
  |------|-------------|
333
337
  | `conduyt_get_lifecycle_settings` | Effective master statuses (defaults + customs) and intake-deals config |
334
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) |
335
340
  | `conduyt_update_master_statuses` | Set the account's custom lead lifecycle statuses |
336
341
 
337
342
  ### Reply Capture (inbound email)
@@ -342,6 +347,24 @@ The rest of team chat (sending, reactions, pins, presence, uploads) is not wrapp
342
347
  | `conduyt_reply_capture_verify` | Check DNS and promote to live once the provider confirms inbound receiving |
343
348
  | `conduyt_reply_capture_remove` | Remove capture (and finish an interrupted removal) — requires `confirm='REMOVE'`; replies to already-sent email are lost |
344
349
 
350
+ ## Error text grammar
351
+
352
+ A refused call (HTTP 4xx/5xx) fails the tool with this text, stable across tools:
353
+
354
+ ```
355
+ Conduyt API <status>: <server message>[ [Retry-After: <N>s]][ <details>]
356
+ ```
357
+
358
+ `[Retry-After: Ns]` appears only when the server sent the header (shown, never acted on: a mutation is never retried).
359
+ `<details>` appears only when the body had more than `error`: it is ONE JSON object and the FINAL token. To extract it, try
360
+ each `{` from the left until the rest of the text parses as JSON (a server message may itself contain a brace). The object
361
+ carries scalars first (`code`, revision), then nested objects whole; `HealthFixRefusal.row` is always whole, any other nested
362
+ object too large for the bound is left out and named in `_omitted`. Every HTTP refusal takes this path, including the multipart call-flow audio upload; a tool that adds its own words (a missing-scope or busy note) puts them BEFORE this text. Example:
363
+
364
+ ```
365
+ Conduyt API 503: Contact merge in progress [Retry-After: 5s] {"code":"merge_in_progress"}
366
+ ```
367
+
345
368
  ## Setup
346
369
 
347
370
  ```bash
package/dist/client.d.ts CHANGED
@@ -28,7 +28,15 @@ export declare class ConduytApiError extends Error {
28
28
  export interface RequestOptions {
29
29
  timeoutMs?: number;
30
30
  maxResponseBytes?: number;
31
+ /** Extra request headers (e.g. Idempotency-Key on POST /tasks); never overrides Authorization / Content-Type. */
32
+ headers?: Record<string, string>;
31
33
  }
34
+ /**
35
+ * Render an error body's details (everything but `error`) as ONE valid JSON object: scalars first (code, revision; a very
36
+ * long string is shortened inside its quotes), then nested objects whole while they fit ERROR_DETAILS_BOUND, plus the
37
+ * ALWAYS_WHOLE_KEYS; a nested object that does not fit is left out and named in `_omitted`. JSON is never cut mid-object.
38
+ */
39
+ export declare function renderErrorDetails(body: Record<string, unknown>): string;
32
40
  export declare class ConduytClient {
33
41
  private baseUrl;
34
42
  private apiKey;
@@ -42,7 +50,13 @@ export declare class ConduytClient {
42
50
  * after (chunked responses carry no length header).
43
51
  */
44
52
  private attempt;
53
+ /**
54
+ * Error grammar (stable): `Conduyt API <status>: <message>` + optionally ` [Retry-After: <N>s]` + optionally ` <details>`,
55
+ * where <details> is ONE JSON object and the FINAL token (see renderErrorDetails). Parse it by trying each `{` from the left
56
+ * until the rest of the text is valid JSON (a server message may itself contain a brace).
57
+ */
45
58
  private httpError;
59
+ private mkHttpError;
46
60
  private retryDelayMs;
47
61
  /**
48
62
  * Retries are for idempotent GETs ONLY: transient statuses (429/502/503/
package/dist/client.js CHANGED
@@ -32,6 +32,78 @@ const MAX_RETRY_AFTER_MS = 10_000;
32
32
  const isAbort = (err) => err?.name === "TimeoutError" || err?.name === "AbortError";
33
33
  /** what a timeout of a MUTATION means: the server may have applied it */
34
34
  const AMBIGUOUS = " The server may have applied it: read the current state before repeating it.";
35
+ /** Room for the nested objects of an error body (a 409 contact_changed's `contact`, a stage-requirements list). */
36
+ const ERROR_DETAILS_BOUND = 1000;
37
+ /** Error-body keys that are the actionable state itself and are always rendered WHOLE: HealthFixRefusal.row (the deal's
38
+ * current health row, which a caller needs to choose again). The response size cap bounds it. */
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
+ }
76
+ /**
77
+ * Render an error body's details (everything but `error`) as ONE valid JSON object: scalars first (code, revision; a very
78
+ * long string is shortened inside its quotes), then nested objects whole while they fit ERROR_DETAILS_BOUND, plus the
79
+ * ALWAYS_WHOLE_KEYS; a nested object that does not fit is left out and named in `_omitted`. JSON is never cut mid-object.
80
+ */
81
+ export function renderErrorDetails(body) {
82
+ const entries = Object.entries(body).filter(([k]) => k !== "error");
83
+ if (entries.length === 0)
84
+ return "";
85
+ const isScalar = (v) => v === null || typeof v !== "object";
86
+ const out = {};
87
+ for (const [k, v] of entries.filter(([, v]) => isScalar(v)))
88
+ out[k] = typeof v === "string" && v.length > ERROR_DETAILS_BOUND ? v.slice(0, ERROR_DETAILS_BOUND) + "...[shortened]" : v;
89
+ const omitted = [];
90
+ for (const [k, v] of entries.filter(([, v]) => !isScalar(v))) {
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)
93
+ out[k] = v;
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
+ }
102
+ }
103
+ if (omitted.length > 0)
104
+ out._omitted = omitted;
105
+ return JSON.stringify(out);
106
+ }
35
107
  export class ConduytClient {
36
108
  baseUrl;
37
109
  apiKey;
@@ -63,9 +135,10 @@ export class ConduytClient {
63
135
  * from Content-Length before the body is read AND from the actual bytes
64
136
  * after (chunked responses carry no length header).
65
137
  */
66
- async attempt(method, path, body, timeoutMs, maxResponseBytes) {
138
+ async attempt(method, path, body, timeoutMs, maxResponseBytes, extraHeaders) {
67
139
  const url = `${this.baseUrl}${path}`;
68
140
  const headers = {
141
+ ...(extraHeaders ?? {}),
69
142
  Authorization: `Bearer ${this.apiKey}`,
70
143
  "Content-Type": "application/json",
71
144
  };
@@ -161,36 +234,40 @@ export class ConduytClient {
161
234
  }
162
235
  return { res, text };
163
236
  }
237
+ /**
238
+ * Error grammar (stable): `Conduyt API <status>: <message>` + optionally ` [Retry-After: <N>s]` + optionally ` <details>`,
239
+ * where <details> is ONE JSON object and the FINAL token (see renderErrorDetails). Parse it by trying each `{` from the left
240
+ * until the rest of the text is valid JSON (a server message may itself contain a brace).
241
+ */
164
242
  httpError(res, text) {
243
+ const retryAfterRaw = res.headers.get("retry-after");
244
+ const retryAfter = retryAfterRaw === null || retryAfterRaw.trim() === "" ? NaN : Number(retryAfterRaw);
245
+ const retryAfterOk = Number.isFinite(retryAfter) && retryAfter >= 0;
246
+ const raTag = retryAfterOk ? ` [Retry-After: ${retryAfter}s]` : "";
165
247
  let msg = res.statusText;
166
248
  try {
167
249
  const json = JSON.parse(text);
168
250
  if (json?.error)
169
251
  msg = json.error;
170
- // Structured error details (e.g. STAGE_REQUIREMENTS carries
171
- // lostReasonOptions) must reach the agent, or a "must be one of the
172
- // configured options" error is unactionable. Bounded so a pathological
173
- // body can't bloat the message.
174
- // Scalars first (code, revision), nested objects last (a 409 contact_changed carries the whole current
175
- // `contact`): the bound below may cut a large object, never the code or the current revision.
176
- const isScalar = (v) => v === null || typeof v !== "object";
177
- const entries = Object.entries(json ?? {}).filter(([k]) => k !== "error");
178
- const details = Object.fromEntries([
179
- ...entries.filter(([, v]) => isScalar(v)),
180
- ...entries.filter(([, v]) => !isScalar(v)),
181
- ]);
182
- if (Object.keys(details).length > 0) {
183
- msg = `${msg} ${JSON.stringify(details).slice(0, 1000)}`;
184
- }
252
+ // Structured error details (e.g. STAGE_REQUIREMENTS carries lostReasonOptions) must reach the agent, or the
253
+ // refusal is unactionable. Rendered by renderErrorDetails: JSON is never cut mid-object.
254
+ const details = renderErrorDetails(json ?? {});
255
+ if (details)
256
+ msg = `${msg}${raTag} ${details}`;
257
+ else
258
+ msg = `${msg}${raTag}`;
259
+ return this.mkHttpError(res, `Conduyt API ${res.status}: ${msg}`, retryAfter, retryAfterOk);
185
260
  }
186
261
  catch {
187
262
  if (text)
188
263
  msg = text.slice(0, 500);
264
+ msg = `${msg}${raTag}`;
189
265
  }
190
- const retryAfterRaw = res.headers.get("retry-after");
191
- const retryAfter = retryAfterRaw === null || retryAfterRaw.trim() === "" ? NaN : Number(retryAfterRaw);
266
+ return this.mkHttpError(res, `Conduyt API ${res.status}: ${msg}`, retryAfter, retryAfterOk);
267
+ }
268
+ mkHttpError(res, message, retryAfter, retryAfterOk) {
192
269
  return new ConduytApiError({
193
- message: `Conduyt API ${res.status}: ${msg}`,
270
+ message,
194
271
  status: res.status,
195
272
  code: "http",
196
273
  retryable: RETRYABLE_STATUSES.has(res.status),
@@ -224,7 +301,7 @@ export class ConduytClient {
224
301
  const remaining = totalBudgetMs - (Date.now() - startedAt);
225
302
  let res = null;
226
303
  try {
227
- const out = await this.attempt(method, path, body, Math.max(1, Math.min(timeoutMs, remaining)), maxResponseBytes);
304
+ const out = await this.attempt(method, path, body, Math.max(1, Math.min(timeoutMs, remaining)), maxResponseBytes, opts?.headers);
228
305
  res = out.res;
229
306
  if (out.res.ok)
230
307
  return out.text;
@@ -282,10 +359,9 @@ export class ConduytClient {
282
359
  catch {
283
360
  json = { raw: text };
284
361
  }
285
- if (!res.ok) {
286
- const msg = json?.error ?? text.slice(0, 300);
287
- throw new ConduytApiError({ message: `Conduyt API ${res.status}: ${msg}`, status: res.status, code: "http", retryable: res.status >= 500 });
288
- }
362
+ // every non-2xx goes through the shared formatter: same grammar, Retry-After placement and final JSON details object
363
+ if (!res.ok)
364
+ throw this.httpError(res, text);
289
365
  return json;
290
366
  }
291
367
  post(path, body, opts) {
@@ -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}`);
@@ -112,6 +112,35 @@ export function registerDealTools(server, client) {
112
112
  const result = qs ? await client.get("/api/v1/deals/inspection?" + qs) : await client.get("/api/v1/deals/inspection");
113
113
  return formatResult(result);
114
114
  }));
115
+ strictInput(server.tool("conduyt_pipeline_health_fix_deal", "Pipeline health QUICK FIX for a deal row (PATCH /api/v1/deals/{id} with checkOrigin). Pass the health check the row came from, the deal's updatedAt exactly as the health row carried it (expectedUpdatedAt, REQUIRED), and ONLY that check's fix: stale = stageId (+ optional expectedStageId, the stage the move was chosen against); unassigned = assignedTo; missing_close_date = expectedCloseDate; missing_contact = contactId. Each set to a value; any other combination is refused 400 health_fix_mismatch and nothing is read or written. For the next-action checks (high_value_idle, needs_action) use conduyt_pipeline_health_fix_next_action instead. Refusals reach you verbatim (status, server message, code, and the deal's CURRENT health row) and are NEVER retried. Error text grammar: `Conduyt API <status>: <message>[ [Retry-After: Ns]][ <one JSON object, the final token>]` (see README, Error text grammar). 409 deal_changed (the deal is no longer at expectedUpdatedAt; error text carries updatedAt and the current row: read it, choose again against row.updatedAt), 409 deal_check_cleared (the deal no longer fails that check; nothing written), a 409 without a code (a stage move by another writer, a stale expectedStageId, an in-flight publication), 400 health_fix_mismatch, 404 (no such deal, or your key cannot see it). The row in a refusal is null when your key can no longer see the deal. Ordinary edits (several fields, no health context) stay on conduyt_update_deal.", {
116
+ dealId: z.string().uuid().describe("Deal UUID"),
117
+ checkOrigin: z.enum(["stale", "unassigned", "missing_close_date", "missing_contact"]).describe("The health check this edit fixes"),
118
+ expectedUpdatedAt: z.string().datetime({ message: "expectedUpdatedAt must be the deal's updatedAt exactly as the health row carried it (UTC, trailing Z, e.g. 2026-10-06T23:19:00.000Z)" }).describe("REQUIRED: the deal's updatedAt as the health row carried it (PipelineHealthDeal.updatedAt). A deal at another version is refused 409 deal_changed."),
119
+ stageId: z.string().uuid().optional().describe("stale only: the stage to move the deal to"),
120
+ expectedStageId: z.string().uuid().optional().describe("stale only: the stage the move was chosen against; refused 409 (nothing written) when the deal is no longer in it"),
121
+ assignedTo: z.string().uuid().optional().describe("unassigned only: the user (owner) UUID to assign"),
122
+ expectedCloseDate: z.string().optional().describe("missing_close_date only: the expected close date (ISO 8601, e.g. '2026-11-30')"),
123
+ contactId: z.string().uuid().optional().describe("missing_contact only: the contact UUID to link"),
124
+ }, async ({ dealId, checkOrigin, expectedUpdatedAt, stageId, expectedStageId, assignedTo, expectedCloseDate, contactId }) => {
125
+ const result = await client.patch(`/api/v1/deals/${dealId}`, { checkOrigin, expectedUpdatedAt, stageId, expectedStageId, assignedTo, expectedCloseDate, contactId });
126
+ return formatResult(result);
127
+ }));
128
+ strictInput(server.tool("conduyt_pipeline_health_fix_next_action", "Pipeline health QUICK FIX \"Set next action\" (POST /api/v1/tasks with checkOrigin): creates a follow-up task on a deal from the High-value idle or Needs next action row. Pass checkOrigin, dealId and expectedDealUpdatedAt (the deal's updatedAt as the health row carried it); the task is created only while, under the deal's next-action lock, the deal is still at that version, still has no next action (no open task, no appointment booked from a day ago on) and still fails the check. Send idempotencyKey (1-255 printable ASCII) so a retry after a lost response replays the first task; reuse it ONLY for the same request. Refusals reach you verbatim (status, message, code, the deal's CURRENT health row) and are NEVER retried. Error text grammar: `Conduyt API <status>: <message>[ [Retry-After: Ns]][ <one JSON object, the final token>]` (see README, Error text grammar). 409 deal_changed (read the row, choose again against row.updatedAt), 409 deal_has_next_action (This deal already has a next action booked.), 409 deal_check_cleared, 400 health_fix_mismatch (checkOrigin not high_value_idle / needs_action, or without dealId / expectedDealUpdatedAt), a 409 without a code (the contact was merged or deleted mid-request: retry against the surviving record), or an idempotency 409/503 (see the idempotencyKey notes; verify the outcome before using a new key). The database allows at most one non-done quick-fix task per deal (a second is refused 409 deal_has_next_action); ordinary tasks (conduyt_create_task) are unconstrained and may coexist with or race a quick fix.", {
129
+ checkOrigin: z.enum(["high_value_idle", "needs_action"]).describe("The health check this task fixes"),
130
+ dealId: z.string().uuid().describe("Deal UUID the task is for"),
131
+ expectedDealUpdatedAt: z.string().datetime({ message: "expectedDealUpdatedAt must be the deal's updatedAt exactly as the health row carried it (UTC, trailing Z)" }).describe("REQUIRED: the deal's updatedAt as the health row carried it (PipelineHealthDeal.updatedAt)"),
132
+ title: z.string().min(1).max(500).describe("Task title (trimmed; blank is refused)"),
133
+ description: z.string().max(5000).optional().describe("Task description (max 5000)"),
134
+ dueDate: z.string().optional().describe("Due date: a date-only YYYY-MM-DD is stored as noon UTC that day; a date-time as given"),
135
+ priority: z.enum(["low", "medium", "high", "urgent"]).optional().describe("Task priority (default medium)"),
136
+ status: z.enum(["todo", "in_progress", "done"]).optional().describe("Task status (default todo)"),
137
+ assignedTo: z.string().nullable().optional().describe("A member's user UUID, '@me' or '@owner'; null or omitted = unassigned (assignment rules may still assign it). Assigning to someone else needs tasks.assign."),
138
+ contactId: z.string().uuid().nullable().optional().describe("Contact UUID to link"),
139
+ idempotencyKey: z.string().regex(/^[!-~](?:[ -~]{0,253}[!-~])?$/, "idempotencyKey must be 1-255 printable ASCII characters, not blank").optional().describe("Sent as the Idempotency-Key header: one key per creation attempt, reused only to retry the SAME request (same key + same body replays the first task; same key + a different body is 422). Two distinct 409s: the original attempt is still processing (wait Retry-After, retry with the SAME key), or code idempotency_key_spent (a prior attempt failed or has an unknown outcome: verify whether it took effect BEFORE a new key). 503 idempotency_claim_held: retry with a NEW key; 503 idempotency_outcome_unknown: verify the outcome first."),
140
+ }, async ({ idempotencyKey, checkOrigin, dealId, expectedDealUpdatedAt, title, description, dueDate, priority, status, assignedTo, contactId }) => {
141
+ const result = await client.post("/api/v1/tasks", { checkOrigin, dealId, expectedDealUpdatedAt, title, description, dueDate, priority, status, assignedTo, contactId }, idempotencyKey !== undefined ? { headers: { "Idempotency-Key": idempotencyKey } } : undefined);
142
+ return formatResult(result);
143
+ }));
115
144
  server.tool("conduyt_get_deal", "Get a single deal with full details — value, currency, probability, stage, contact, expected close date, custom-field values, activities, tasks, and history. Use this to read structured custom-field data back out of a deal (e.g. for a P&L or balance-sheet model). Deal rows also carry hasNextStep and nextAppointmentAt (null unless your key can view the calendar) when the API returns them.", {
116
145
  id: z.string().describe("Deal UUID"),
117
146
  }, async ({ id }) => {
@@ -10,8 +10,10 @@ 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
- /** A 403 names the complete requirement; a busy 503 says when to retry (never replayed here: create is a mutation). */
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
16
+ * client's message, so its details JSON object stays the final token (README, Error text grammar). */
15
17
  export declare function intakeToolError(err: unknown, requirement: string): {
16
18
  content: Array<{
17
19
  type: "text";
@@ -10,19 +10,32 @@ 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
- /** A 403 names the complete requirement; a busy 503 says when to retry (never replayed here: create is a mutation). */
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
16
+ * client's message, so its details JSON object stays the final token (README, Error text grammar). */
15
17
  export function intakeToolError(err, requirement) {
16
18
  if (err instanceof ConduytApiError) {
17
19
  if (err.status === 403)
18
- return formatToolError(`${err.message}. This tool needs ${requirement}.`);
20
+ return formatToolError(`This tool needs ${requirement}. ${err.message}`);
19
21
  if (err.status === 503 && err.retryAfterSeconds !== null) {
20
- return formatToolError(`busy; retry after ${err.retryAfterSeconds} s (${err.message})`);
22
+ return formatToolError(`busy; retry after ${err.retryAfterSeconds} s: ${err.message}`);
21
23
  }
22
24
  }
23
25
  throw err;
24
26
  }
25
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
+ });
26
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 " +
27
40
  LIST_AND_CREATE_REQUIREMENT +
28
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);
@@ -49,7 +49,7 @@ export function registerTaskTools(server, client) {
49
49
  const result = await client.get(`/api/v1/tasks?${params}`);
50
50
  return formatResult(result);
51
51
  });
52
- server.tool("conduyt_create_task", "Create a new task. Can be linked to a contact or deal and assigned to a team member.", {
52
+ server.tool("conduyt_create_task", "Create a new task. Can be linked to a contact or deal and assigned to a team member. This is the ORDINARY task route: ordinary tasks are unconstrained and may coexist with or race a Pipeline health quick-fix task. The database allows at most one non-done quick-fix task per deal (a second is refused 409 deal_has_next_action); to create the quick fix itself (checkOrigin + expectedDealUpdatedAt, guarded against a deal that changed) use conduyt_pipeline_health_fix_next_action. A 409 without a code means the contact was merged or deleted mid-request: retry against the surviving record. Errors reach you verbatim and are never retried.", {
53
53
  title: z.string().describe("Task title"),
54
54
  description: z.string().optional().describe("Task description"),
55
55
  dueDate: z.string().optional().describe("Due date (ISO 8601)"),
@@ -68,7 +68,7 @@ export function registerTaskTools(server, client) {
68
68
  const result = await client.get(`/api/v1/tasks/${encodeURIComponent(id)}`);
69
69
  return formatResult(result);
70
70
  });
71
- server.tool("conduyt_update_task", "Update an existing task's fields. Only the fields you provide are changed; omit a field to leave it as-is. To mark a task done, prefer conduyt_complete_task. Requires tasks:edit.", {
71
+ server.tool("conduyt_update_task", "Update an existing task's fields. Only the fields you provide are changed; omit a field to leave it as-is. To mark a task done, prefer conduyt_complete_task. Requires tasks:edit. A 409 means nothing was written, surfaced verbatim and never retried: code deal_has_next_action (\"This deal already has a next action booked.\": re-opening or re-linking a quick-fix task would give the deal a second open one), or no code (the task changed while saving: read it again and retry).", {
72
72
  id: z.string().uuid().describe("Task UUID to update"),
73
73
  title: z.string().optional().describe("Updated task title"),
74
74
  description: z.string().optional().describe("Updated description (pass an empty string to clear it)"),
@@ -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.79.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-09T04:00:29.593Z",
3
- "toolCount": 334,
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
  ]
@@ -2165,6 +2164,7 @@
2165
2164
  "PATCH /api/v1/deals/:id": {
2166
2165
  "status": "covered",
2167
2166
  "tools": [
2167
+ "conduyt_pipeline_health_fix_deal",
2168
2168
  "conduyt_update_deal"
2169
2169
  ]
2170
2170
  },
@@ -3094,6 +3094,12 @@
3094
3094
  "conduyt_revoke_form_intake"
3095
3095
  ]
3096
3096
  },
3097
+ "GET /api/v1/integrations/wordpress-forms/status": {
3098
+ "status": "covered",
3099
+ "tools": [
3100
+ "conduyt_get_form_intake_status"
3101
+ ]
3102
+ },
3097
3103
  "GET /api/v1/integrations/zapier/sample/:event": {
3098
3104
  "status": "planned",
3099
3105
  "reason": "integration management"
@@ -3202,6 +3208,12 @@
3202
3208
  "status": "planned",
3203
3209
  "reason": "message surfaces beyond send tools"
3204
3210
  },
3211
+ "POST /api/v1/messages/check": {
3212
+ "status": "covered",
3213
+ "tools": [
3214
+ "conduyt_check_message"
3215
+ ]
3216
+ },
3205
3217
  "GET /api/v1/messages/sms/:id": {
3206
3218
  "status": "planned",
3207
3219
  "reason": "message surfaces beyond send tools"
@@ -4495,6 +4507,7 @@
4495
4507
  "POST /api/v1/tasks": {
4496
4508
  "status": "covered",
4497
4509
  "tools": [
4510
+ "conduyt_pipeline_health_fix_next_action",
4498
4511
  "conduyt_create_task"
4499
4512
  ]
4500
4513
  },