conduyt-mcp 4.91.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
@@ -19,8 +19,9 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
19
19
  | `conduyt_create_contact` | Create a new contact (a 409 with `existingId`, `rule`, `ruleVersion`, `matchedOn`, `reason` means the identity policy identified the incoming person with that record: confirm the identity (name, company) before updating it, a shared phone or email is not proof, and the server creates (with a passive duplicate proposal) a different person on a shared identifier; 409 `identifier_changed` / `owner_busy` and 503 `IDENTITY_INDEX_NOT_READY` are retryable, nothing written; with `callId`: the lead for a contact-less inbound call, linked in the same transaction; the server derives source "Inbound Call" and the owner) |
20
20
  | `conduyt_update_contact` | Update contact fields; optional `expectedRevision` (0..2147483647) refuses a stale save with 409 `contact_changed` (code + current revision in the error) |
21
21
  | `conduyt_merge_contacts` | DESTRUCTIVE: merge duplicates into a survivor (`POST /contacts/merge`, owner/admin); the losers are deleted. Two steps: without `confirmImpact` a preview (`counts`, `guardCounts`, `identifierOverflow`, `blockers`); with `confirmImpact: true` plus the preview's `expectedCounts`, `expectedGuardCounts` (and `expectedIdentifiers`) the merge; the confirm is sent only against a preview this server returned in the last 10 minutes (else refused locally); the server pins the record counts, guard counts and identifier plan, not other field edits made after the preview; 75 s timeout. Moved counts 409, 503 `merge_contacts_busy` / `merge_budget_exhausted` + Retry-After surfaced verbatim, never retried |
22
- | `conduyt_list_duplicate_proposals` | List a contact's possible duplicates (`GET /contacts/{id}/duplicate-proposals`): open and not-sure proposals (`id`, `status`, `rule`, `other`, `raisedHere`) and the identifiers held from it (value-free); a record you cannot view has no id |
23
- | `conduyt_decide_duplicate_proposal` | Decide one proposal (`POST /contacts/{id}/duplicate-proposals/{proposalId}`, contacts:edit on both records): `not_sure` keeps both visible, `different` is final (never shown again), `merge` records a pair ALREADY merged (this tool never merges: use `conduyt_merge_contacts` first, else 409 `not_merged`); 409 `already_decided`, 403 `other_record_not_editable`; never retried |
22
+ | `conduyt_list_duplicate_proposals` | List a contact's possible duplicates (`GET /contacts/{id}/duplicate-proposals`): open and not-sure proposals (`id`, `status`, `rule`, `other`, `raisedHere`) and the identifiers held from it (value-free); a record you cannot view has no id; keyset paged with `cursor` / `limit` (default 50) and `held_cursor` / `held_limit` (default 20), answers `total`, `nextCursor`, the held twins and membership revisions (re-read page one when a later page answers another revision) |
23
+ | `conduyt_list_import_duplicate_proposals` | The duplicate proposals one import raised on the records it created (`GET /imports/{id}/duplicate-proposals`, contacts:view): every status, oldest first, `page` / `per_page` (default 50), `{ rows, meta }`; a record you cannot view is "A contact you cannot view" with no id |
24
+ | `conduyt_decide_duplicate_proposal` | Decide one proposal (`POST /contacts/{id}/duplicate-proposals/{proposalId}`, contacts:edit on both records): `not_sure` keeps both visible, `different` is final (never shown again), `merge` records a pair ALREADY merged (this tool never merges: use `conduyt_merge_contacts` first, else 409 `not_merged`); 409 `already_decided`, 403 `other_record_not_editable`; answers the list's `membershipRevision` and `total` after the decision and `previousMembershipRevision`; never retried |
24
25
  | `conduyt_add_contact_collaborator` | Add a team member as a collaborator (`POST /contacts/{id}/collaborators`); optional `expectedRevision`, 409 `contact_changed` |
25
26
  | `conduyt_remove_contact_collaborator` | Remove a collaborator (`DELETE /contacts/{id}/collaborators`, body); optional `expectedRevision`, 409 `contact_changed`, 404 when not a collaborator |
26
27
  | `conduyt_add_tags` | Add tags to a contact |
@@ -28,7 +29,7 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
28
29
  ### Deals
29
30
  | Tool | Description |
30
31
  |------|-------------|
