conduyt-mcp 4.73.0 → 4.75.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
@@ -23,7 +23,10 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
23
23
  ### Deals
24
24
  | Tool | Description |
25
25
  |------|-------------|
26
- | `conduyt_list_deals` | List deals by pipeline, stage, or assignee |
26
+ | `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}}}`) |
27
+ | `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` |
28
+ | `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); other stages untouched; revision-aware, retried once on a 409 |
29
+ | `conduyt_get_deal_card_fields` / `conduyt_set_deal_card_fields` | Read / replace the kanban card fields (1-8, built-ins or `custom:<fieldKey>`) |
27
30
  | `conduyt_get_deal` | Get deal details with history, including custom-field values |
28
31
  | `conduyt_create_deal` | Create a deal with full attributes — currency (e.g. GBP), probability, priority, expected close date, contact/company, and structured custom-field values |
29
32
  | `conduyt_update_deal` | Move stage, change value/currency, set probability or close date, reassign, mark won/lost, or write custom-field values; optional `expectedUpdatedAt` pins the `updatedAt` you read, and a stale pin returns 409 `deal_changed` with the current `updatedAt` and writes nothing |
@@ -40,7 +43,7 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
40
43
  |------|-------------|
41
44
  | `conduyt_list_pipelines` | List pipelines with stages |
42
45
  | `conduyt_create_pipeline` | Create a pipeline, optionally with its full set of stages (no stage-count cap; up to 50) |
43
- | `conduyt_update_pipeline` | Rename a pipeline, change its description, or set it as default |
46
+ | `conduyt_update_pipeline` | Rename a pipeline, change its description, set it as default, or set `automationOnly` (only automations, API keys and owners / admins move or edit its cards; owner / admin key) |
44
47
  | `conduyt_delete_pipeline` | Delete a pipeline (move/remove its deals first) |
45
48
  | `conduyt_create_stage` | Append a stage to a pipeline (e.g. 'On Hold', 'Declined/Dead') |
46
49
  | `conduyt_update_stage` | Rename, recolor, reorder, or change won/lost flags on a stage |
@@ -152,6 +155,13 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
152
155
  | `conduyt_update_webhook` | Update URL, events, or enable/disable |
153
156
  | `conduyt_delete_webhook` | Delete a webhook endpoint |
154
157
 
158
+ ### WordPress forms
159
+ | Tool | Description |
160
+ |------|-------------|
161
+ | `conduyt_list_form_intakes` | List the intake addresses: every active one with its full URL, then the revoked history (newest 200) |
162
+ | `conduyt_create_form_intake` | Create an intake address for one Gravity Forms / WPForms / Elementor form (returns its full URL) |
163
+ | `conduyt_revoke_form_intake` | Revoke an intake address (the row is kept for history) |
164
+
155
165
  ### Notifications
156
166
  | Tool | Description |
157
167
  |------|-------------|
package/dist/client.d.ts CHANGED
@@ -11,12 +11,16 @@ export declare class ConduytApiError extends Error {
11
11
  readonly status: number | null;
12
12
  readonly code: "http" | "timeout" | "response_too_large" | "network";
13
13
  readonly retryable: boolean;
14
+ /** The response's Retry-After, in seconds, when it sent one (a busy 503 / a 429): surfaced to the agent, never used
15
+ * to replay a mutation. */
16
+ readonly retryAfterSeconds: number | null;
14
17
  constructor(opts: {
15
18
  message: string;
16
19
  status?: number | null;
17
20
  code: ConduytApiError["code"];
18
21
  retryable: boolean;
19
22
  cause?: unknown;
23
+ retryAfterSeconds?: number | null;
20
24
  });
21
25
  }
22
26
  /** Per-call overrides for legitimately long/large operations (GDPR export,
package/dist/client.js CHANGED
@@ -10,12 +10,16 @@ export class ConduytApiError extends Error {
10
10
  status;
11
11
  code;
12
12
  retryable;
13
+ /** The response's Retry-After, in seconds, when it sent one (a busy 503 / a 429): surfaced to the agent, never used
14
+ * to replay a mutation. */
15
+ retryAfterSeconds;
13
16
  constructor(opts) {
14
17
  super(opts.message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
15
18
  this.name = "ConduytApiError";
16
19
  this.status = opts.status ?? null;
17
20
  this.code = opts.code;
18
21
  this.retryable = opts.retryable;
22
+ this.retryAfterSeconds = opts.retryAfterSeconds ?? null;
19
23
  }
20
24
  }
21
25
  /** Bounded defaults; each overridable via config / env (see src/index.ts). */
@@ -163,11 +167,14 @@ export class ConduytClient {
163
167
  if (text)
164
168
  msg = text.slice(0, 500);
165
169
  }
170
+ const retryAfterRaw = res.headers.get("retry-after");
171
+ const retryAfter = retryAfterRaw === null || retryAfterRaw.trim() === "" ? NaN : Number(retryAfterRaw);
166
172
  return new ConduytApiError({
167
173
  message: `Conduyt API ${res.status}: ${msg}`,
168
174
  status: res.status,
169
175
  code: "http",
170
176
  retryable: RETRYABLE_STATUSES.has(res.status),
177
+ retryAfterSeconds: Number.isFinite(retryAfter) && retryAfter >= 0 ? retryAfter : null,
171
178
  });
172
179
  }
173
180
  retryDelayMs(res, attemptIndex) {
package/dist/index.js CHANGED
@@ -21,6 +21,8 @@ import { registerCalendarTools } from "./tools/calendar.js";
21
21
  import { registerFormTools } from "./tools/forms.js";
22
22
  import { registerInvoiceTools } from "./tools/invoices.js";
23
23
  import { registerWebhookTools } from "./tools/webhooks.js";
24
+ import { registerFormIntakeTools } from "./tools/form-intakes.js";
25
+ import { registerBoardSettingsTools } from "./tools/board-settings.js";
24
26
  import { registerNotificationTools } from "./tools/notifications.js";
25
27
  import { registerCustomFieldTools } from "./tools/custom-fields.js";
26
28
  import { registerSmartListTools } from "./tools/smart-lists.js";
@@ -93,6 +95,8 @@ registerCalendarTools(server, client);
93
95
  registerFormTools(server, client);
94
96
  registerInvoiceTools(server, client);
95
97
  registerWebhookTools(server, client);
98
+ registerFormIntakeTools(server, client);
99
+ registerBoardSettingsTools(server, client);
96
100
  registerNotificationTools(server, client);
97
101
  registerCustomFieldTools(server, client);
98
102
  registerSmartListTools(server, client);
@@ -26,7 +26,7 @@ export function registerAutomationTools(server, client) {
26
26
  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
27
  n8nWorkflowId: z.string().optional().describe("n8n workflow ID (required when kind='n8n')"),
28
28
  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."),
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)."),
30
30
  graphVersion: z.number().optional().describe("Graph version — 1 (legacy array) or 2 (node graph). Default 1"),
31
31
  schedule: z.string().optional().describe("5-field cron expression (required if triggerEvent='scheduled')"),
32
32
  scheduleTimezone: z.string().optional().describe("IANA timezone for scheduled triggers (e.g. 'America/Los_Angeles')"),
@@ -48,7 +48,7 @@ export function registerAutomationTools(server, client) {
48
48
  .optional()
49
49
  .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
50
  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'})"),
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)."),
52
52
  schedule: z.string().optional().describe("Updated cron schedule"),
53
53
  scheduleTimezone: z.string().optional().describe("Updated timezone"),
54
54
  stopOnResponse: z.boolean().optional().describe("Stop drip if contact responds"),
@@ -0,0 +1,14 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ConduytClient } from "../client.js";
3
+ /**
4
+ * Deal board settings (app 10-08, BDA board): per-stage rules (settings.pipelineStageRules[pipelineId][stageId]) and the
5
+ * kanban card fields (settings.dealCardFields). The validators mirror the API's settingsPatchSchema entries, which are
6
+ * the source of truth: the built-in names below are the app's lists, `custom:<fieldKey>` names one of the account's DEAL
7
+ * custom fields (the API checks it exists at save), and the stage rule object is strict (no other keys).
8
+ */
9
+ export declare const DEAL_STAGE_REQUIRED_FIELDS: readonly ["value", "expectedCloseDate", "contactId", "assignedTo", "lostReason"];
10
+ export declare const DEAL_CARD_FIELDS: readonly ["value", "contact", "phones", "company", "owner", "priority", "expectedCloseDate", "daysInStage", "nextAction", "tags", "stageHealth", "source"];
11
+ export declare const MAX_DEAL_CARD_FIELDS = 8;
12
+ export declare const MAX_STAGE_REQUIRED_FIELDS = 25;
13
+ export declare const MAX_STAGE_REQUIRED_DOCUMENTS = 20;
14
+ export declare function registerBoardSettingsTools(server: McpServer, client: ConduytClient): void;
@@ -0,0 +1,71 @@
1
+ import { z } from "zod";
2
+ import { formatResult } from "../client.js";
3
+ import { strictInput } from "./strict.js";
4
+ import { patchGeneralSettingsMerged } from "./lifecycle.js";
5
+ /**
6
+ * Deal board settings (app 10-08, BDA board): per-stage rules (settings.pipelineStageRules[pipelineId][stageId]) and the
7
+ * kanban card fields (settings.dealCardFields). The validators mirror the API's settingsPatchSchema entries, which are
8
+ * the source of truth: the built-in names below are the app's lists, `custom:<fieldKey>` names one of the account's DEAL
9
+ * custom fields (the API checks it exists at save), and the stage rule object is strict (no other keys).
10
+ */
11
+ export const DEAL_STAGE_REQUIRED_FIELDS = ["value", "expectedCloseDate", "contactId", "assignedTo", "lostReason"];
12
+ export const DEAL_CARD_FIELDS = ["value", "contact", "phones", "company", "owner", "priority", "expectedCloseDate", "daysInStage", "nextAction", "tags", "stageHealth", "source"];
13
+ export const MAX_DEAL_CARD_FIELDS = 8;
14
+ export const MAX_STAGE_REQUIRED_FIELDS = 25;
15
+ export const MAX_STAGE_REQUIRED_DOCUMENTS = 20;
16
+ const CUSTOM_FIELD_RE = /^custom:[a-zA-Z_][a-zA-Z0-9_]*$/;
17
+ const RULE_KEYS = ["requiredFields", "requiredDocuments", "lostReasonLabel", "lostReasonOptions"];
18
+ const requiredField = z.union([
19
+ z.enum(DEAL_STAGE_REQUIRED_FIELDS),
20
+ z.string().regex(CUSTOM_FIELD_RE, "custom required fields are custom:<deal custom field key>"),
21
+ ]);
22
+ const cardField = z.union([
23
+ z.enum(DEAL_CARD_FIELDS),
24
+ z.string().regex(CUSTOM_FIELD_RE, "custom card fields are custom:<deal custom field key>"),
25
+ ]);
26
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
27
+ export function registerBoardSettingsTools(server, client) {
28
+ strictInput(server.tool("conduyt_get_stage_rules", "Read the per-stage rules (settings.pipelineStageRules) as { pipelineId: { stageId: { requiredFields, requiredDocuments, lostReasonLabel, lostReasonOptions } } }, optionally for one pipeline or one stage. A deal may enter a stage only when its requiredFields are filled (built-ins value, expectedCloseDate, contactId, assignedTo, lostReason, or custom:<fieldKey> for a deal custom field) and it has at least requiredDocuments documents attached. Set one stage with conduyt_set_stage_rules. Pipelines your key cannot see are left out.", {
29
+ pipelineId: z.string().uuid().optional().describe("Only this pipeline's stage rules"),
30
+ stageId: z.string().uuid().optional().describe("Only this stage's rule (needs pipelineId)"),
31
+ }, async ({ pipelineId, stageId }) => {
32
+ if (stageId && !pipelineId)
33
+ throw new Error("stageId needs pipelineId.");
34
+ const result = (await client.get("/api/v1/settings"));
35
+ const all = isObj(result?.data?.pipelineStageRules) ? result.data.pipelineStageRules : {};
36
+ let out = all;
37
+ if (pipelineId) {
38
+ const stages = isObj(all[pipelineId]) ? all[pipelineId] : {};
39
+ out = { [pipelineId]: stageId ? (stages[stageId] !== undefined ? { [stageId]: stages[stageId] } : {}) : stages };
40
+ }
41
+ return formatResult({ data: { pipelineStageRules: out } });
42
+ }));
43
+ strictInput(server.tool("conduyt_set_stage_rules", "Set the rule of ONE stage (settings.pipelineStageRules[pipelineId][stageId]; admin key, settings:edit). Only the fields you pass change: they are merged into that stage's current rule, and every other stage and pipeline is left exactly as stored. requiredFields REPLACES the stage's list (max 25 entries; built-ins value, expectedCloseDate, contactId, assignedTo, lostReason, or custom:<fieldKey> for one of the account's DEAL custom fields; [] clears it); requiredDocuments is the number of documents that must be attached before a deal may enter the stage (integer 0-20); lostReasonLabel (1-60 chars) and lostReasonOptions (max 50, trimmed and de-duplicated by the API) configure the stage's lost-reason prompt. Read-merge-write with the settings revision; if another save lands first it is re-read and retried once, then the conflict is returned with the current settings. An unknown custom field key, pipeline or stage is refused by the API.", {
44
+ pipelineId: z.string().uuid().describe("Pipeline UUID"),
45
+ stageId: z.string().uuid().describe("Stage UUID within that pipeline"),
46
+ requiredFields: z.array(requiredField).max(MAX_STAGE_REQUIRED_FIELDS).optional().describe("Fields a deal must have filled to enter the stage: value, expectedCloseDate, contactId, assignedTo, lostReason, or custom:<deal custom field key>. Replaces the list."),
47
+ requiredDocuments: z.number().int().min(0).max(MAX_STAGE_REQUIRED_DOCUMENTS).optional().describe("Documents that must be attached to the deal before it enters the stage (integer 0-20)"),
48
+ lostReasonLabel: z.string().trim().min(1).max(60).optional().describe("Label of the stage's lost-reason prompt, e.g. 'Disqualification Reason'"),
49
+ lostReasonOptions: z.array(z.string()).max(50).optional().describe("Dropdown options of the lost-reason prompt (replaces the list)"),
50
+ }, async ({ pipelineId, stageId, ...given }) => {
51
+ const change = Object.fromEntries(Object.entries(given).filter(([, v]) => v !== undefined));
52
+ if (Object.keys(change).length === 0) {
53
+ throw new Error("Nothing to set. Pass at least one of requiredFields, requiredDocuments, lostReasonLabel, lostReasonOptions.");
54
+ }
55
+ return patchGeneralSettingsMerged(client, (settings) => {
56
+ const rules = isObj(settings.pipelineStageRules) ? settings.pipelineStageRules : {};
57
+ const stages = isObj(rules[pipelineId]) ? rules[pipelineId] : {};
58
+ const current = isObj(stages[stageId]) ? stages[stageId] : {};
59
+ const kept = Object.fromEntries(RULE_KEYS.filter((k) => current[k] !== undefined).map((k) => [k, current[k]]));
60
+ // the API merges per pipeline and replaces that pipeline's stage map: send the whole map with this stage changed
61
+ return { pipelineStageRules: { [pipelineId]: { ...stages, [stageId]: { ...kept, ...change } } } };
62
+ });
63
+ }));
64
+ server.tool("conduyt_get_deal_card_fields", "Read the fields shown on each kanban deal card, in order (settings.dealCardFields; max 8): built-in names (value, contact, phones, company, owner, priority, expectedCloseDate, daysInStage, nextAction, tags, stageHealth, source) and custom:<fieldKey> entries for the account's deal custom fields. Change with conduyt_set_deal_card_fields.", {}, async () => {
65
+ const result = (await client.get("/api/v1/settings"));
66
+ return formatResult({ data: { dealCardFields: result?.data?.dealCardFields ?? null } });
67
+ });
68
+ strictInput(server.tool("conduyt_set_deal_card_fields", "Set the fields shown on each kanban deal card, in order (settings.dealCardFields; admin key, settings:edit). REPLACES the list: 1 to 8 entries, each a built-in (value, contact, phones, company, owner, priority, expectedCloseDate, daysInStage, nextAction, tags, stageHealth, source) or custom:<fieldKey> for one of the account's DEAL custom fields (the API refuses an unknown key). Read the current list with conduyt_get_deal_card_fields. Saved with the settings revision; a stale revision is re-read and retried once.", {
69
+ fields: z.array(cardField).min(1).max(MAX_DEAL_CARD_FIELDS).describe("The complete ordered list, 1-8 entries"),
70
+ }, async ({ fields }) => patchGeneralSettingsMerged(client, () => ({ dealCardFields: fields }))));
71
+ }
@@ -1,3 +1,5 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { ConduytClient } from "../client.js";
3
+ /** The six Pipeline health checks (GET /deals health_check, GET /deals/inspection check). */
4
+ export declare const PIPELINE_HEALTH_CHECKS: readonly ["high_value_idle", "stale", "unassigned", "needs_action", "missing_close_date", "missing_contact"];
3
5
  export declare function registerDealTools(server: McpServer, client: ConduytClient): void;
@@ -1,7 +1,12 @@
1
1
  import { z } from "zod";
2
2
  import { formatResult } from "../client.js";
3
+ import { strictInput } from "./strict.js";
4
+ /** The six Pipeline health checks (GET /deals health_check, GET /deals/inspection check). */
5
+ export const PIPELINE_HEALTH_CHECKS = ["high_value_idle", "stale", "unassigned", "needs_action", "missing_close_date", "missing_contact"];
6
+ const healthCheckSchema = z.enum(PIPELINE_HEALTH_CHECKS);
7
+ const HEALTH_ERRORS = "A 400 carries a machine-readable code in the error text: health_cursor_invalid (forged cursor, or cursor with offset), health_cursor_context (cursor from another list or the view/thresholds changed), health_cursor_blank, health_cursor_needs_check (cursor without a check), health_filter_conflict (a filter given twice with different values).";
3
8
  export function registerDealTools(server, client) {
4
- server.tool("conduyt_list_deals", "List deals with filtering by pipeline, stage, assignee, or status. Returns deal title, value, stage, contact, and dates. Filter by pipeline/stage NAME (the same names you import with) or by UUID — both camelCase (pipelineName) and snake_case (pipeline_name) argument names are accepted. An unknown name returns a clear error, never the whole account.", {
9
+ strictInput(server.tool("conduyt_list_deals", "List deals with filtering by pipeline, stage, assignee, or status. Returns deal title, value, stage, contact, and dates. Filter by pipeline/stage NAME (the same names you import with) or by UUID — both camelCase (pipelineName) and snake_case (pipeline_name) argument names are accepted. An unknown name returns a clear error, never the whole account. Each deal row carries hasNextStep (boolean: an open task or a booked appointment from a day ago on; the Needs next action rule's own answer) and nextAppointmentAt (ISO string or null; null unless your key can view the calendar, even when hasNextStep is true). PIPELINE HEALTH: pass health_summary=true to get the Pipeline health strip for the same filtered set instead of the deal list: { totalOpenDeals, atRiskValue, checks[] (key, label, severity, color, rule, count, value; the six checks high_value_idle, stale, unassigned, needs_action, missing_close_date, missing_contact, ordered by money at risk), deals }. Add health_check to also get that check's first page of deals (highest value first) as deals: { check, offset, items, hasMore, nextCursor, total }; deals is null without a check. Page with health_cursor (the previous nextCursor, passed back UNCHANGED: an opaque signed token) or health_offset, and health_rows_only=true to get just the page without the summary: the answer is {data:{deals:{check, offset, items, hasMore, nextCursor}}} (the page sits under data.deals). " + HEALTH_ERRORS, {
5
10
  pipeline: z.string().optional().describe("Filter by pipeline UUID (canonical). Aliases: pipelineId, pipeline_id."),
6
11
  pipelineId: z.string().optional().describe("Filter by pipeline UUID (alias of pipeline)."),
7
12
  pipeline_id: z.string().optional().describe("snake_case alias of pipelineId."),
@@ -17,6 +22,12 @@ export function registerDealTools(server, client) {
17
22
  search: z.string().optional().describe("Search deal titles"),
18
23
  page: z.number().optional().describe("Page number (default 1)"),
19
24
  per_page: z.number().optional().describe("Results per page (default 25, max 100)"),
25
+ health_summary: z.boolean().optional().describe("true: answer the Pipeline health shape (totalOpenDeals, atRiskValue, checks[], deals) for the filtered set instead of the deal list."),
26
+ health_check: healthCheckSchema.optional().describe("With health_summary: one check whose deals are added to `deals`, highest value first."),
27
+ health_limit: z.number().int().min(1).max(100).optional().describe("With health_summary and health_check: deals per page, 1-100 (default 5)."),
28
+ health_offset: z.number().int().min(0).optional().describe("With health_summary and health_check: deals to skip (default 0). Prefer health_cursor; never combine the two."),
29
+ health_cursor: z.string().optional().describe("With health_summary and health_check: continue after this cursor, the nextCursor of the previous page, passed back UNCHANGED (opaque signed token bound to the list; a blank, forged or foreign one is a 400 with its code)."),
30
+ health_rows_only: z.boolean().optional().describe("With health_summary and health_check: true answers only the requested page of that check's deals, without the summary (for the next page of a list); the answer is {data:{deals:{check, offset, items, hasMore, nextCursor}}}."),
20
31
  }, async (a) => {
21
32
  const params = new URLSearchParams();
22
33
  // Accept every reasonable casing of each filter (canonical bare name +
@@ -62,10 +73,46 @@ export function registerDealTools(server, client) {
62
73
  params.set("page", String(page));
63
74
  if (per_page)
64
75
  params.set("per_page", String(per_page));
76
+ // Pipeline health strip: sent only when given, the cursor byte for byte (it is signed; never trimmed or re-encoded by hand)
77
+ if (a.health_summary !== undefined)
78
+ params.set("health_summary", String(a.health_summary));
79
+ if (a.health_check !== undefined)
80
+ params.set("health_check", a.health_check);
81
+ if (a.health_limit !== undefined)
82
+ params.set("health_limit", String(a.health_limit));
83
+ if (a.health_offset !== undefined)
84
+ params.set("health_offset", String(a.health_offset));
85
+ if (a.health_cursor !== undefined)
86
+ params.set("health_cursor", a.health_cursor);
87
+ if (a.health_rows_only !== undefined)
88
+ params.set("health_rows_only", String(a.health_rows_only));
65
89
  const result = await client.get(`/api/v1/deals?${params}`);
66
90
  return formatResult(result);
67
- });
68
- 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).", {
91
+ }));
92
+ strictInput(server.tool("conduyt_pipeline_health", "Pipeline health for one pipeline (or every visible active pipeline) over its OPEN deals (GET /api/v1/deals/inspection): { totalOpenDeals, atRiskValue (total value of the High-value idle deals), checks[] (key, label, severity, color, rule, count, value; the six checks high_value_idle, stale, unassigned, needs_action, missing_close_date, missing_contact, ordered by money at risk), deals, summary (count per check), labels (label per check) }. With check, deals is that check's page { check, offset, items, hasMore, nextCursor, total } (highest value first, each item with its issues); without it deals is null. Page with cursor (the previous nextCursor, passed back UNCHANGED) or offset; never both. For the board's other filters (stage, assignee, saved view) use conduyt_list_deals with health_summary=true. " + HEALTH_ERRORS, {
93
+ pipeline: z.string().optional().describe("Pipeline UUID; omit for every visible active pipeline."),
94
+ check: healthCheckSchema.optional().describe("Adds `deals` for that check."),
95
+ limit: z.number().int().min(1).max(100).optional().describe("With check: deals per page, 1-100 (default 5)."),
96
+ offset: z.number().int().min(0).optional().describe("With check: deals to skip (default 0)."),
97
+ cursor: z.string().optional().describe("With check: continue after this cursor, the nextCursor of the previous page, passed back UNCHANGED. Not combined with offset."),
98
+ }, async ({ pipeline, check, limit, offset, cursor }) => {
99
+ const params = new URLSearchParams();
100
+ if (pipeline !== undefined)
101
+ params.set("pipeline", pipeline);
102
+ if (check !== undefined)
103
+ params.set("check", check);
104
+ if (limit !== undefined)
105
+ params.set("limit", String(limit));
106
+ if (offset !== undefined)
107
+ params.set("offset", String(offset));
108
+ if (cursor !== undefined)
109
+ params.set("cursor", cursor);
110
+ const qs = params.toString();
111
+ // literal paths on purpose: the coverage scanner reads the first string after client.get(, and a template with a nested template hid this endpoint from it
112
+ const result = qs ? await client.get("/api/v1/deals/inspection?" + qs) : await client.get("/api/v1/deals/inspection");
113
+ return formatResult(result);
114
+ }));
115
+ 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.", {
69
116
  id: z.string().describe("Deal UUID"),
70
117
  }, async ({ id }) => {
71
118
  const result = await client.get(`/api/v1/deals/${id}`);
@@ -0,0 +1,22 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ConduytClient } from "../client.js";
3
+ /**
4
+ * WordPress forms intake addresses (app 10-07, tri-surface parity): a per-form URL that Gravity Forms (Webhooks
5
+ * Add-On), WPForms (Webhooks addon) or Elementor Pro (Webhook action) posts submissions to; each submission becomes a
6
+ * contact. GET / POST /api/v1/integrations/wordpress-forms/intakes, DELETE /api/v1/integrations/wordpress-forms/intakes/{id}.
7
+ * Listing and creating need the "form-intakes" AND "contacts" scopes on a write or admin tier key (the URLs are
8
+ * credentials that create and edit contacts); revoking needs "form-intakes". Create and list answer each active intake's
9
+ * full `url` (the credential, to paste into the form plugin); a revoked intake's `url` is null.
10
+ */
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
+ 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 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
+ export declare function intakeToolError(err: unknown, requirement: string): {
16
+ content: Array<{
17
+ type: "text";
18
+ text: string;
19
+ }>;
20
+ isError: true;
21
+ };
22
+ export declare function registerFormIntakeTools(server: McpServer, client: ConduytClient): void;
@@ -0,0 +1,72 @@
1
+ import { z } from "zod";
2
+ import { ConduytApiError, formatResult, formatToolError } from "../client.js";
3
+ /**
4
+ * WordPress forms intake addresses (app 10-07, tri-surface parity): a per-form URL that Gravity Forms (Webhooks
5
+ * Add-On), WPForms (Webhooks addon) or Elementor Pro (Webhook action) posts submissions to; each submission becomes a
6
+ * contact. GET / POST /api/v1/integrations/wordpress-forms/intakes, DELETE /api/v1/integrations/wordpress-forms/intakes/{id}.
7
+ * Listing and creating need the "form-intakes" AND "contacts" scopes on a write or admin tier key (the URLs are
8
+ * credentials that create and edit contacts); revoking needs "form-intakes". Create and list answer each active intake's
9
+ * full `url` (the credential, to paste into the form plugin); a revoked intake's `url` is null.
10
+ */
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
+ 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 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
+ export function intakeToolError(err, requirement) {
16
+ if (err instanceof ConduytApiError) {
17
+ if (err.status === 403)
18
+ return formatToolError(`${err.message}. This tool needs ${requirement}.`);
19
+ if (err.status === 503 && err.retryAfterSeconds !== null) {
20
+ return formatToolError(`busy; retry after ${err.retryAfterSeconds} s (${err.message})`);
21
+ }
22
+ }
23
+ throw err;
24
+ }
25
+ export function registerFormIntakeTools(server, client) {
26
+ 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
+ LIST_AND_CREATE_REQUIREMENT +
28
+ " (the URLs are credentials that create and edit contacts).", {}, async () => {
29
+ try {
30
+ const result = await client.get("/api/v1/integrations/wordpress-forms/intakes");
31
+ return formatResult(result);
32
+ }
33
+ catch (err) {
34
+ return intakeToolError(err, LIST_AND_CREATE_REQUIREMENT);
35
+ }
36
+ });
37
+ server.tool("conduyt_create_form_intake", "Create a WordPress form intake address for one form. Answers the row with `url`, the full address (the credential; conduyt_list_form_intakes shows it too while the intake is active). Paste it into the form plugin's webhook URL (Gravity Forms Webhooks Add-On, WPForms Webhooks, Elementor Pro Webhook action). Each submission then creates or updates a contact (by email, then phone), applies defaultTag / source / assignedTo, and adds 'Submitted <form name>' with the message and fields to the contact's timeline. At most 100 active addresses per account; a busy answer (503) says how many seconds to wait before retrying. Requires " +
38
+ LIST_AND_CREATE_REQUIREMENT +
39
+ ".", {
40
+ name: z.string().trim().min(1).max(120).describe("What the form is, e.g. 'Contact page, Smith Roofing'"),
41
+ defaultTag: z.string().max(100).nullable().optional().describe("Tag NAME applied to every submission unless the submission sends its own tags (created on first use)"),
42
+ source: z.string().max(64).optional().describe("Contact source for submissions that do not send one (default 'wordpress')"),
43
+ assignedTo: z.string().uuid().nullable().optional().describe("User id (an active member) who owns new contacts; omit or null = lead routing decides"),
44
+ }, async ({ name, defaultTag, source, assignedTo }) => {
45
+ try {
46
+ // the body as ONE literal: the contract gate reads the request fields from it
47
+ const result = await client.post("/api/v1/integrations/wordpress-forms/intakes", {
48
+ name,
49
+ ...(defaultTag !== undefined ? { defaultTag } : {}),
50
+ ...(source !== undefined ? { source } : {}),
51
+ ...(assignedTo !== undefined ? { assignedTo } : {}),
52
+ });
53
+ return formatResult(result);
54
+ }
55
+ catch (err) {
56
+ return intakeToolError(err, LIST_AND_CREATE_REQUIREMENT);
57
+ }
58
+ });
59
+ server.tool("conduyt_revoke_form_intake", "Revoke a WordPress form intake address: submissions to it answer 404 from now on and create nothing. The row is kept (status 'revoked', revokedAt) for history; revoking a revoked address answers it unchanged. Requires " +
60
+ REVOKE_REQUIREMENT +
61
+ ".", {
62
+ id: z.string().uuid().describe("Intake UUID (from conduyt_list_form_intakes)"),
63
+ }, async ({ id }) => {
64
+ try {
65
+ const result = await client.del(`/api/v1/integrations/wordpress-forms/intakes/${id}`);
66
+ return formatResult(result);
67
+ }
68
+ catch (err) {
69
+ return intakeToolError(err, REVOKE_REQUIREMENT);
70
+ }
71
+ });
72
+ }
@@ -8,4 +8,16 @@ export declare function patchGeneralSettings(client: ConduytClient, body: Record
8
8
  text: string;
9
9
  }>;
10
10
  }>;
11
+ /**
12
+ * Read-merge-write for a settings change that depends on what is stored (a per-stage rule inside settings.pipelineStageRules):
13
+ * `build` derives the PATCH body from the settings just read, and the save carries that read's settingsRevision. A stale
14
+ * revision (409: another save landed first) re-reads and re-derives ONCE; a second 409 hands back the conflict with the
15
+ * current settings, like patchGeneralSettings. Anything other than a 409 is thrown. `build` may throw a plain Error to refuse.
16
+ */
17
+ export declare function patchGeneralSettingsMerged(client: ConduytClient, build: (settings: Record<string, unknown>) => Record<string, unknown>): Promise<{
18
+ content: Array<{
19
+ type: "text";
20
+ text: string;
21
+ }>;
22
+ }>;
11
23
  export declare function registerLifecycleTools(server: McpServer, client: ConduytClient): void;
@@ -19,6 +19,34 @@ export async function patchGeneralSettings(client, body) {
19
19
  throw err;
20
20
  }
21
21
  }
22
+ /**
23
+ * Read-merge-write for a settings change that depends on what is stored (a per-stage rule inside settings.pipelineStageRules):
24
+ * `build` derives the PATCH body from the settings just read, and the save carries that read's settingsRevision. A stale
25
+ * revision (409: another save landed first) re-reads and re-derives ONCE; a second 409 hands back the conflict with the
26
+ * current settings, like patchGeneralSettings. Anything other than a 409 is thrown. `build` may throw a plain Error to refuse.
27
+ */
28
+ export async function patchGeneralSettingsMerged(client, build) {
29
+ let lastRevision = null;
30
+ for (let attempt = 0; attempt < 2; attempt += 1) {
31
+ const read = (await client.get("/api/v1/settings"));
32
+ const settings = read?.data ?? {};
33
+ const revision = typeof settings.settingsRevision === "string" ? settings.settingsRevision : null;
34
+ lastRevision = revision;
35
+ const body = build(settings);
36
+ try {
37
+ return formatResult(await client.patch("/api/v1/settings", { ...body, ...(revision ? { expectedSettingsRevision: revision } : {}) }));
38
+ }
39
+ catch (err) {
40
+ if (!(err instanceof ConduytApiError && err.status === 409))
41
+ throw err;
42
+ if (attempt === 1) {
43
+ const current = (await client.get("/api/v1/settings"));
44
+ return formatResult({ conflict: true, message: err.message, expectedSettingsRevision: lastRevision, current: current?.data ?? null, hint: "the account settings changed twice while this change was being saved — read them again and call again" });
45
+ }
46
+ }
47
+ }
48
+ throw new Error("unreachable");
49
+ }
22
50
  /**
23
51
  * Lifecycle v2 settings (master lead status + intake deals). The master lead
24
52
  * is the acquisition truth (first-write-wins source, one lifecycle status);
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { formatResult } from "../client.js";
3
+ import { strictInput } from "./strict.js";
3
4
  export function registerPipelineTools(server, client) {
4
5
  server.tool("conduyt_list_pipelines", "List all sales pipelines with their stages. Use this to discover pipeline and stage UUIDs before creating or moving deals.", {}, async () => {
5
6
  const result = await client.get("/api/v1/pipelines");
@@ -35,15 +36,16 @@ export function registerPipelineTools(server, client) {
35
36
  const result = await client.post("/api/v1/pipelines", body);
36
37
  return formatResult(result);
37
38
  });
38
- server.tool("conduyt_update_pipeline", "Update a pipeline's name, description, or default flag. To add/edit/remove stages, use the stage tools.", {
39
+ strictInput(server.tool("conduyt_update_pipeline", "Update a pipeline's name, description, default flag, or automationOnly flag (owner/admin key only). An automation-only pipeline's cards are moved and edited only by automations, API keys and owners / admins; a member's edit is refused with 403 pipeline_automation_only. conduyt_list_pipelines returns the flag on each pipeline. To add/edit/remove stages, use the stage tools.", {
39
40
  id: z.string().describe("Pipeline UUID"),
40
41
  name: z.string().optional().describe("Updated pipeline name"),
41
42
  description: z.string().optional().describe("Updated description"),
42
43
  isDefault: z.boolean().optional().describe("Set as the account's default pipeline"),
44
+ automationOnly: z.boolean().optional().describe("Automation-only pipeline: only automations, API keys and owners / admins move or edit its cards; a member's edit is refused with 403 pipeline_automation_only. Owner / admin only."),
43
45
  }, async ({ id, ...updates }) => {
44
46
  const result = await client.patch(`/api/v1/pipelines/${id}`, updates);
45
47
  return formatResult(result);
46
- });
48
+ }));
47
49
  server.tool("conduyt_delete_pipeline", "Delete a pipeline. Deals in the pipeline must be moved or removed first. Cannot delete the default or an archived pipeline.", {
48
50
  id: z.string().describe("Pipeline UUID to delete"),
49
51
  }, async ({ id }) => {
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The SDK wraps a tool's raw Zod shape in a NON-strict object, so an argument the tool does not declare (a typo, or a
3
+ * field the API does not take, e.g. lostReasonAllowOther) is silently stripped and the call "succeeds" without it. For a
4
+ * tool where that would widen or drop a change, `strictInput(server.tool(...))` swaps the registered input schema for
5
+ * its strict form: an unknown key is an InvalidParams error and the advertised JSON schema says additionalProperties: false.
6
+ * (The SDK's server.tool() only takes a raw shape, so the swap is done on the registered tool.) A server stub that
7
+ * returns nothing is left alone.
8
+ */
9
+ export declare function strictInput<T>(tool: T): T;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The SDK wraps a tool's raw Zod shape in a NON-strict object, so an argument the tool does not declare (a typo, or a
3
+ * field the API does not take, e.g. lostReasonAllowOther) is silently stripped and the call "succeeds" without it. For a
4
+ * tool where that would widen or drop a change, `strictInput(server.tool(...))` swaps the registered input schema for
5
+ * its strict form: an unknown key is an InvalidParams error and the advertised JSON schema says additionalProperties: false.
6
+ * (The SDK's server.tool() only takes a raw shape, so the swap is done on the registered tool.) A server stub that
7
+ * returns nothing is left alone.
8
+ */
9
+ export function strictInput(tool) {
10
+ const t = tool;
11
+ if (t && typeof t.inputSchema?.strict === "function")
12
+ t.inputSchema = t.inputSchema.strict();
13
+ return tool;
14
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt-mcp",
3
- "version": "4.73.0",
3
+ "version": "4.75.0",
4
4
  "mcpName": "com.conduyt/mcp",
5
5
  "description": "MCP server for Conduyt CRM — expose CRM operations as AI-accessible tools",
6
6
  "type": "module",
@@ -1,11 +1,11 @@
1
1
  {
2
- "generatedAt": "2026-10-04T13:54:51.239Z",
3
- "toolCount": 299,
4
- "totalEndpoints": 1043,
2
+ "generatedAt": "2026-10-08T19:42:42.712Z",
3
+ "toolCount": 307,
4
+ "totalEndpoints": 1048,
5
5
  "counts": {
6
- "covered": 273,
7
- "excluded": 317,
8
- "planned": 453,
6
+ "covered": 277,
7
+ "excluded": 319,
8
+ "planned": 452,
9
9
  "uncategorized": 0
10
10
  },
11
11
  "matrix": {
@@ -1773,6 +1773,10 @@
1773
1773
  "status": "excluded",
1774
1774
  "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
1775
1775
  },
1776
+ "GET /api/v1/cron/process-google-ads-conversions": {
1777
+ "status": "excluded",
1778
+ "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
1779
+ },
1776
1780
  "GET /api/v1/cron/process-import-side-effects": {
1777
1781
  "status": "excluded",
1778
1782
  "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
@@ -2206,8 +2210,10 @@
2206
2210
  "reason": "deal subroutes beyond core CRUD (history, files, links)"
2207
2211
  },
2208
2212
  "GET /api/v1/deals/inspection": {
2209
- "status": "planned",
2210
- "reason": "deal subroutes beyond core CRUD (history, files, links)"
2213
+ "status": "covered",
2214
+ "tools": [
2215
+ "conduyt_pipeline_health"
2216
+ ]
2211
2217
  },
2212
2218
  "POST /api/v1/deals/probabilities": {
2213
2219
  "status": "planned",
@@ -2997,6 +3003,24 @@
2997
3003
  "status": "planned",
2998
3004
  "reason": "integration management"
2999
3005
  },
3006
+ "GET /api/v1/integrations/wordpress-forms/intakes": {
3007
+ "status": "covered",
3008
+ "tools": [
3009
+ "conduyt_list_form_intakes"
3010
+ ]
3011
+ },
3012
+ "POST /api/v1/integrations/wordpress-forms/intakes": {
3013
+ "status": "covered",
3014
+ "tools": [
3015
+ "conduyt_create_form_intake"
3016
+ ]
3017
+ },
3018
+ "DELETE /api/v1/integrations/wordpress-forms/intakes/:id": {
3019
+ "status": "covered",
3020
+ "tools": [
3021
+ "conduyt_revoke_form_intake"
3022
+ ]
3023
+ },
3000
3024
  "GET /api/v1/integrations/zapier/sample/:event": {
3001
3025
  "status": "planned",
3002
3026
  "reason": "integration management"
@@ -3846,6 +3870,8 @@
3846
3870
  "GET /api/v1/settings": {
3847
3871
  "status": "covered",
3848
3872
  "tools": [
3873
+ "conduyt_get_stage_rules",
3874
+ "conduyt_get_deal_card_fields",
3849
3875
  "conduyt_get_contact_table_defaults",
3850
3876
  "conduyt_set_contact_table_defaults",
3851
3877
  "conduyt_get_call_recording_settings",
@@ -4577,6 +4603,10 @@
4577
4603
  "status": "excluded",
4578
4604
  "reason": "inbound provider callback receivers (Twilio/Resend/etc.), not client-callable"
4579
4605
  },
4606
+ "POST /api/v1/webhooks/inbound/forms/:token": {
4607
+ "status": "excluded",
4608
+ "reason": "inbound provider callback receivers (Twilio/Resend/etc.), not client-callable"
4609
+ },
4580
4610
  "GET /api/v1/webhooks/manage": {
4581
4611
  "status": "covered",
4582
4612
  "tools": [