conduyt-mcp 4.79.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` |
@@ -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;
@@ -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.79.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
  },