conduyt-mcp 4.92.0 → 4.94.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
@@ -29,7 +29,7 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
29
29
  ### Deals
30
30
  | Tool | Description |
31
31
  |------|-------------|
32
- | `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}}}`) |
32
+ | `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}}}`). With `board_snapshot=true`, `pipeline` (or `pipelineName`) and `board_columns` (`[{stageId, limit?}]`, 1-50 columns of that pipeline, limit 1-1000, at most 5000 rows) every column is read from ONE database snapshot (a moved deal is in exactly one column): `{ columns: [{ stageId, limit, deals }], meta: { board_snapshot: true } }`, rows only; a stage filter, the `health_*` arguments or `page` (a snapshot is always page one) are refused locally before any request. 503 `board_snapshot_busy` (snapshot only) and `gate_busy` (any call) carry Retry-After: the GET is retried within its budget, then the error reaches you |
33
33
  | `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` |
34
34
  | `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` |
35
35
  | `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 |
@@ -88,7 +88,7 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
88
88
  | Tool | Description |
89
89
  |------|-------------|
90
90
  | `conduyt_workflow_validate` | Validate a workflow graph without saving — returns errors and warnings |
91
- | `conduyt_workflow_simulate` | Dry-run a workflow against a real or stub contact — step-by-step trace with compliance and provider checks |
91
+ | `conduyt_workflow_simulate` | Dry-run a workflow against a real or stub contact — step-by-step trace with compliance and provider checks; optional `dealId` (deal occurrence), `companyId` (company occurrence) and `executionId` (replays a stored run, admin or owner only, supplies the company its payload carried) |
92
92
  | `conduyt_workflow_promote` | Safely publish a workflow — validates first, refuses if errors exist |
93
93
 
94
94
  ### Sequences (Email and SMS)
@@ -2,4 +2,8 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { ConduytClient } from "../client.js";
3
3
  /** The six Pipeline health checks (GET /deals health_check, GET /deals/inspection check). */
4
4
  export declare const PIPELINE_HEALTH_CHECKS: readonly ["high_value_idle", "stale", "unassigned", "needs_action", "missing_close_date", "missing_contact"];
5
+ /** GET /deals board_snapshot=true limits (the app's lib/deal-board-snapshot-limits, contract at 5729bc7b4). */
6
+ export declare const BOARD_SNAPSHOT_MAX_COLUMNS = 50;
7
+ export declare const BOARD_SNAPSHOT_MAX_COLUMN_ROWS = 1000;
8
+ export declare const BOARD_SNAPSHOT_MAX_TOTAL_ROWS = 5000;
5
9
  export declare function registerDealTools(server: McpServer, client: ConduytClient): void;
@@ -5,8 +5,50 @@ import { strictInput } from "./strict.js";
5
5
  export const PIPELINE_HEALTH_CHECKS = ["high_value_idle", "stale", "unassigned", "needs_action", "missing_close_date", "missing_contact"];
6
6
  const healthCheckSchema = z.enum(PIPELINE_HEALTH_CHECKS);
7
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).";
8
+ /** GET /deals board_snapshot=true limits (the app's lib/deal-board-snapshot-limits, contract at 5729bc7b4). */
9
+ export const BOARD_SNAPSHOT_MAX_COLUMNS = 50;
10
+ export const BOARD_SNAPSHOT_MAX_COLUMN_ROWS = 1000;
11
+ export const BOARD_SNAPSHOT_MAX_TOTAL_ROWS = 5000;
12
+ const BOARD_SNAPSHOT_DOC = "BOARD SNAPSHOT: board_snapshot=true with pipeline (or pipelineName) and board_columns ([{stageId, limit?}], 1-50 columns of that one pipeline, limit 1-1000 each, default per_page, at most 5000 rows in total, no stage twice) reads every named column from ONE read-only database snapshot, so a deal moved while the read runs is in exactly one column: the answer is {data:{columns:[{stageId, limit, deals}], meta:{board_snapshot:true}}}, columns in the order named, each column's deals exactly what stage=<stageId>&per_page=<limit> rows-only answers (same filters, visibility, order and projection; deals.length equal to limit means the column may hold more). No totals or other aggregate. It cannot be combined with a stage filter (any alias), the health_* arguments or page (a snapshot is always page one: omit page): this tool refuses those locally before any request, and also a total over 5000 rows (explicit limits plus the default per_page, 50 when omitted, for each column without a limit) (the server also answers 400 for rows_only, summary, a page other than exactly 1, or a stage of another pipeline). board_columns without board_snapshot=true is refused too.";
13
+ const BUSY_503_DOC = "503 (this tool's GET is retried within its time budget after Retry-After, then the error reaches you as `Conduyt API 503: <message> [Retry-After: Ns] {\"code\":...,\"retryAfter\":N}`; nothing was changed): code board_snapshot_busy (board_snapshot=true only: the one snapshot could not finish inside its budget; read the columns one by one with stage=<stageId>, or retry later) or code gate_busy (any call, snapshot or not: the access check could not finish in time; retry after Retry-After).";
14
+ /** The board_snapshot mode's local refusals (before any request) and the board_columns wire value. */
15
+ function boardSnapshotColumns(a, f) {
16
+ if (a.board_snapshot !== true) {
17
+ if (a.board_columns !== undefined)
18
+ throw new Error("board_columns needs board_snapshot=true.");
19
+ return undefined;
20
+ }
21
+ const conflicts = [];
22
+ if (f.stageV !== undefined || f.stageNameV !== undefined)
23
+ conflicts.push("stage");
24
+ const health = ["health_summary", "health_check", "health_limit", "health_offset", "health_cursor", "health_rows_only"].filter((k) => a[k] !== undefined);
25
+ conflicts.push(...health);
26
+ if (a.page !== undefined)
27
+ conflicts.push("page");
28
+ if (conflicts.length > 0) {
29
+ throw new Error(`board_snapshot=true cannot be combined with ${conflicts.join(", ")}: it answers every named column's first rows, rows only.`);
30
+ }
31
+ if (f.pipelineV === undefined && f.pipelineNameV === undefined)
32
+ throw new Error("board_snapshot=true needs pipeline (or pipelineName).");
33
+ if (a.board_columns === undefined)
34
+ throw new Error("board_snapshot=true needs board_columns: the stage ids of the columns to read.");
35
+ const seen = new Set();
36
+ for (const c of a.board_columns) {
37
+ const id = c.stageId.toLowerCase();
38
+ if (seen.has(id))
39
+ throw new Error(`board_columns names stage ${id} more than once.`);
40
+ seen.add(id);
41
+ }
42
+ // per_page is validated as an integer 1-1000 (the schema), so the string sent is exactly what the server parses; the aggregate bound counts it (50 when omitted) for every column without a limit.
43
+ const defaultLimit = a.per_page ?? 50;
44
+ const total = a.board_columns.reduce((n, c) => n + (c.limit ?? defaultLimit), 0);
45
+ if (total > BOARD_SNAPSHOT_MAX_TOTAL_ROWS) {
46
+ throw new Error(`board_columns asks for ${total} rows in total (columns without a limit count the default of ${defaultLimit}, from per_page); a snapshot answers at most ${BOARD_SNAPSHOT_MAX_TOTAL_ROWS}. Lower the limits or read fewer columns.`);
47
+ }
48
+ return a.board_columns.map((c) => (c.limit === undefined ? c.stageId : `${c.stageId}:${c.limit}`)).join(",");
49
+ }
8
50
  export function registerDealTools(server, client) {
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, {
51
+ 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 + " " + BOARD_SNAPSHOT_DOC + " " + BUSY_503_DOC, {
10
52
  pipeline: z.string().optional().describe("Filter by pipeline UUID (canonical). Aliases: pipelineId, pipeline_id."),
11
53
  pipelineId: z.string().optional().describe("Filter by pipeline UUID (alias of pipeline)."),
12
54
  pipeline_id: z.string().optional().describe("snake_case alias of pipelineId."),
@@ -21,13 +63,18 @@ export function registerDealTools(server, client) {
21
63
  status: z.enum(["open", "won", "lost"]).optional().describe("Filter by deal status"),
22
64
  search: z.string().optional().describe("Search deal titles"),
23
65
  page: z.number().optional().describe("Page number (default 1)"),
24
- per_page: z.number().optional().describe("Results per page (default 25, max 100)"),
66
+ per_page: z.number().int().min(1).max(BOARD_SNAPSHOT_MAX_COLUMN_ROWS).optional().describe("Results per page, an integer 1-1000 (server default 50, max 1000; with board_snapshot it is the default rows per column)"),
25
67
  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
68
  health_check: healthCheckSchema.optional().describe("With health_summary: one check whose deals are added to `deals`, highest value first."),
27
69
  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
70
  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
71
  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
72
  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}}}."),
73
+ board_snapshot: z.boolean().optional().describe("true: read every column in board_columns from ONE database snapshot (a deal moved during the read is in exactly one column). Needs pipeline (or pipelineName) and board_columns; refused locally, before any request, with stage / stageId / stage_id / stageName / stage_name, any health_* argument, or page (a snapshot is always page one: omit page). Answers {data:{columns:[{stageId, limit, deals}], meta:{board_snapshot:true}}}."),
74
+ board_columns: z.array(z.object({
75
+ stageId: z.string().uuid().describe("A live stage UUID of the pipeline"),
76
+ limit: z.number().int().min(1).max(BOARD_SNAPSHOT_MAX_COLUMN_ROWS).optional().describe(`Rows for this column, 1-${BOARD_SNAPSHOT_MAX_COLUMN_ROWS} (default per_page, itself default 50 on the server)`),
77
+ }).strict()).min(1).max(BOARD_SNAPSHOT_MAX_COLUMNS).optional().describe(`With board_snapshot=true only: the columns to read, in answer order (1-${BOARD_SNAPSHOT_MAX_COLUMNS}, no stage twice, at most ${BOARD_SNAPSHOT_MAX_TOTAL_ROWS} rows in total). Sent as board_columns=<stageId>[:<limit>],...`),
31
78
  }, async (a) => {
32
79
  const params = new URLSearchParams();
33
80
  // Accept every reasonable casing of each filter (canonical bare name +
@@ -55,6 +102,7 @@ export function registerDealTools(server, client) {
55
102
  const pipelineNameV = coalesce("pipeline name", a.pipelineName, a.pipeline_name);
56
103
  const stageV = coalesce("stage id", a.stage, a.stageId, a.stage_id);
57
104
  const stageNameV = coalesce("stage name", a.stageName, a.stage_name);
105
+ const boardColumnsV = boardSnapshotColumns(a, { pipelineV, pipelineNameV, stageV, stageNameV });
58
106
  if (pipelineV !== undefined)
59
107
  params.set("pipeline", pipelineV);
60
108
  if (pipelineNameV !== undefined)
@@ -86,6 +134,10 @@ export function registerDealTools(server, client) {
86
134
  params.set("health_cursor", a.health_cursor);
87
135
  if (a.health_rows_only !== undefined)
88
136
  params.set("health_rows_only", String(a.health_rows_only));
137
+ if (a.board_snapshot !== undefined)
138
+ params.set("board_snapshot", String(a.board_snapshot));
139
+ if (boardColumnsV !== undefined)
140
+ params.set("board_columns", boardColumnsV);
89
141
  const result = await client.get(`/api/v1/deals?${params}`);
90
142
  return formatResult(result);
91
143
  }));
@@ -24,19 +24,33 @@ export function registerWorkflowSandboxTools(server, client) {
24
24
  });