31
- | `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 |
32
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` |
33
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` |
34
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 |
@@ -11,6 +11,10 @@ const IDENTITY_409_MEANING = "A 409 with { existingId, rule, ruleVersion, matche
11
11
  const IDENTITY_CREATE_ANSWERS = "Identity rules (v2): " + IDENTITY_409_MEANING + " 409 with code `identifier_changed` or `owner_busy` = another save for the same email, phone or owner was in progress, nothing was written: wait a moment and send the same request again (retryable). 503 with code `IDENTITY_INDEX_NOT_READY` = the workspace's identifier index is still being built at this size, nothing was written: retry later. A possible duplicate that is not an exact match is created and shown as a duplicate proposal on the record (conduyt_list_duplicate_proposals).";
12
12
  const IDENTITY_SAVE_RETRY = "Identity rules (v2): " + IDENTITY_409_MEANING + " 409 with code `identifier_changed` = another save for this email or phone was in progress (or the record's email or phone changed while this saved), nothing was written: reload the contact and send the change again (retryable). 409 with code `owner_busy` = another save for the same owner was in progress, nothing was written: wait a moment and send the same request again (retryable). 503 with code `IDENTITY_INDEX_NOT_READY` = the workspace's identifier index is still being built at this size, nothing was written: retry later.";
13
13
  const PROPOSAL_DECISIONS = ["not_sure", "different", "merge"];
14
+ /** Duplicate proposals look (board #95, app f2d638db6): paging bounds of the proposal lists. */
15
+ const pageLimit = (max, dflt, name) => z.number().int().min(1).max(max).optional().describe(`${name}: integer 1-${max} (default ${dflt}); a value above ${max} is refused, not capped`);
16
+ const opaqueCursor = (name, from) => z.string().min(1).max(2048).optional().describe(`${name}: the ${from} of the previous page's answer, unchanged (opaque). Omit for the first page.`);
17
+ const MEMBERSHIP_REVISION_NOTE = "membershipRevision and heldIdentifiersMembershipRevision are opaque digests of each list's whole shown set (every page answers the same value while the list is unchanged): keep page one's revision and, when a later page answers another, discard that page and read page one again.";
14
18
  export const CONTACT_REVISION_MAX = 2147483647;
15
19
  const CONTACT_CHANGED = "A stale pin is refused with 409 and code contact_changed (nothing written): the error text carries `code`, the contact's CURRENT `revision` and the current `contact`; reload, decide, and call again with that revision (never retry the same pin). A malformed expectedRevision is a 400; only an omitted field means unpinned.";
16
20
  const expectedRevision = () => z.number().int().min(0).max(CONTACT_REVISION_MAX).optional().describe("Optimistic-concurrency pin: the contact `revision` as you last read it (conduyt_get_contact / conduyt_list_contacts / a previous change). Omit for last-write-wins per field.");
@@ -100,7 +104,7 @@ export function registerContactTools(server, client) {
100
104
  const result = await client.get(`/api/v1/contacts?${params}`);
101
105
  return formatResult(result);
102
106
  });
