conduyt-mcp 4.92.0 → 4.93.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 |
@@ -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
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt-mcp",
3
- "version": "4.92.0",
3
+ "version": "4.93.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,5 +1,5 @@
1
1
  {
2
- "generatedAt": "2026-10-16T09:38:16.598Z",
2
+ "generatedAt": "2026-10-16T13:22:04.002Z",
3
3
  "toolCount": 355,
4
4
  "totalEndpoints": 1093,
5
5
  "counts": {