25
25
  return formatResult(result);
26
26
  });
27
- server.tool("conduyt_workflow_simulate", "Dry-run a saved automation against a real or stub contact. Returns a step-by-step trace of what WOULD happen — no messages sent, no tags applied, no side effects. Checks compliance (DNC, opt-outs), provider readiness (SMS/email configured), and condition evaluation. Use this to verify a workflow does what you expect before publishing.", {
27
+ server.tool("conduyt_workflow_simulate", "Dry-run a saved automation against a real or stub contact. Returns a step-by-step trace of what WOULD happen — no messages sent, no tags applied, no side effects. Checks compliance (DNC, opt-outs), provider readiness (SMS/email configured), and condition evaluation. Use this to verify a workflow does what you expect before publishing. A deal occurrence needs dealId and a company occurrence needs companyId (see those fields). executionId (optional, admin or owner only) replays a stored run of this automation and supplies ONE fact, the company carried by that run's payload; the run must belong to this account and this automation with the same trigger event, contact and deal as the preview, and without executionId on a non-company occurrence the preview is a synthetic sample whose company rules evaluate as unknown/absent, which the response states.", {
28
28
  automationId: z.string().describe("Automation UUID to simulate"),
29
29
  contactId: z.string().optional().describe("Contact UUID to simulate against. If omitted, uses a stub contact with empty fields."),
30
- }, async ({ automationId, contactId }) => {
30
+ dealId: z.string().optional().describe("The event's deal for a DEAL occurrence (deal.created / stage_changed / won / lost / ...): REQUIRED, 400 without it (the contact's newest deal is never a substitute); must be a deal you can see (deals.view and pipeline visibility), 404 otherwise; refused (400) when the selected occurrence is not a deal event (UUID)"),
31
+ companyId: z.string().optional().describe("The event's company for a COMPANY occurrence (company.created / updated / stage_changed / deleted / tagged / tag_removed): REQUIRED, 400 without it; needs companies.view, 404 when not found in this account; executionId does not replace it. On any other occurrence it is accepted only when it equals the company carried by the replayed executionId payload, else refused (400). With executionId on a company occurrence it must equal the replayed payload's company (400 otherwise). Company producers carry no contact, so contact filters on such an occurrence are judged as production judges them, without one (UUID)"),
32
+ executionId: z.string().optional().describe("Optional id of a stored run of THIS automation to replay (admin or owner only). The replay supplies ONE fact: the company carried by that run's stored payload, read the way the live runner reads it; nothing else about the subject comes from the run. The run must belong to this account and this automation, carry the same trigger event as the occurrence being previewed, and the same contact and deal as the preview; otherwise 404 (not found, or a deal run hidden from API keys without the deals scope) or 400 (other event or other subject). Runs that carry a deal are hidden from API keys without the deals scope. On a COMPANY occurrence companyId stays required and executionId does not replace it (it must equal the replayed payload's company). Omitted, on a non-company occurrence: the preview is a synthetic sample, company rules evaluate as unknown/absent, and the response says so (UUID)"),
33
+ }, async ({ automationId, contactId, dealId, companyId, executionId }) => {
31
34
  if (!UUID_RE.test(automationId)) {
32
35
  return formatResult({ error: "automationId must be a valid UUID" });
33
36
  }
34
37
  if (contactId && !UUID_RE.test(contactId)) {
35
38
  return formatResult({ error: "contactId must be a valid UUID" });
36
39
  }
40
+ for (const [name, value] of [["dealId", dealId], ["companyId", companyId], ["executionId", executionId]]) {
41
+ if (value !== undefined && !UUID_RE.test(value)) {
42
+ return formatResult({ error: `${name} must be a valid UUID` });
43
+ }
44
+ }
37
45
  const body = {};
38
46
  if (contactId)
39
47
  body.contactId = contactId;
48
+ if (dealId !== undefined)
49
+ body.dealId = dealId;
50
+ if (companyId !== undefined)
51
+ body.companyId = companyId;
52
+ if (executionId !== undefined)
53
+ body.executionId = executionId;
40
54
  const result = await client.post(`/api/v1/automations/${automationId}/dry-run`, body);
41
55
  return formatResult(result);
42
56
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt-mcp",
3
- "version": "4.92.0",
3
+ "version": "4.94.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-16T09:38:16.598Z",
2
+ "generatedAt": "2026-10-17T19:39:12.198Z",
3
3
  "toolCount": 355,
4
- "totalEndpoints": 1093,
4
+ "totalEndpoints": 1096,
5
5
  "counts": {
6
6
  "covered": 314,
7
- "excluded": 324,
8
- "planned": 455,
7
+ "excluded": 326,
8
+ "planned": 456,
9
9
  "uncategorized": 0
10
10
  },
11
11
  "matrix": {
@@ -1657,10 +1657,18 @@
1657
1657
  "status": "planned",
1658
1658
  "reason": "conversation thread surfaces"
1659
1659
  },
1660
+ "GET /api/v1/cron/analytics-account-sync": {
1661
+ "status": "excluded",
1662
+ "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
1663
+ },
1660
1664
  "GET /api/v1/cron/apply-contact-stamps": {
1661
1665
  "status": "excluded",
1662
1666
  "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
1663
1667
  },
1668
+ "GET /api/v1/cron/apply-product-signals": {
1669
+ "status": "excluded",
1670
+ "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
1671
+ },
1664
1672
  "GET /api/v1/cron/booking-reminders": {
1665
1673
  "status": "excluded",
1666
1674
  "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
@@ -3157,6 +3165,10 @@
3157
3165
  "conduyt_mailchimp_retry_unsubscribes"
3158
3166
  ]
3159
3167
  },
3168
+ "POST /api/v1/integrations/posthog/intent": {
3169
+ "status": "planned",
3170
+ "reason": "integration management"
3171
+ },
3160
3172
  "GET /api/v1/integrations/slack/channels": {
3161
3173
  "status": "planned",
3162
3174
  "reason": "integration management"