103
- server.tool("conduyt_get_contact", "Get a single contact with full details — profile, tags, custom fields, timeline, deals, and activity history. Includes `revision` (integer save revision): pass it as expectedRevision on conduyt_update_contact or the collaborator tools so a newer change is never overwritten. Includes `hasOutboundSms` (boolean): true when this workspace has ever PROVABLY texted the contact (a proven send only: not a draft, a queued row or a pre-send failure); use it to pick the `context` for conduyt_check_message (false = 'first', true = 'conversation'). Also includes `hasOutboundEmail` (boolean): the same for email (true when at least one outbound email to the contact was proven sent, so a genuine \"Re:\" reply is a conversation and not flagged); use it to pick the `context` for an email check. Also includes `duplicateProposals` (the open and not-sure possible-duplicate lines on this record, each naming the other record as far as your key may see it) and `heldIdentifiers` (emails or phones held from this record because another record owns them, value-free); list or decide them with conduyt_list_duplicate_proposals / conduyt_decide_duplicate_proposal.", {
107
+ server.tool("conduyt_get_contact", "Get a single contact with full details — profile, tags, custom fields, timeline, deals, and activity history. Includes `revision` (integer save revision): pass it as expectedRevision on conduyt_update_contact or the collaborator tools so a newer change is never overwritten. Includes `hasOutboundSms` (boolean): true when this workspace has ever PROVABLY texted the contact (a proven send only: not a draft, a queued row or a pre-send failure); use it to pick the `context` for conduyt_check_message (false = 'first', true = 'conversation'). Also includes `hasOutboundEmail` (boolean): the same for email (true when at least one outbound email to the contact was proven sent, so a genuine \"Re:\" reply is a conversation and not flagged); use it to pick the `context` for an email check. Also includes `duplicateProposals` (the open and not-sure possible-duplicate lines on this record, each naming the other record as far as your key may see it) and `heldIdentifiers` (emails or phones held from this record because another record owns them, value-free), each the FIRST page only, with duplicateProposalsTotal, duplicateProposalsNextCursor, duplicateProposalsMembershipRevision, heldIdentifiersTotal, heldIdentifiersNextCursor and heldIdentifiersMembershipRevision (page on with conduyt_list_duplicate_proposals, passing the next cursors as cursor / held_cursor); list or decide them with conduyt_list_duplicate_proposals / conduyt_decide_duplicate_proposal.", {
104
108
  id: z.string().describe("Contact UUID"),
105
109
  }, async ({ id }) => {
106
110
  const result = await client.get(`/api/v1/contacts/${id}`);
@@ -240,13 +244,27 @@ export function registerContactTools(server, client) {
240
244
  mergePreviews.drop(key);
241
245
  return formatResult(result);
242
246
  }));
243
- strictInput(server.tool("conduyt_list_duplicate_proposals", "List the possible duplicates of one contact (GET /api/v1/contacts/{contactId}/duplicate-proposals, identity rules v2; requires contacts:view). Answers { data: { proposals, heldIdentifiers } }. Each proposal: { id (the proposalId for conduyt_decide_duplicate_proposal), status ('open' or 'not_sure': the only two shown), rule, ruleVersion, source, createdAt, decidedAt, raisedHere (true = this record is the newer arrival the proposal was raised on), other: { id, name, createdAt, visible, editable } }. When your key cannot view the other record it is described only as 'a contact you cannot view' with id and createdAt null and visible false: you cannot decide that pair. heldIdentifiers (up to 20) are the emails or phones this record was NOT given because another record owns them (rule 4), value-free: { id, kind ('email' or 'phone'), rule, source, createdAt, heldBy: [{ id, name, visible }] }. Read-only, nothing is changed. 404 = no such contact for you. conduyt_get_contact carries the same two lists.", {
247
+ strictInput(server.tool("conduyt_list_duplicate_proposals", "List the possible duplicates of one contact (GET /api/v1/contacts/{contactId}/duplicate-proposals, identity rules v2; requires contacts:view). Answers { data: { proposals, heldIdentifiers, total, nextCursor, membershipRevision, heldIdentifiersTotal, heldIdentifiersNextCursor, heldIdentifiersMembershipRevision } }. Each proposal: { id (the proposalId for conduyt_decide_duplicate_proposal), status ('open' or 'not_sure': the only two shown), rule, ruleVersion, source, createdAt, decidedAt, raisedHere (true = this record is the newer arrival the proposal was raised on), other: { id, name, createdAt, visible, editable } }. When your key cannot view the other record it is described only as 'a contact you cannot view' with id and createdAt null and visible false: you cannot decide that pair. heldIdentifiers are the emails or phones this record was NOT given because another record owns them (rule 4), value-free: { id, kind ('email' or 'phone'), rule, source, createdAt, heldBy: [{ id, name, visible }] }. Paging: both lists are keyset paged, newest first. cursor / limit (1-100, default 50) page the proposals; held_cursor / held_limit (1-100, default 20) page the held identifiers; a list sent without its cursor answers its first page. total and heldIdentifiersTotal count every line of each list (all pages); nextCursor and heldIdentifiersNextCursor are the cursors of the next pages, null on the last page. " + MEMBERSHIP_REVISION_NOTE + " Read-only, nothing is changed. Refusals: 400 code `invalid_cursor` (a cursor this list did not issue, a held cursor sent as cursor or the reverse), 400 naming limit or held_limit, 404 = no such contact for you. conduyt_get_contact carries the first page of the same two lists.", {
244
248
  contactId: z.string().uuid().describe("Contact UUID whose possible duplicates to list"),
245
- }, async ({ contactId }) => {
246
- const result = await client.get(`/api/v1/contacts/${encodeURIComponent(contactId)}/duplicate-proposals`);
249
+ cursor: opaqueCursor("cursor", "nextCursor"),
250
+ limit: pageLimit(100, 50, "limit (proposals per page)"),
251
+ held_cursor: opaqueCursor("held_cursor", "heldIdentifiersNextCursor"),
252
+ held_limit: pageLimit(100, 20, "held_limit (held identifiers per page)"),
253
+ }, async ({ contactId, cursor, limit, held_cursor, held_limit }) => {
254
+ const qp = new URLSearchParams();
255
+ if (cursor !== undefined)
256
+ qp.set("cursor", cursor);
257
+ if (limit !== undefined)
258
+ qp.set("limit", String(limit));
259
+ if (held_cursor !== undefined)
260
+ qp.set("held_cursor", held_cursor);
261
+ if (held_limit !== undefined)
262
+ qp.set("held_limit", String(held_limit));
263
+ const qs = qp.toString();
264
+ const result = await client.get(`/api/v1/contacts/${encodeURIComponent(contactId)}/duplicate-proposals` + (qs ? `?${qs}` : ""));
247
265
  return formatResult(result);
248
266
  }));
249
- strictInput(server.tool("conduyt_decide_duplicate_proposal", "Record a decision on one possible-duplicate pair shown on a contact (POST /api/v1/contacts/{contactId}/duplicate-proposals/{proposalId} with { decision }; requires contacts:edit on BOTH records of the pair). Get proposalId from conduyt_list_duplicate_proposals. decision, exactly one of: 'not_sure' = keep both records visible, the pair stays listed and who looked is recorded (it can be decided again later); 'different' = the two records are different people, the pair is never shown again and the decision is FINAL; 'merge' = record that the pair is ALREADY merged: this tool NEVER merges. Merge first with conduyt_merge_contacts (preview, then confirm), then send 'merge' here; before the merge exists it is refused with 409 code `not_merged`. A pair already decided 'different' or merged is final: 409 code `already_decided` (also when someone decided it just now). Other refusals, nothing written: 404 (no such proposal on this contact: for EVERY decision; or the other record is gone: for 'not_sure' and 'different' only, while 'merge' still succeeds after the other record has been merged away), 403 code `other_record_not_editable` (you cannot edit the other record), 403 (you can view this contact but not edit it), 400 (decision missing or not one of the three), 402 billing. Answers { data: { id, status, decidedAt } } with status 'not_sure', 'different' or 'merged'. Never retried automatically.", {
267
+ strictInput(server.tool("conduyt_decide_duplicate_proposal", "Record a decision on one possible-duplicate pair shown on a contact (POST /api/v1/contacts/{contactId}/duplicate-proposals/{proposalId} with { decision }; requires contacts:edit on BOTH records of the pair). Get proposalId from conduyt_list_duplicate_proposals. decision, exactly one of: 'not_sure' = keep both records visible, the pair stays listed and who looked is recorded (it can be decided again later); 'different' = the two records are different people, the pair is never shown again and the decision is FINAL; 'merge' = record that the pair is ALREADY merged: this tool NEVER merges. Merge first with conduyt_merge_contacts (preview, then confirm), then send 'merge' here; before the merge exists it is refused with 409 code `not_merged`. A pair already decided 'different' or merged is final: 409 code `already_decided` (also when someone decided it just now). Other refusals, nothing written: 404 (no such proposal on this contact: for EVERY decision; or the other record is gone: for 'not_sure' and 'different' only, while 'merge' still succeeds after the other record has been merged away), 403 code `other_record_not_editable` (you cannot edit the other record), 403 (you can view this contact but not edit it), 400 (decision missing or not one of the three), 402 billing. Answers { data: { id, status, decidedAt, previousMembershipRevision, membershipRevision, total } } with status 'not_sure', 'different' or 'merged'. membershipRevision and total are this contact's proposals list AFTER the decision (the same digest and count conduyt_list_duplicate_proposals answers); previousMembershipRevision is that digest just BEFORE the write (equal to membershipRevision on an idempotent repeat): a client holding loaded pages adopts the after values only when previousMembershipRevision equals the revision it holds, otherwise it reads page one again. Never retried automatically.", {
250
268
  contactId: z.string().uuid().describe("Contact UUID the proposal is shown on"),
251
269
  proposalId: z.string().uuid().describe("The proposal id from conduyt_list_duplicate_proposals"),
252
270
  decision: z.enum(PROPOSAL_DECISIONS).describe("'not_sure' keeps both visible; 'different' = different people, never shown again (final); 'merge' = the pair is already merged elsewhere (run conduyt_merge_contacts first; this tool never merges)"),
@@ -254,4 +272,18 @@ export function registerContactTools(server, client) {
254
272
  const result = await client.post(`/api/v1/contacts/${encodeURIComponent(contactId)}/duplicate-proposals/${encodeURIComponent(proposalId)}`, { decision });
255
273
  return formatResult(result);
256
274
  }));
275
+ strictInput(server.tool("conduyt_list_import_duplicate_proposals", "List the duplicate proposals one import raised on the records it created (GET /api/v1/imports/{importId}/duplicate-proposals; the import results section 'Created, similar record exists'; requires contacts:view). One row per proposal, EVERY status ('open', 'not_sure', 'different', 'merged'; a pair whose records are merged reads 'merged'), oldest first. Paged with page (1-100000, default 1) and per_page (1-100, default 50): answers { data: { rows, meta: { page, per_page, total } } }, total counts every row of the import. Each row: { proposalId, status, rowNum (the import file's row number), createdAt, decidedAt, decidedByName, newRecord (the record the import created), similar (the existing record it may duplicate), matchedOn (kinds only such as 'phone' or 'phone + company', never a value), keptName (for a merged pair, the record that was kept) }; newRecord and similar are { id, name, visible, editable, live }. A record outside your key's visibility has id null and the name 'A contact you cannot view': that row cannot be decided. Decide a row with conduyt_decide_duplicate_proposal (contactId = newRecord.id, proposalId; needs contacts:edit on both records). An owner, admin or view-all key reads any import of the account, any other key only the imports its user ran. Read-only. Refusals: 400 naming page or per_page (not an integer, out of range), 403 without contacts:view, 404 for an unknown import or one this key may not read.", {
276
+ importId: z.string().uuid().describe("Import job UUID"),
277
+ page: z.number().int().min(1).max(100000).optional().describe("page: integer 1-100000 (default 1)"),
278
+ per_page: pageLimit(100, 50, "per_page (rows per page)"),
279
+ }, async ({ importId, page, per_page }) => {
280
+ const qp = new URLSearchParams();
281
+ if (page !== undefined)
282
+ qp.set("page", String(page));
283
+ if (per_page !== undefined)
284
+ qp.set("per_page", String(per_page));
285
+ const qs = qp.toString();
286
+ const result = await client.get(`/api/v1/imports/${encodeURIComponent(importId)}/duplicate-proposals` + (qs ? `?${qs}` : ""));
287
+ return formatResult(result);
288
+ }));
257
289
  }
@@ -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.91.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,9 +1,9 @@
1
1
  {
2
- "generatedAt": "2026-10-16T01:48:16.550Z",
3
- "toolCount": 354,
4
- "totalEndpoints": 1092,
2
+ "generatedAt": "2026-10-16T13:22:04.002Z",
3
+ "toolCount": 355,
4
+ "totalEndpoints": 1093,
5
5
  "counts": {
6
- "covered": 313,
6
+ "covered": 314,
7
7
  "excluded": 324,
8
8
  "planned": 455,
9
9
  "uncategorized": 0
@@ -2976,6 +2976,12 @@
2976
2976
  "status": "planned",
2977
2977
  "reason": "import job monitoring/resubmission (ai-import tool covers creation)"
2978
2978
  },
2979
+ "GET /api/v1/imports/:id/duplicate-proposals": {
2980
+ "status": "covered",
2981
+ "tools": [
2982
+ "conduyt_list_import_duplicate_proposals"
2983
+ ]
2984
+ },
2979
2985
  "GET /api/v1/imports/:id/integrity": {
2980
2986
  "status": "planned",
2981
2987
  "reason": "import job monitoring/resubmission (ai-import tool covers creation)"
@@ -1,11 +0,0 @@
1
- export type ActionConfigTypo = {
2
- actionType: string;
3
- key: string;
4
- suggestion: string;
5
- };
6
- /** The first misspelled option key in an actions graph (v1 array of {type, config}, or v2 { nodes: [{ action: {type, config} }] }), or null */
7
- export declare function findActionConfigTypo(graph: unknown): ActionConfigTypo | null;
8
- /** The refusal text; the details object is the final token */
9
- export declare function actionConfigTypoMessage(t: ActionConfigTypo): string;
10
- /** Throws (a plain Error) when the graph carries a misspelled option */
11
- export declare function assertNoActionConfigTypo(graph: unknown): void;
@@ -1,78 +0,0 @@
1
- /**
2
- * Local typo guard for automation action configs (card ownership per lane, Codex r1/r2). The server validator is the authority
3
- * and there the option names are not enforced yet, so a typoed `laneSoruce` on assign_to_user would silently assign the run's
4
- * deal instead of the lane card. Only the options below are known here.
5
- *
6
- * Traversal rule: ONLY the real action positions of the graph are visited, exactly as the graph is shaped: a v1 `actions`
7
- * array's elements `{type, config}`, and a v2 graph's `nodes[].action` `{type, config}`. For each action of a known type the
8
- * IMMEDIATE keys of `config` are checked; the config VALUES are opaque and never descended into (a webhook payload may hold any
9
- * object, including one shaped like an action), and nothing outside those positions (request metadata, other node fields) is
10
- * scanned. A key that is not a known option but is a case-insensitive match or ONE edit (substitution, insertion, deletion or
11
- * adjacent transposition) from one is refused; keys further away, and every other action type, pass through.
12
- * Source of the names: lib/automation-action-registry.ts in the app (assign_to_user, create_deal, close_other_open_deals).
13
- */
14
- const KNOWN_ACTION_OPTIONS = {
15
- assign_to_user: ["runWindow", "mode", "userId", "userIds", "groupId", "target", "applyTo", "overwriteExisting", "laneSource", "lanePipelineId", "laneStageId"],
16
- create_deal: ["pipelineId", "stageId", "dealName", "dealValue", "allowDuplicates", "status", "source", "assignedTo", "leaveUnassigned"],
17
- close_other_open_deals: ["lostReason"],
18
- };
19
- /** Damerau-Levenshtein (optimal string alignment) distance, case-insensitive */
20
- function distance(a, b) {
21
- const x = a.toLowerCase(), y = b.toLowerCase();
22
- const d = Array.from({ length: x.length + 1 }, (_, i) => [i, ...Array(y.length).fill(0)]);
23
- for (let j = 1; j <= y.length; j++)
24
- d[0][j] = j;
25
- for (let i = 1; i <= x.length; i++) {
26
- for (let j = 1; j <= y.length; j++) {
27
- d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (x[i - 1] === y[j - 1] ? 0 : 1));
28
- if (i > 1 && j > 1 && x[i - 1] === y[j - 2] && x[i - 2] === y[j - 1])
29
- d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
30
- }
31
- }
32
- return d[x.length][y.length];
33
- }
34
- const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
35
- function checkAction(action) {
36
- if (!isObject(action) || typeof action.type !== "string" || !isObject(action.config))
37
- return null;
38
- const known = KNOWN_ACTION_OPTIONS[action.type];
39
- if (!known)
40
- return null;
41
- for (const key of Object.keys(action.config)) {
42
- if (known.includes(key))
43
- continue;
44
- const near = known.find((k) => distance(key, k) <= 1);
45
- if (near)
46
- return { actionType: action.type, key, suggestion: near };
47
- }
48
- return null;
49
- }
50
- /** The first misspelled option key in an actions graph (v1 array of {type, config}, or v2 { nodes: [{ action: {type, config} }] }), or null */
51
- export function findActionConfigTypo(graph) {
52
- if (Array.isArray(graph)) {
53
- for (const action of graph) {
54
- const t = checkAction(action);
55
- if (t)
56
- return t;
57
- }
58
- return null;
59
- }
60
- if (isObject(graph) && Array.isArray(graph.nodes)) {
61
- for (const node of graph.nodes) {
62
- const t = isObject(node) ? checkAction(node.action) : null;
63
- if (t)
64
- return t;
65
- }
66
- }
67
- return null;
68
- }
69
- /** The refusal text; the details object is the final token */
70
- export function actionConfigTypoMessage(t) {
71
- return `Unknown option "${t.key}" in ${t.actionType} config: did you mean "${t.suggestion}"? Nothing was sent. ${JSON.stringify(t)}`;
72
- }
73
- /** Throws (a plain Error) when the graph carries a misspelled option */
74
- export function assertNoActionConfigTypo(graph) {
75
- const t = findActionConfigTypo(graph);
76
- if (t)
77
- throw new Error(actionConfigTypoMessage(t));
78
- }