conduyt-mcp 4.78.0 → 4.80.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
@@ -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` |
@@ -55,7 +57,7 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
55
57
  | `conduyt_delete_pipeline` | Delete a pipeline (move/remove its deals first) |
56
58
  | `conduyt_create_stage` | Append a stage to a pipeline (e.g. 'On Hold', 'Declined/Dead') |
57
59
  | `conduyt_update_stage` | Rename, recolor, reorder, or change won/lost flags on a stage |
58
- | `conduyt_delete_stage` | Delete a stage from a pipeline; `moveTo` moves its live deals to another stage first, `expectedSettingsRevision` refuses the delete when settings changed (409 `stale_settings_revision`; the answer then carries the new `settingsRevision`) |
60
+ | `conduyt_delete_stage` | Soft-delete a stage: an EMPTY stage can always be deleted (history kept, hidden from every list, GET / PATCH then 404, no restore in v1, name reusable); `moveTo` re-homes its live deals first, `expectedSettingsRevision` refuses the delete when settings changed (409 `stale_settings_revision`; the answer then carries the new `settingsRevision`). Refusals are 409s whose message is surfaced verbatim: deals in the stage, `used by N active automation(s) (names)`, DPP status map, `pipeline_archived`, `intake_target`. A run parked before the delete fails at that step with a clear reason |
59
61
 
60
62
  ### Activities
61
63
  | Tool | Description |
@@ -342,6 +344,24 @@ The rest of team chat (sending, reactions, pins, presence, uploads) is not wrapp
342
344
  | `conduyt_reply_capture_verify` | Check DNS and promote to live once the provider confirms inbound receiving |
343
345
  | `conduyt_reply_capture_remove` | Remove capture (and finish an interrupted removal) — requires `confirm='REMOVE'`; replies to already-sent email are lost |
344
346
 
347
+ ## Error text grammar
348
+
349
+ A refused call (HTTP 4xx/5xx) fails the tool with this text, stable across tools:
350
+
351
+ ```
352
+ Conduyt API <status>: <server message>[ [Retry-After: <N>s]][ <details>]
353
+ ```
354
+
355
+ `[Retry-After: Ns]` appears only when the server sent the header (shown, never acted on: a mutation is never retried).
356
+ `<details>` appears only when the body had more than `error`: it is ONE JSON object and the FINAL token. To extract it, try
357
+ each `{` from the left until the rest of the text parses as JSON (a server message may itself contain a brace). The object
358
+ carries scalars first (`code`, revision), then nested objects whole; `HealthFixRefusal.row` is always whole, any other nested
359
+ 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:
360
+
361
+ ```
362
+ Conduyt API 503: Contact merge in progress [Retry-After: 5s] {"code":"merge_in_progress"}
363
+ ```
364
+
345
365
  ## Setup
346
366
 
347
367
  ```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,35 @@ 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
+ /**
41
+ * Render an error body's details (everything but `error`) as ONE valid JSON object: scalars first (code, revision; a very
42
+ * long string is shortened inside its quotes), then nested objects whole while they fit ERROR_DETAILS_BOUND, plus the
43
+ * ALWAYS_WHOLE_KEYS; a nested object that does not fit is left out and named in `_omitted`. JSON is never cut mid-object.
44
+ */
45
+ export function renderErrorDetails(body) {
46
+ const entries = Object.entries(body).filter(([k]) => k !== "error");
47
+ if (entries.length === 0)
48
+ return "";
49
+ const isScalar = (v) => v === null || typeof v !== "object";
50
+ const out = {};
51
+ for (const [k, v] of entries.filter(([, v]) => isScalar(v)))
52
+ out[k] = typeof v === "string" && v.length > ERROR_DETAILS_BOUND ? v.slice(0, ERROR_DETAILS_BOUND) + "...[shortened]" : v;
53
+ const omitted = [];
54
+ 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)
56
+ out[k] = v;
57
+ else
58
+ omitted.push(k);
59
+ }
60
+ if (omitted.length > 0)
61
+ out._omitted = omitted;
62
+ return JSON.stringify(out);
63
+ }
35
64
  export class ConduytClient {
36
65
  baseUrl;
37
66
  apiKey;
@@ -63,9 +92,10 @@ export class ConduytClient {
63
92
  * from Content-Length before the body is read AND from the actual bytes
64
93
  * after (chunked responses carry no length header).
65
94
  */
66
- async attempt(method, path, body, timeoutMs, maxResponseBytes) {
95
+ async attempt(method, path, body, timeoutMs, maxResponseBytes, extraHeaders) {
67
96
  const url = `${this.baseUrl}${path}`;
68
97
  const headers = {
98
+ ...(extraHeaders ?? {}),
69
99
  Authorization: `Bearer ${this.apiKey}`,
70
100
  "Content-Type": "application/json",
71
101
  };
@@ -161,36 +191,40 @@ export class ConduytClient {
161
191
  }
162
192
  return { res, text };
163
193
  }
194
+ /**
195
+ * Error grammar (stable): `Conduyt API <status>: <message>` + optionally ` [Retry-After: <N>s]` + optionally ` <details>`,
196
+ * where <details> is ONE JSON object and the FINAL token (see renderErrorDetails). Parse it by trying each `{` from the left
197
+ * until the rest of the text is valid JSON (a server message may itself contain a brace).
198
+ */
164
199
  httpError(res, text) {
200
+ const retryAfterRaw = res.headers.get("retry-after");
201
+ const retryAfter = retryAfterRaw === null || retryAfterRaw.trim() === "" ? NaN : Number(retryAfterRaw);
202
+ const retryAfterOk = Number.isFinite(retryAfter) && retryAfter >= 0;
203
+ const raTag = retryAfterOk ? ` [Retry-After: ${retryAfter}s]` : "";
165
204
  let msg = res.statusText;
166
205
  try {
167
206
  const json = JSON.parse(text);
168
207
  if (json?.error)
169
208
  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
- }
209
+ // Structured error details (e.g. STAGE_REQUIREMENTS carries lostReasonOptions) must reach the agent, or the
210
+ // refusal is unactionable. Rendered by renderErrorDetails: JSON is never cut mid-object.
211
+ const details = renderErrorDetails(json ?? {});
212
+ if (details)
213
+ msg = `${msg}${raTag} ${details}`;
214
+ else
215
+ msg = `${msg}${raTag}`;
216
+ return this.mkHttpError(res, `Conduyt API ${res.status}: ${msg}`, retryAfter, retryAfterOk);
185
217
  }
186
218
  catch {
187
219
  if (text)
188
220
  msg = text.slice(0, 500);
221
+ msg = `${msg}${raTag}`;
189
222
  }
190
- const retryAfterRaw = res.headers.get("retry-after");
191
- const retryAfter = retryAfterRaw === null || retryAfterRaw.trim() === "" ? NaN : Number(retryAfterRaw);
223
+ return this.mkHttpError(res, `Conduyt API ${res.status}: ${msg}`, retryAfter, retryAfterOk);
224
+ }
225
+ mkHttpError(res, message, retryAfter, retryAfterOk) {
192
226
  return new ConduytApiError({
193
- message: `Conduyt API ${res.status}: ${msg}`,
227
+ message,
194
228
  status: res.status,
195
229
  code: "http",
196
230
  retryable: RETRYABLE_STATUSES.has(res.status),
@@ -224,7 +258,7 @@ export class ConduytClient {
224
258
  const remaining = totalBudgetMs - (Date.now() - startedAt);
225
259
  let res = null;
226
260
  try {
227
- const out = await this.attempt(method, path, body, Math.max(1, Math.min(timeoutMs, remaining)), maxResponseBytes);
261
+ const out = await this.attempt(method, path, body, Math.max(1, Math.min(timeoutMs, remaining)), maxResponseBytes, opts?.headers);
228
262
  res = out.res;
229
263
  if (out.res.ok)
230
264
  return out.text;
@@ -282,10 +316,9 @@ export class ConduytClient {
282
316
  catch {
283
317
  json = { raw: text };
284
318
  }
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
- }
319
+ // every non-2xx goes through the shared formatter: same grammar, Retry-After placement and final JSON details object
320
+ if (!res.ok)
321
+ throw this.httpError(res, text);
289
322
  return json;
290
323
  }
291
324
  post(path, body, opts) {
@@ -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 }) => {
@@ -11,7 +11,8 @@ import { ConduytClient } from "../client.js";
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
13
  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). */
14
+ /** 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
+ * client's message, so its details JSON object stays the final token (README, Error text grammar). */
15
16
  export declare function intakeToolError(err: unknown, requirement: string): {
16
17
  content: Array<{
17
18
  type: "text";
@@ -11,13 +11,14 @@ import { ConduytApiError, formatResult, formatToolError } from "../client.js";
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
13
  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). */
14
+ /** 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
+ * client's message, so its details JSON object stays the final token (README, Error text grammar). */
15
16
  export function intakeToolError(err, requirement) {
16
17
  if (err instanceof ConduytApiError) {
17
18
  if (err.status === 403)
18
- return formatToolError(`${err.message}. This tool needs ${requirement}.`);
19
+ return formatToolError(`This tool needs ${requirement}. ${err.message}`);
19
20
  if (err.status === 503 && err.retryAfterSeconds !== null) {
20
- return formatToolError(`busy; retry after ${err.retryAfterSeconds} s (${err.message})`);
21
+ return formatToolError(`busy; retry after ${err.retryAfterSeconds} s: ${err.message}`);
21
22
  }
22
23
  }
23
24
  throw err;
@@ -9,7 +9,7 @@ export const ARCHIVE_ERROR_CODES = "A refusal carries a machine-readable code in
9
9
  /** above the archive route's 60 s transaction (up to 500 deal moves) plus post-commit events; the client default is 30 s */
10
10
  export const ARCHIVE_PIPELINE_TIMEOUT_MS = 75_000;
11
11
  export function registerPipelineTools(server, client) {
12
- server.tool("conduyt_list_pipelines", "List all active sales pipelines with their stages. Use this to discover pipeline and stage UUIDs before creating or moving deals. Each pipeline carries color (one of the 10 palette hex colours, or null until the settings page saves one), automationOnly and sortOrder (the board-menu order); an archived pipeline's pre-archive name is archivedFromName (see conduyt_list_archived_pipelines).", {}, async () => {
12
+ server.tool("conduyt_list_pipelines", "List all active sales pipelines with their stages (soft-deleted stages are never included). Use this to discover pipeline and stage UUIDs before creating or moving deals. Each pipeline carries color (one of the 10 palette hex colours, or null until the settings page saves one), automationOnly and sortOrder (the board-menu order); an archived pipeline's pre-archive name is archivedFromName (see conduyt_list_archived_pipelines).", {}, async () => {
13
13
  const result = await client.get("/api/v1/pipelines");
14
14
  return formatResult(result);
15
15
  });
@@ -90,7 +90,7 @@ export function registerPipelineTools(server, client) {
90
90
  const result = await client.post("/api/v1/pipelines/reorder", { order, expectedRevision });
91
91
  return formatResult(result);
92
92
  }));
93
- server.tool("conduyt_create_stage", "Add a new stage to an existing pipeline. The stage is appended after the current last stage. Use this to extend a funnel (e.g. add 'On Hold' or 'Declined/Dead').", {
93
+ server.tool("conduyt_create_stage", "Add a new stage to an existing pipeline. The stage is appended after the current last stage. Use this to extend a funnel (e.g. add 'On Hold' or 'Declined/Dead'). A name may be reused after the old stage of that name was deleted (deleted stages are soft-deleted and hidden).", {
94
94
  pipelineId: z.string().describe("Pipeline UUID to add the stage to"),
95
95
  name: z.string().describe("Stage name"),
96
96
  color: z.string().optional().describe("Hex color, e.g. '#3B82F6'"),
@@ -107,7 +107,7 @@ export function registerPipelineTools(server, client) {
107
107
  });
108
108
  return formatResult(result);
109
109
  });
110
- server.tool("conduyt_update_stage", "Update a stage within a pipeline — rename, recolor, reorder (sortOrder), or change its won/lost/system flags.", {
110
+ server.tool("conduyt_update_stage", "Update a stage within a pipeline — rename, recolor, reorder (sortOrder), or change its won/lost/system flags. A soft-deleted stage (conduyt_delete_stage) answers 404.", {
111
111
  pipelineId: z.string().describe("Pipeline UUID"),
112
112
  stageId: z.string().describe("Stage UUID to update"),
113
113
  name: z.string().optional().describe("Updated stage name"),
@@ -128,7 +128,7 @@ export function registerPipelineTools(server, client) {
128
128
  const result = await client.post(`/api/v1/pipelines/${pipelineId}/stages/reorder`, { order, expectedRevision });
129
129
  return formatResult(result);
130
130
  }));
131
- strictInput(server.tool("conduyt_delete_stage", "Delete a stage from a pipeline. Strict input: the guards are spelled moveTo and expectedSettingsRevision, any other spelling (move_to, expected_settings_revision, a typo) is InvalidParams and nothing is deleted. The stage must be empty (no deals) unless moveTo names another stage of the same pipeline (same won / lost setting, not a system stage): its live deals then move there and the stage is deleted in one transaction. A stage a soft-deleted deal still points at is refused. Pass expectedSettingsRevision (the settingsRevision from conduyt_get_settings_revision, read right before) to refuse the delete when settings changed since you read them; the answer is then { id, deleted: true, settingsRevision } with the revision after the delete. A refusal carries a machine-readable code in the error text and the server's message, never swallowed: stale_settings_revision (409), pipeline_archived (409), intake_target (409, enabled intake routes deals into this stage); other 409s (DPP status map, unattended-lead automations, deals that used the stage) have no code but name the cause.", {
131
+ strictInput(server.tool("conduyt_delete_stage", "Delete a stage from a pipeline (soft delete). Strict input: the guards are spelled moveTo and expectedSettingsRevision, any other spelling (move_to, expected_settings_revision, a typo) is InvalidParams and nothing is deleted. An EMPTY stage can always be deleted, even if deals once used it: the row is kept so deal history, activities, audit rows and reports keep its name, but the stage is hidden from every pipeline and stage list, GET / PATCH on it answer 404, there is NO restore in v1, and its name may be reused by a new stage. Its stage rule, appointment-outcome mappings and status metadata are removed with it. With moveTo (another stage of the same pipeline, same won / lost setting, not a system stage) its live deals move there and the stage is deleted in one transaction. Pass expectedSettingsRevision (the settingsRevision from conduyt_get_settings_revision, read right before) to refuse the delete when settings changed since you read them; the answer is then { id, deleted: true, settingsRevision } with the revision after the delete. A refusal is a 409 whose server message is surfaced verbatim (never swallowed, never retried): deals currently in the stage and no moveTo (\"Cannot delete stage: N deal(s) are in this stage...\"), \"Cannot delete stage: it is used by N active automation(s) (names). Change their stage or pause them first.\" (a published automation references the stage in an action, trigger, condition or wait-until), the DPP status map selects it, plus the coded refusals stale_settings_revision, pipeline_archived and intake_target (enabled intake routes deals into this stage); a moveTo that is not a valid target is a 400. Accepted window: a run parked or in flight before the delete is not tracked; at the step that targets the deleted stage it fails or is skipped with a clear reason in the execution log.", {
132
132
  pipelineId: z.string().describe("Pipeline UUID"),
133
133
  stageId: z.string().describe("Stage UUID to delete"),
134
134
  moveTo: z.string().uuid().optional().describe("Another stage of the same pipeline the live deals move to (sent as move_to)"),
@@ -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)"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt-mcp",
3
- "version": "4.78.0",
3
+ "version": "4.80.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,6 +1,6 @@
1
1
  {
2
- "generatedAt": "2026-10-09T04:00:29.593Z",
3
- "toolCount": 334,
2
+ "generatedAt": "2026-10-09T06:41:49.430Z",
3
+ "toolCount": 336,
4
4
  "totalEndpoints": 1066,
5
5
  "counts": {
6
6
  "covered": 299,
@@ -2165,6 +2165,7 @@
2165
2165
  "PATCH /api/v1/deals/:id": {
2166
2166
  "status": "covered",
2167
2167
  "tools": [
2168
+ "conduyt_pipeline_health_fix_deal",
2168
2169
  "conduyt_update_deal"
2169
2170
  ]
2170
2171
  },
@@ -4495,6 +4496,7 @@
4495
4496
  "POST /api/v1/tasks": {
4496
4497
  "status": "covered",
4497
4498
  "tools": [
4499
+ "conduyt_pipeline_health_fix_next_action",
4498
4500
  "conduyt_create_task"
4499
4501
  ]
4500
4502
  },