conduyt-mcp 4.97.0 → 4.98.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
@@ -17,7 +17,7 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
17
17
  | `conduyt_list_contacts` | List and search contacts with filters |
18
18
  | `conduyt_get_contact` | Get full contact details with timeline; `hasOutboundSms` = the workspace has ever provably texted the contact; `hasOutboundEmail` = the same for email; `duplicateProposals` and `heldIdentifiers` = the open possible-duplicate lines and held identifiers |
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
- | `conduyt_update_contact` | Update contact fields; optional `expectedRevision` (0..2147483647) refuses a stale save with 409 `contact_changed` (code + current revision in the error) |
20
+ | `conduyt_update_contact` | Update contact fields (incl. `assignedTo`; `keepPreviousOwnerAsCollaborator: true` with a reassignment keeps the previous owner as a read-only collaborator instead of releasing them, 400 without `assignedTo`); 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
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
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 |
@@ -33,13 +33,15 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
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 |
36
- | `conduyt_get_stage_rules` / `conduyt_set_stage_rules` | Read / set one stage's rule (`requiredFields` incl. `custom:<fieldKey>`, `requiredDocuments` 0-20, lost-reason label and options, `lostReasonAllowOther`, `entryByAutomationOnly`: only an automation can move or create a deal into the stage, every other entry is refused 422 `stage_automation_only`; `conduyt_list_pipelines` shows it on each stage); other stages untouched; revision-aware, retried once on a 409 |
36
+ | `conduyt_get_stage_rules` / `conduyt_set_stage_rules` | Read / set one stage's rule (`requiredFields` incl. `custom:<fieldKey>`, `requiredDocuments` 0-20, lost-reason label and options, `lostReasonAllowOther`, `entryByAutomationOnly`: only an automation can move or create a deal into the stage, every other entry is refused 422 `stage_automation_only`; `conduyt_list_pipelines` shows it on each stage; `closesOtherOpenDeals` (+ `closesOtherOpenDealsReason`, 1-200 characters): a deal entering the stage marks the contact's other open deals lost; `assignsContactOwner`: the entering deal's owner becomes the contact owner, the previous contact owner stays a collaborator); other stages untouched; revision-aware, retried once on a 409 |
37
+ | `conduyt_get_calling_hours` / `conduyt_update_calling_hours` | Read the window the dialer enforces (`effectiveCallingWindow`: `source`, `enabled`, `start`/`end` HH:MM, `timezone` or null = contact zone, then number area code, then account) / set `settings.callingWindow` (`enabled`, `start`, `end` always sent, `end` after `start`, `timezone` trimmed and recognised by the API (omit keeps the stored zone, null clears it); `enabled: false` needs `acknowledgeAnyHour: true`, else 400 `calling_hours_acknowledgement_required`) |
38
+ | `conduyt_list_email_opt_outs` / `conduyt_add_email_opt_out` / `conduyt_resubscribe_email_opt_out` | The email opt-out list (owner / admin key; hash-only rows `{ id, source, reason, createdAt }`): list (`page`, `perPage` 1-100), add an address by hand (`reason` 1-200 required; 201 new row, 200 when an unsubscribe-link or Mailchimp row is promoted to manual, 409 with the existing row when already listed by hand) and take an address off (`{ removed }`) |
37
39
  | `conduyt_get_deal_card_fields` / `conduyt_set_deal_card_fields` | Read / replace the kanban card fields (1-8, built-ins or `custom:<fieldKey>`) |
38
40
  | `conduyt_get_settings_revision` | The account `settingsRevision` (plus stored stage rules and rot days): pass it as `expectedSettingsRevision` to `conduyt_archive_pipeline` / `conduyt_delete_stage` so a stale one is refused 409 `stale_settings_revision` |
39
41
  | `conduyt_get_pipeline_rot_days` / `conduyt_set_pipeline_rot_days` | Read / set the stale-in-stage threshold per pipeline (`pipelineRotDays`, 1-90 days); only the pipelines sent change; revision-aware, retried once on a 409 |
40
42
  | `conduyt_get_deal` | Get deal details with history, including custom-field values |
41
43
  | `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 |
42
- | `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 |
44
+ | `conduyt_update_deal` | Move stage, change value/currency, set probability or close date, reassign (`keepPreviousOwnerAsCollaborator: true` keeps the previous owner as a read-only collaborator of the contact; 400 without `assignedTo` or with a contact change), 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 |
43
45
  | `conduyt_import_deals` | Bulk-import deals (pipeline/stage by name, contact by email) via the canonical import engine — dedupes, side-effect-free, owner/admin only |
44
46
 
45
47
  ### Tasks
@@ -11,7 +11,7 @@ import { registerSequenceTools } from "./tools/sequences.js";
11
11
  import { registerMessagingTools } from "./tools/messaging.js";
12
12
  import { registerMessageCheckSettingsTools } from "./tools/message-check-settings.js";
13
13
  import { registerCallTools } from "./tools/calls.js";
14
- import { registerDialerSettingsTools } from "./tools/dialer-settings.js";
14
+ import { registerDialerSettingsTools, registerCallingHoursTools } from "./tools/dialer-settings.js";
15
15
  import { registerCompanyTools } from "./tools/companies.js";
16
16
  import { registerTagTools } from "./tools/tags.js";
17
17
  import { registerCalendarTools } from "./tools/calendar.js";
@@ -31,6 +31,7 @@ import { registerUserTools } from "./tools/users.js";
31
31
  import { registerBulkTools } from "./tools/bulk.js";
32
32
  import { registerBatchOperationTools } from "./tools/batch-operations.js";
33
33
  import { registerDncTools } from "./tools/dnc.js";
34
+ import { registerEmailOptOutTools } from "./tools/email-opt-out.js";
34
35
  import { registerSavedFilterTools } from "./tools/saved-filters.js";
35
36
  import { registerDripCampaignTools } from "./tools/drip-campaigns.js";
36
37
  import { registerScoringTools } from "./tools/scoring.js";
@@ -69,6 +70,7 @@ export function registerAllTools(server, client) {
69
70
  registerMessageCheckSettingsTools(server, client);
70
71
  registerCallTools(server, client);
71
72
  registerDialerSettingsTools(server, client);
73
+ registerCallingHoursTools(server, client);
72
74
  registerCompanyTools(server, client);
73
75
  registerTagTools(server, client);
74
76
  registerCalendarTools(server, client);
@@ -88,6 +90,7 @@ export function registerAllTools(server, client) {
88
90
  registerBulkTools(server, client);
89
91
  registerBatchOperationTools(server, client);
90
92
  registerDncTools(server, client);
93
+ registerEmailOptOutTools(server, client);
91
94
  registerSavedFilterTools(server, client);
92
95
  registerDripCampaignTools(server, client);
93
96
  registerScoringTools(server, client);
@@ -105,6 +105,8 @@ export const TOOL_ANNOTATIONS = {
105
105
  conduyt_sync_local_presence: row("Sync Local Presence", false, false, false), // every call writes lastSyncedAt (local-presence-sync.ts:225)
106
106
  conduyt_get_dialer_settings: ro("Get Dialer Settings"),
107
107
  conduyt_update_dialer_settings: update("Update Dialer Settings"),
108
+ conduyt_get_calling_hours: ro("Get Calling Hours"),
109
+ conduyt_update_calling_hours: destroy("Update Calling Hours"), // enabled:false (with acknowledgeAnyHour) switches the calling-time check off, which re-enables outreach at any hour: a compliance change like the DNC removals; a repeat saves the same window
108
110
  conduyt_get_call_recording_settings: ro("Get Call Recording Settings"),
109
111
  conduyt_update_call_recording_settings: update("Update Call Recording Settings"),
110
112
  conduyt_list_companies: ro("List Companies"),
@@ -216,6 +218,9 @@ export const TOOL_ANNOTATIONS = {
216
218
  conduyt_list_dnc: ro("List DNC"),
217
219
  conduyt_add_dnc: update("Add DNC"),
218
220
  conduyt_remove_dnc: destroy("Remove DNC"),
221
+ conduyt_list_email_opt_outs: ro("List Email Opt-Outs"),
222
+ conduyt_add_email_opt_out: update("Add Email Opt-Out"), // a repeat is refused 409 (already listed by hand) before any write, so it lands on the same state; adding a block is protective
223
+ conduyt_resubscribe_email_opt_out: destroy("Resubscribe Email Address"), // consent change: removing the row re-enables every customer-facing email to the address
219
224
  conduyt_list_saved_filters: ro("List Saved Filters"),
220
225
  conduyt_create_saved_filter: create("Create Saved Filter"),
221
226
  conduyt_update_saved_filter: update("Update Saved Filter"),
@@ -1,7 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { ConduytClient } from "../client.js";
3
3
  /** (card ownership per lane, app 10-08/09) the action options an agent can set; the catalog does not carry action config, so this is the one place they are documented */
4
- export declare const CARD_OWNERSHIP_ACTIONS_NOTE = " Card ownership per lane: assign_to_user config {userId | userIds (round robin) | groupId, mode (single | round_robin | trigger_host), target|applyTo: contact|deal|both, overwriteExisting, laneSource, lanePipelineId, laneStageId}: laneSource names a card lane (the Deal.source its Auto-Create step wrote, e.g. 'GSM', max 500 chars) and makes the deal leg assign the contact's newest OPEN card from that lane (inside lanePipelineId when set) instead of the run's deal; when the contact has none and lanePipelineId is set, the card is created there (laneStageId, else the pipeline's first stage) already owned by the user; lanePipelineId or laneStageId without laneSource, or laneStageId without lanePipelineId, is refused; a lane never applies to an 'Assign to: contact' step. assign_to_user mode 'trigger_host' assigns to the appointment's booked host (appointment.booked runs only; fails closed when the run has no host). create_deal config {pipelineId (required), stageId, dealName, dealValue, allowDuplicates, status, source, assignedTo, assigneeMode, leaveUnassigned}: leaveUnassigned true leaves the card unassigned when no assignee is set (default false: the card goes to the lead's owner). assigneeMode 'trigger_host' assigns the card to the appointment's booked host (appointment.booked runs only; wins over assignedTo; fails closed when the run has no host, creating nothing). The condition field 'Booked host' (trigger.hostUserId) exists on appointment.booked, whose payload now carries hostUserId. close_other_open_deals config {lostReason (required)}: closes the contact's OTHER open cards as lost with that reason, keeping the run's own card (each moves to its pipeline's lost stage with the usual stage-change effects; cards already won/lost, in the DPP Post-Close pipeline or in a pipeline with no lost stage are skipped; a re-run closes nothing twice); it needs the run's own deal, so use a deal trigger or a find_opportunity step first.";
4
+ export declare const CARD_OWNERSHIP_ACTIONS_NOTE = " Card ownership per lane: assign_to_user config {userId | userIds (round robin) | groupId, mode (single | round_robin | trigger_host), target|applyTo: contact|deal|both, overwriteExisting, previousOwner, laneSource, lanePipelineId, laneStageId}: laneSource names a card lane (the Deal.source its Auto-Create step wrote, e.g. 'GSM', max 500 chars) and makes the deal leg assign the contact's newest OPEN card from that lane (inside lanePipelineId when set) instead of the run's deal; when the contact has none and lanePipelineId is set, the card is created there (laneStageId, else the pipeline's first stage) already owned by the user; lanePipelineId or laneStageId without laneSource, or laneStageId without lanePipelineId, is refused; a lane never applies to an 'Assign to: contact' step. assign_to_user mode 'trigger_host' assigns to the appointment's booked host (appointment.booked runs only; fails closed when the run has no host). previousOwner (release | keep_as_collaborator; default release) says what happens to the previous owner when the step changes an owner: release means they lose the lead unless something else explains their access (e.g. another open card of theirs), keep_as_collaborator keeps them as a collaborator of the contact (they still see the lead and its cards, read-only); any other value is refused, and keep_as_collaborator is refused on a step with a card lane (laneSource) unless it assigns the contact only (validate and publish answer a field error on action.config.previousOwner). create_deal config {pipelineId (required), stageId, dealName, dealValue, allowDuplicates, status, source, assignedTo, assigneeMode, leaveUnassigned}: leaveUnassigned true leaves the card unassigned when no assignee is set (default false: the card goes to the lead's owner). assigneeMode 'trigger_host' assigns the card to the appointment's booked host (appointment.booked runs only; wins over assignedTo; fails closed when the run has no host, creating nothing). The condition field 'Booked host' (trigger.hostUserId) exists on appointment.booked, whose payload now carries hostUserId. close_other_open_deals config {lostReason (required)}: closes the contact's OTHER open cards as lost with that reason, keeping the run's own card (each moves to its pipeline's lost stage with the usual stage-change effects; cards already won/lost, in the DPP Post-Close pipeline or in a pipeline with no lost stage are skipped; a re-run closes nothing twice); it needs the run's own deal, so use a deal trigger or a find_opportunity step first.";
5
5
  /** (deal nodes, app 10-13) find_opportunity v2, link_appointment_to_deal, update_deal_field systemField source; the catalog does not carry action config */
6
6
  export declare const DEAL_NODES_ACTIONS_NOTE = " Deal nodes: find_opportunity config {version: 2, pipelineIds (ordered; the first pipeline holding a match wins; empty = every pipeline), status (open default | won | lost | abandoned | any), excludeSources (lead sources never matched, case-insensitive), excludeCreatedByAutomationIds (automations whose create_deal cards are skipped; cards created from 2026-10-13 on), matchField {fieldKey, value} (searches the WHOLE WORKSPACE, any contact, for the card whose deal custom field equals the value, merge fields allowed e.g. {{trigger.oes}}; a card is found when its field holds the searched value (trimmed) as the same text exactly (no case folding); a value written with separators such as dashes or spaces also matches its digits as text; it matches a stored number only when the value (or those digits) is a plain number with no leading zero and at most 15 significant digits that a number holds exactly (007 is text and does not match the number 7; a 16-digit value, 1e-400 or 3e-324 is text only); booleans and differently formatted stored values never match; status, pipelines and exclusions still apply; exactly one match is found, several are ambiguous and take not found; a found card becomes the run's contact when the run has none; the field must exist; an empty rendered value finds nothing), resolution}: searches the run's contact's cards; FOUND makes the card the run's deal and follows nextIfTrue (else next), NOT FOUND is a normal outcome that follows nextIfFalse (else next) and leaves the run's existing deal unchanged (a later deal step on the false branch still acts on the deal the run already had); without version 2 the step keeps the original single-pipeline lookup (a miss fails the step). Prerequisite: a version 2 find needs step cursors on this workspace, and publishing refuses (find_needs_step_cursors) with \"Find opportunity needs step cursors on this workspace; ask support\"; every version 2 find must hold its own live step cursor or it hard-stops the run without selecting or routing. A version 2 find cannot be retried on its own: retrying a failed one alone answers 409, so re-trigger the automation for the contact. An automation definition that touches deals needs the `deals` scope on an API key: without it the write answers 403. link_appointment_to_deal config {stageId, pipelineId (needs stageId), lostReason}: in an appointment-triggered run, links the trigger's appointment to the run's deal (its appointment fields follow it; a newer booking is never displaced; the appointment must be the deal's contact's), then optionally moves the card; it needs an appointment trigger and the run's deal (find_opportunity first, or a booking made with a deal id). update_deal_field config {systemField: \"source\", value, overwriteExisting}: sets the deal's own lead source instead of a custom field (a value only, trimmed, at most 500 characters; overwriteExisting false writes only while the source is the Unknown label).";
7
7
  /** (#124, automation unknown keys / types) the refusal codes the write paths answer; surfaced verbatim by the client (details JSON object last), never retried */
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { formatResult, formatToolError } from "../client.js";
3
3
  /** (card ownership per lane, app 10-08/09) the action options an agent can set; the catalog does not carry action config, so this is the one place they are documented */
4
- export const CARD_OWNERSHIP_ACTIONS_NOTE = " Card ownership per lane: assign_to_user config {userId | userIds (round robin) | groupId, mode (single | round_robin | trigger_host), target|applyTo: contact|deal|both, overwriteExisting, laneSource, lanePipelineId, laneStageId}: laneSource names a card lane (the Deal.source its Auto-Create step wrote, e.g. 'GSM', max 500 chars) and makes the deal leg assign the contact's newest OPEN card from that lane (inside lanePipelineId when set) instead of the run's deal; when the contact has none and lanePipelineId is set, the card is created there (laneStageId, else the pipeline's first stage) already owned by the user; lanePipelineId or laneStageId without laneSource, or laneStageId without lanePipelineId, is refused; a lane never applies to an 'Assign to: contact' step. assign_to_user mode 'trigger_host' assigns to the appointment's booked host (appointment.booked runs only; fails closed when the run has no host). create_deal config {pipelineId (required), stageId, dealName, dealValue, allowDuplicates, status, source, assignedTo, assigneeMode, leaveUnassigned}: leaveUnassigned true leaves the card unassigned when no assignee is set (default false: the card goes to the lead's owner). assigneeMode 'trigger_host' assigns the card to the appointment's booked host (appointment.booked runs only; wins over assignedTo; fails closed when the run has no host, creating nothing). The condition field 'Booked host' (trigger.hostUserId) exists on appointment.booked, whose payload now carries hostUserId. close_other_open_deals config {lostReason (required)}: closes the contact's OTHER open cards as lost with that reason, keeping the run's own card (each moves to its pipeline's lost stage with the usual stage-change effects; cards already won/lost, in the DPP Post-Close pipeline or in a pipeline with no lost stage are skipped; a re-run closes nothing twice); it needs the run's own deal, so use a deal trigger or a find_opportunity step first.";
4
+ export const CARD_OWNERSHIP_ACTIONS_NOTE = " Card ownership per lane: assign_to_user config {userId | userIds (round robin) | groupId, mode (single | round_robin | trigger_host), target|applyTo: contact|deal|both, overwriteExisting, previousOwner, laneSource, lanePipelineId, laneStageId}: laneSource names a card lane (the Deal.source its Auto-Create step wrote, e.g. 'GSM', max 500 chars) and makes the deal leg assign the contact's newest OPEN card from that lane (inside lanePipelineId when set) instead of the run's deal; when the contact has none and lanePipelineId is set, the card is created there (laneStageId, else the pipeline's first stage) already owned by the user; lanePipelineId or laneStageId without laneSource, or laneStageId without lanePipelineId, is refused; a lane never applies to an 'Assign to: contact' step. assign_to_user mode 'trigger_host' assigns to the appointment's booked host (appointment.booked runs only; fails closed when the run has no host). previousOwner (release | keep_as_collaborator; default release) says what happens to the previous owner when the step changes an owner: release means they lose the lead unless something else explains their access (e.g. another open card of theirs), keep_as_collaborator keeps them as a collaborator of the contact (they still see the lead and its cards, read-only); any other value is refused, and keep_as_collaborator is refused on a step with a card lane (laneSource) unless it assigns the contact only (validate and publish answer a field error on action.config.previousOwner). create_deal config {pipelineId (required), stageId, dealName, dealValue, allowDuplicates, status, source, assignedTo, assigneeMode, leaveUnassigned}: leaveUnassigned true leaves the card unassigned when no assignee is set (default false: the card goes to the lead's owner). assigneeMode 'trigger_host' assigns the card to the appointment's booked host (appointment.booked runs only; wins over assignedTo; fails closed when the run has no host, creating nothing). The condition field 'Booked host' (trigger.hostUserId) exists on appointment.booked, whose payload now carries hostUserId. close_other_open_deals config {lostReason (required)}: closes the contact's OTHER open cards as lost with that reason, keeping the run's own card (each moves to its pipeline's lost stage with the usual stage-change effects; cards already won/lost, in the DPP Post-Close pipeline or in a pipeline with no lost stage are skipped; a re-run closes nothing twice); it needs the run's own deal, so use a deal trigger or a find_opportunity step first.";
5
5
  /** (deal nodes, app 10-13) find_opportunity v2, link_appointment_to_deal, update_deal_field systemField source; the catalog does not carry action config */
6
6
  export const DEAL_NODES_ACTIONS_NOTE = " Deal nodes: find_opportunity config {version: 2, pipelineIds (ordered; the first pipeline holding a match wins; empty = every pipeline), status (open default | won | lost | abandoned | any), excludeSources (lead sources never matched, case-insensitive), excludeCreatedByAutomationIds (automations whose create_deal cards are skipped; cards created from 2026-10-13 on), matchField {fieldKey, value} (searches the WHOLE WORKSPACE, any contact, for the card whose deal custom field equals the value, merge fields allowed e.g. {{trigger.oes}}; a card is found when its field holds the searched value (trimmed) as the same text exactly (no case folding); a value written with separators such as dashes or spaces also matches its digits as text; it matches a stored number only when the value (or those digits) is a plain number with no leading zero and at most 15 significant digits that a number holds exactly (007 is text and does not match the number 7; a 16-digit value, 1e-400 or 3e-324 is text only); booleans and differently formatted stored values never match; status, pipelines and exclusions still apply; exactly one match is found, several are ambiguous and take not found; a found card becomes the run's contact when the run has none; the field must exist; an empty rendered value finds nothing), resolution}: searches the run's contact's cards; FOUND makes the card the run's deal and follows nextIfTrue (else next), NOT FOUND is a normal outcome that follows nextIfFalse (else next) and leaves the run's existing deal unchanged (a later deal step on the false branch still acts on the deal the run already had); without version 2 the step keeps the original single-pipeline lookup (a miss fails the step). Prerequisite: a version 2 find needs step cursors on this workspace, and publishing refuses (find_needs_step_cursors) with \"Find opportunity needs step cursors on this workspace; ask support\"; every version 2 find must hold its own live step cursor or it hard-stops the run without selecting or routing. A version 2 find cannot be retried on its own: retrying a failed one alone answers 409, so re-trigger the automation for the contact. An automation definition that touches deals needs the `deals` scope on an API key: without it the write answers 403. link_appointment_to_deal config {stageId, pipelineId (needs stageId), lostReason}: in an appointment-triggered run, links the trigger's appointment to the run's deal (its appointment fields follow it; a newer booking is never displaced; the appointment must be the deal's contact's), then optionally moves the card; it needs an appointment trigger and the run's deal (find_opportunity first, or a booking made with a deal id). update_deal_field config {systemField: \"source\", value, overwriteExisting}: sets the deal's own lead source instead of a custom field (a value only, trimmed, at most 500 characters; overwriteExisting false writes only while the source is the Unknown label).";
7
7
  /** (#124, automation unknown keys / types) the refusal codes the write paths answer; surfaced verbatim by the client (details JSON object last), never retried */
@@ -11,6 +11,7 @@ export declare const DEAL_CARD_FIELDS: readonly ["value", "contact", "phones", "
11
11
  export declare const MAX_DEAL_CARD_FIELDS = 8;
12
12
  export declare const MAX_STAGE_REQUIRED_FIELDS = 25;
13
13
  export declare const MAX_STAGE_REQUIRED_DOCUMENTS = 20;
14
+ export declare const MAX_CLOSES_OTHERS_REASON_LENGTH = 200;
14
15
  export declare const MIN_PIPELINE_ROT_DAYS = 1;
15
16
  export declare const MAX_PIPELINE_ROT_DAYS = 90;
16
17
  export declare function registerBoardSettingsTools(server: McpServer, client: ConduytClient): void;
@@ -14,7 +14,8 @@ export const MAX_DEAL_CARD_FIELDS = 8;
14
14
  export const MAX_STAGE_REQUIRED_FIELDS = 25;
15
15
  export const MAX_STAGE_REQUIRED_DOCUMENTS = 20;
16
16
  const CUSTOM_FIELD_RE = /^custom:[a-zA-Z_][a-zA-Z0-9_]*$/;
17
- const RULE_KEYS = ["requiredFields", "requiredDocuments", "lostReasonLabel", "lostReasonOptions", "lostReasonAllowOther", "entryByAutomationOnly"];
17
+ const RULE_KEYS = ["requiredFields", "requiredDocuments", "lostReasonLabel", "lostReasonOptions", "lostReasonAllowOther", "entryByAutomationOnly", "closesOtherOpenDeals", "closesOtherOpenDealsReason", "assignsContactOwner"];
18
+ export const MAX_CLOSES_OTHERS_REASON_LENGTH = 200;
18
19
  export const MIN_PIPELINE_ROT_DAYS = 1;
19
20
  export const MAX_PIPELINE_ROT_DAYS = 90;
20
21
  const requiredField = z.union([
@@ -27,7 +28,7 @@ const cardField = z.union([
27
28
  ]);
28
29
  const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
29
30
  export function registerBoardSettingsTools(server, client) {
30
- strictInput(server.tool("conduyt_get_stage_rules", "Read the per-stage rules (settings.pipelineStageRules) as { pipelineId: { stageId: { requiredFields, requiredDocuments, lostReasonLabel, lostReasonOptions, lostReasonAllowOther, entryByAutomationOnly } } }, 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. entryByAutomationOnly: true (absent = off) means only an automation run can move or create a deal INTO the stage; every other entry, admins and API keys included, is refused 422 stage_automation_only (moving out stays free). Set one stage with conduyt_set_stage_rules. Pipelines your key cannot see are left out.", {
31
+ strictInput(server.tool("conduyt_get_stage_rules", "Read the per-stage rules (settings.pipelineStageRules) as { pipelineId: { stageId: { requiredFields, requiredDocuments, lostReasonLabel, lostReasonOptions, lostReasonAllowOther, entryByAutomationOnly, closesOtherOpenDeals, closesOtherOpenDealsReason, assignsContactOwner } } }, 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. entryByAutomationOnly: true (absent = off) means only an automation run can move or create a deal INTO the stage; every other entry, admins and API keys included, is refused 422 stage_automation_only (moving out stays free). What ENTERING the stage does (each absent = off): closesOtherOpenDeals: true marks every OTHER open deal card of the same contact, in any pipeline, lost (with closesOtherOpenDealsReason, or \"Closed automatically: another card reached <stage>\"); assignsContactOwner: true makes the owner of the entering card the contact's owner (the previous contact owner stays as a collaborator). Set one stage with conduyt_set_stage_rules. Pipelines your key cannot see are left out.", {
31
32
  pipelineId: z.string().uuid().optional().describe("Only this pipeline's stage rules"),
32
33
  stageId: z.string().uuid().optional().describe("Only this stage's rule (needs pipelineId)"),
33
34
  }, async ({ pipelineId, stageId }) => {
@@ -42,7 +43,7 @@ export function registerBoardSettingsTools(server, client) {
42
43
  }
43
44
  return formatResult({ data: { pipelineStageRules: out } });
44
45
  }));
45
- 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; lostReasonAllowOther (boolean) lets the rep also pick \"Other\" and type the reason (stored as the reason text; only takes effect while the stage has lost-reason options); entryByAutomationOnly (boolean) is \"Entry by automation only\": true means only an automation run can move or create a deal INTO the stage, and every route entry (deal update, create, bulk edit/update, inbound deals webhook, appointment outcome, public contact route, admins and API keys included) is refused 422 stage_automation_only with the message \"Only an automation can move a deal into <stage>\" (moving out stays free; for a manual correction set it false, move the deal, set it true again; it cannot be the starting stage of automatic intake deals). 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.", {
46
+ 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; lostReasonAllowOther (boolean) lets the rep also pick \"Other\" and type the reason (stored as the reason text; only takes effect while the stage has lost-reason options); entryByAutomationOnly (boolean) is \"Entry by automation only\": true means only an automation run can move or create a deal INTO the stage, and every route entry (deal update, create, bulk edit/update, inbound deals webhook, appointment outcome, public contact route, admins and API keys included) is refused 422 stage_automation_only with the message \"Only an automation can move a deal into <stage>\" (moving out stays free; for a manual correction set it false, move the deal, set it true again; it cannot be the starting stage of automatic intake deals); closesOtherOpenDeals (boolean) is \"Close the contact's other open deal cards\": true means a deal that ENTERS this stage (moved in or created in it, by any writer) marks every OTHER open deal of the same contact, in any pipeline, lost, each moving to its own pipeline's lost stage with closesOtherOpenDealsReason (emitting deal.stage_changed / deal.lost; the entering deal is never touched; closed cards can be reopened); closesOtherOpenDealsReason (string, trimmed, 1-200 characters) is the lost reason written on those cards (absent = \"Closed automatically: another card reached <stage name>\"); assignsContactOwner (boolean) is \"Card owner becomes the contact owner\": true means the owner of a deal that enters this stage becomes its contact's owner (only an active member; a deal with no owner changes nothing) and the previous contact owner stays on the contact as a collaborator. Both switches are per stage, never \"every Won stage\"; send false to turn one off. 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.", {
46
47
  pipelineId: z.string().uuid().describe("Pipeline UUID"),
47
48
  stageId: z.string().uuid().describe("Stage UUID within that pipeline"),
48
49
  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."),
@@ -51,10 +52,13 @@ export function registerBoardSettingsTools(server, client) {
51
52
  lostReasonOptions: z.array(z.string()).max(50).optional().describe("Dropdown options of the lost-reason prompt (replaces the list)"),
52
53
  lostReasonAllowOther: z.boolean().optional().describe("The rep may pick 'Other' and type the reason (stored as the reason text). Needs lost-reason options to take effect."),
53
54
  entryByAutomationOnly: z.boolean().optional().describe("Entry by automation only: when true, only an automation run can move or create a deal INTO this stage; every API / UI entry (admins and API keys included) is refused 422 stage_automation_only. Moving a deal out stays free. To make a manual correction, turn it off, move the deal, turn it back on."),
55
+ closesOtherOpenDeals: z.boolean().optional().describe("Close the contact's other open deal cards: when true, a deal that enters this stage (moved in or created in it) marks every OTHER open deal of the same contact, in any pipeline, lost (each moves to its pipeline's lost stage with closesOtherOpenDealsReason). The entering deal is never touched; closed cards can be reopened."),
56
+ closesOtherOpenDealsReason: z.string().trim().min(1).max(MAX_CLOSES_OTHERS_REASON_LENGTH).optional().describe("The lost reason written on the cards closed by closesOtherOpenDeals (1-200 characters). Absent = \"Closed automatically: another card reached <stage name>\"."),
57
+ assignsContactOwner: z.boolean().optional().describe("Card owner becomes the contact owner: when true, the owner of a deal that enters this stage becomes its contact's owner (only an active member; a deal with no owner changes nothing). The previous contact owner stays on the contact as a collaborator."),
54
58
  }, async ({ pipelineId, stageId, ...given }) => {
55
59
  const change = Object.fromEntries(Object.entries(given).filter(([, v]) => v !== undefined));
56
60
  if (Object.keys(change).length === 0) {
57
- throw new Error("Nothing to set. Pass at least one of requiredFields, requiredDocuments, lostReasonLabel, lostReasonOptions, lostReasonAllowOther, entryByAutomationOnly.");
61
+ throw new Error("Nothing to set. Pass at least one of requiredFields, requiredDocuments, lostReasonLabel, lostReasonOptions, lostReasonAllowOther, entryByAutomationOnly, closesOtherOpenDeals, closesOtherOpenDealsReason, assignsContactOwner.");
58
62
  }
59
63
  return patchGeneralSettingsMerged(client, (settings) => {
60
64
  const rules = isObj(settings.pipelineStageRules) ? settings.pipelineStageRules : {};
@@ -3,6 +3,8 @@ import { ConduytClient } from "../client.js";
3
3
  /** The contact save revision pin (catalog bounds: integer 0..2147483647). */
4
4
  /** Create-from-call rule: the server derives the lead's source ("Inbound Call") and its owner (the rep who took the call), so a
5
5
  * create that carries callId must not carry either field. Refused here, after every field is merged, with the rule named. */
6
+ /** (#361) the API's 400 text for keepPreviousOwnerAsCollaborator sent without a new owner */
7
+ export declare const KEEP_PREVIOUS_OWNER_NEEDS_ASSIGNEE = "keepPreviousOwnerAsCollaborator needs assignedTo (the new owner) in the same request";
6
8
  export declare const CALL_DERIVED_REFUSAL = "callId cannot be combined with source or assignedTo: for a lead created from a call the server derives both (source is 'Inbound Call', the owner is the rep who took the call). Omit them.";
7
9
  export declare const callDerivedFieldsConflict: (v: {
8
10
  callId?: unknown;
@@ -4,6 +4,8 @@ import { strictInput } from "./strict.js";
4
4
  /** The contact save revision pin (catalog bounds: integer 0..2147483647). */
5
5
  /** Create-from-call rule: the server derives the lead's source ("Inbound Call") and its owner (the rep who took the call), so a
6
6
  * create that carries callId must not carry either field. Refused here, after every field is merged, with the rule named. */
7
+ /** (#361) the API's 400 text for keepPreviousOwnerAsCollaborator sent without a new owner */
8
+ export const KEEP_PREVIOUS_OWNER_NEEDS_ASSIGNEE = "keepPreviousOwnerAsCollaborator needs assignedTo (the new owner) in the same request";
7
9
  export const CALL_DERIVED_REFUSAL = "callId cannot be combined with source or assignedTo: for a lead created from a call the server derives both (source is 'Inbound Call', the owner is the rep who took the call). Omit them.";
8
10
  export const callDerivedFieldsConflict = (v) => v.callId !== undefined && (v.source !== undefined || v.assignedTo !== undefined);
9
11
  /** Identity rules v2 (board #69, app main 61bf61876): what a create answers when the email or phone already belongs to a record. */
@@ -140,7 +142,7 @@ export function registerContactTools(server, client) {
140
142
  created.inputSchema = Object.assign(base.superRefine((v, ctx) => { if (callDerivedFieldsConflict(v))
141
143
  ctx.addIssue({ code: z.ZodIssueCode.custom, message: CALL_DERIVED_REFUSAL, path: ["callId"] }); }), { shape: base.shape });
142
144
  }
143
- strictInput(server.tool("conduyt_update_contact", "Update an existing contact's fields — name, email, phone, company, custom fields. customFields merge into existing values (omit a key to leave it unchanged). Pass expectedRevision (the contact's `revision` as you read it) to refuse the save when the contact changed since." + INVALID_NUMBER_REFUSALS + " " + CONTACT_CHANGED + " " + IDENTITY_SAVE_RETRY, {
145
+ strictInput(server.tool("conduyt_update_contact", "Update an existing contact's fields — name, email, phone, company, owner (assignedTo), custom fields. A reassignment releases the previous owner unless keepPreviousOwnerAsCollaborator is true, which keeps them as a collaborator (read-only access to the lead and its cards). customFields merge into existing values (omit a key to leave it unchanged). Pass expectedRevision (the contact's `revision` as you read it) to refuse the save when the contact changed since." + INVALID_NUMBER_REFUSALS + " " + CONTACT_CHANGED + " " + IDENTITY_SAVE_RETRY, {
144
146
  id: z.string().describe("Contact UUID"),
145
147
  email: z.string().optional().describe("Updated email"),
146
148
  firstName: z.string().optional().describe("Updated first name"),
@@ -148,6 +150,8 @@ export function registerContactTools(server, client) {
148
150
  phone: z.string().optional().describe("Updated phone"),
149
151
  timezone: z.string().nullable().optional().describe("IANA timezone override (e.g. America/Denver); pass null to clear the override and re-derive from ZIP → area code → state"),
150
152
  company: z.string().optional().describe("Updated company name"),
153
+ assignedTo: z.string().trim().min(1).max(100).nullable().optional().describe("Reassign the lead: the new owner's user ID; null unassigns. The previous owner is released (they lose the lead unless something else explains their access) unless keepPreviousOwnerAsCollaborator is true."),
154
+ keepPreviousOwnerAsCollaborator: z.boolean().optional().describe("With assignedTo set to another user: keep the previous owner as a collaborator of this contact (read-only access to the lead and its cards) instead of releasing them. Default false. Refused 400 without assignedTo in the same request."),
151
155
  source: z.string().trim().min(1).max(100).nullable().optional().describe("Correct the lead's first-touch source (normally first-write-wins and set at creation — update only to FIX bad attribution). Pass null to clear."),
152
156
  masterStatus: z.string().trim().min(1).max(40).optional().describe("Lifecycle status of the LEAD (master record), distinct from any deal's status: 'open' (default), 'won', 'lost', 'abandoned', 'disqualified', or an account-defined custom status. A terminal status (won/lost/abandoned/disqualified) SILENCES all outbound automations for this contact — drips, sequences, and scheduled sends stop. Use conduyt_get_lifecycle_settings to see the account's configured statuses."),
153
157
  customFields: z.record(z.any()).optional().describe("Structured custom-field values to merge, keyed by field key. Omit a key to leave it unchanged."),
@@ -155,6 +159,10 @@ export function registerContactTools(server, client) {
155
159
  expectedRevision: expectedRevision(),
156
160
  }, async ({ id, customFields, custom_fields, ...updates }) => {
157
161
  const cf = resolveCustomFields(customFields, custom_fields);
162
+ // (#361) the API's own 400, raised before the request: the flag rides a reassignment to a user only
163
+ if (updates.keepPreviousOwnerAsCollaborator === true && !(typeof updates.assignedTo === "string" && updates.assignedTo)) {
164
+ throw new Error(KEEP_PREVIOUS_OWNER_NEEDS_ASSIGNEE);
165
+ }
158
166
  const body = cf !== undefined ? { ...updates, customFields: cf } : updates;
159
167
  const result = await client.patch(`/api/v1/contacts/${id}`, body);
160
168
  return formatResult(result);
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { formatResult } from "../client.js";
3
3
  import { strictInput } from "./strict.js";
4
+ import { KEEP_PREVIOUS_OWNER_NEEDS_ASSIGNEE } from "./contacts.js";
4
5
  /** The six Pipeline health checks (GET /deals health_check, GET /deals/inspection check). */
5
6
  export const PIPELINE_HEALTH_CHECKS = ["high_value_idle", "stale", "unassigned", "needs_action", "missing_close_date", "missing_contact"];
6
7
  const healthCheckSchema = z.enum(PIPELINE_HEALTH_CHECKS);
@@ -218,7 +219,7 @@ export function registerDealTools(server, client) {
218
219
  const result = await client.post("/api/v1/deals", params);
219
220
  return formatResult(result);
220
221
  });
221
- server.tool("conduyt_update_deal", "Update a deal — move stage, change value/currency, set probability or expected close date, reassign, mark won/lost, or write structured custom-field values. customFields are merged into existing values (omit a key to leave it unchanged). A stage set to \"Entry by automation only\" refuses every entry here: 422 code stage_automation_only, message \"Only an automation can move a deal into <stage>\" (admins and API keys included; moving out is free).", {
222
+ server.tool("conduyt_update_deal", "Update a deal — move stage, change value/currency, set probability or expected close date, reassign (assignedTo: the previous owner is released unless they own another open card of the lead, or keepPreviousOwnerAsCollaborator is true, which keeps them as a read-only collaborator of the contact; the flag is refused 400 without assignedTo and 400 together with a contact change), mark won/lost, or write structured custom-field values. customFields are merged into existing values (omit a key to leave it unchanged). A stage set to \"Entry by automation only\" refuses every entry here: 422 code stage_automation_only, message \"Only an automation can move a deal into <stage>\" (admins and API keys included; moving out is free).", {
222
223
  id: z.string().describe("Deal UUID"),
223
224
  title: z.string().optional().describe("Updated title"),
224
225
  stageId: z.string().optional().describe("Move to a new stage UUID"),
@@ -231,11 +232,16 @@ export function registerDealTools(server, client) {
231
232
  probability: z.number().min(0).max(1).optional().describe("Win probability as a 0–1 fraction (e.g. 0.75 = 75%)"),
232
233
  expectedCloseDate: z.string().optional().describe("Expected close date (ISO 8601)"),
233
234
  assignedTo: z.string().optional().describe("Reassign to user (owner) UUID"),
235
+ keepPreviousOwnerAsCollaborator: z.boolean().optional().describe("With assignedTo set to another user: keep the previous owner as a collaborator of the deal's contact (read-only access to the lead and its cards) instead of releasing them. Default false: the previous owner is released unless they own another open card of the lead. Refused 400 without assignedTo in the same request, and 400 together with a contact change (contactId)."),
234
236
  source: z.string().trim().min(1).max(500).optional().describe("Attribution source for THIS deal (the trigger that produced it) — distinct from the contact's first-touch source. Every deal keeps a source (#129): it can be changed, never cleared (null/blank are rejected). 1-500 chars."),
235
237
  lostReason: z.string().optional().describe("Reason for a lost/disqualified deal. Some stages REQUIRE this on entry (the move fails with code STAGE_REQUIREMENTS listing lostReasonOptions when the stage has a configured dropdown — resend with one of those exact values)."),
236
238
  customFields: z.record(z.any()).optional().describe("Structured custom-field values to merge, keyed by field key (send null for a key to clear it; keys you omit keep their current values)"),
237
239
  expectedUpdatedAt: z.string().datetime({ message: "expectedUpdatedAt must be the deal's updatedAt exactly as the API returned it (UTC, trailing Z, e.g. 2026-10-06T23:19:00.000Z)" }).optional().describe("Optional: the deal's updatedAt from your last read, ISO 8601 exactly as returned (e.g. 2026-10-06T23:19:00.000Z). If the deal changed since, the update is refused (409, code deal_changed, with the current updatedAt) and nothing is written — read the deal again and retry. Omit for last-write-wins per field."),
238
240
  }, async ({ id, ...updates }) => {
241
+ // (#361) the API's own 400, raised before the request: the flag rides a reassignment to a user only
242
+ if (updates.keepPreviousOwnerAsCollaborator === true && !(typeof updates.assignedTo === "string" && updates.assignedTo)) {
243
+ throw new Error(KEEP_PREVIOUS_OWNER_NEEDS_ASSIGNEE);
244
+ }
239
245
  const result = await client.patch(`/api/v1/deals/${id}`, updates);
240
246
  return formatResult(result);
241
247
  });
@@ -28,4 +28,17 @@ export declare const wrapupAutoReleaseSecondsSchema: z.ZodEffects<z.ZodNumber, n
28
28
  * follows. Read needs the dialer scope; update needs the settings scope (admin). (#95/#96, 2026-09-16)
29
29
  */
30
30
  export declare function registerDialerSettingsTools(server: McpServer, client: ConduytClient): void;
31
+ /** HH:MM, 24 hour: the same pattern the settings route validates callingWindow.start / end with */
32
+ export declare const CALLING_TIME: RegExp;
33
+ export declare const CALLING_HOURS_ACK_MESSAGE = "Switching calling hours off requires acknowledgeAnyHour: true (calls at any hour; your team is responsible for calling-time rules)";
34
+ export declare const CALLING_HOURS_ORDER_MESSAGE = "callingWindow.end must be after callingWindow.start";
35
+ /**
36
+ * The dialer's calling hours (#307; settings.callingWindow, PATCH /api/v1/settings, the Calling hours card). Read through
37
+ * GET /api/v1/settings, which answers every caller (members too) with `effectiveCallingWindow`, the window the dialer
38
+ * enforces as one sanitized object. The tools mirror the route's callingWindow schema (strict object; enabled, start and
39
+ * end required; end after start; switching off needs acknowledgeAnyHour: true) so a bad value fails here with the API's
40
+ * message; the API stays the authority and its refusals (400 with code calling_hours_acknowledgement_required, 409 stale
41
+ * settings revision, 403) are surfaced as it sends them.
42
+ */
43
+ export declare function registerCallingHoursTools(server: McpServer, client: ConduytClient): void;
31
44
  export {};
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { formatResult } from "../client.js";
3
3
  import { patchGeneralSettings } from "./lifecycle.js";
4
+ import { strictInput } from "./strict.js";
4
5
  /** the two lines a workspace records calls on: its Twilio lines (settings.recording) and its iPhone line (settings.projectBlueRecording) */
5
6
  const RECORDING_KEY = { twilio: "recording", iphone: "projectBlueRecording" };
6
7
  const isOn = (v) => v === "inbound" || v === "outbound" || v === "both";
@@ -105,3 +106,40 @@ export function registerDialerSettingsTools(server, client) {
105
106
  return patchGeneralSettings(client, { [RECORDING_KEY[which]]: policy });
106
107
  });
107
108
  }
109
+ /** HH:MM, 24 hour: the same pattern the settings route validates callingWindow.start / end with */
110
+ export const CALLING_TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
111
+ export const CALLING_HOURS_ACK_MESSAGE = "Switching calling hours off requires acknowledgeAnyHour: true (calls at any hour; your team is responsible for calling-time rules)";
112
+ export const CALLING_HOURS_ORDER_MESSAGE = "callingWindow.end must be after callingWindow.start";
113
+ /**
114
+ * The dialer's calling hours (#307; settings.callingWindow, PATCH /api/v1/settings, the Calling hours card). Read through
115
+ * GET /api/v1/settings, which answers every caller (members too) with `effectiveCallingWindow`, the window the dialer
116
+ * enforces as one sanitized object. The tools mirror the route's callingWindow schema (strict object; enabled, start and
117
+ * end required; end after start; switching off needs acknowledgeAnyHour: true) so a bad value fails here with the API's
118
+ * message; the API stays the authority and its refusals (400 with code calling_hours_acknowledgement_required, 409 stale
119
+ * settings revision, 403) are surfaced as it sends them.
120
+ */
121
+ export function registerCallingHoursTools(server, client) {
122
+ server.tool("conduyt_get_calling_hours", "Read the window the dialer enforces for calls (settings.callingWindow, the Calling hours card; any key may read it, settings:view is not needed): effectiveCallingWindow { source ('callingWindow' = saved from the card or the API, a legacy source name = an older saved shape still read, 'default' = never saved, which means 09:00-17:00), enabled (false = the check is off, calls are allowed at any hour), start and end as HH:MM 24 hour, timezone (the IANA zone the hours are judged in, or null = follow the contact's own zone, then the called number's area code, then the account's) }. Calls outside the window are refused with code calling_window. Change it with conduyt_update_calling_hours.", {}, async () => {
123
+ const result = (await client.get("/api/v1/settings"));
124
+ return formatResult({ data: { effectiveCallingWindow: result?.data?.effectiveCallingWindow ?? null } });
125
+ });
126
+ strictInput(server.tool("conduyt_update_calling_hours", "Set the dialer's calling hours (settings.callingWindow via PATCH /api/v1/settings; admin key, settings:edit). enabled, start and end are always sent together (the API replaces the saved window): start / end are HH:MM 24 hour and end must be after start; timezone is an IANA zone such as America/Chicago the hours are judged in; it is sent trimmed and otherwise as given, and the API recognises it (it also accepts compatibility spellings such as AKST), so an unknown zone comes back as the API's 400. OMITTING timezone keeps the stored zone (the API merges the window); timezone: null clears it, and then the hours follow the contact's zone, then the called number's area code, then the account's. enabled: false switches the calling-hours check OFF (calls at any hour) and then needs acknowledgeAnyHour: true, meaning your team owns the calling-time rules; the acknowledgement is not stored and the change is written to the audit log. Without it the API refuses 400 calling_hours_acknowledgement_required. The first save over an older saved shape (callingHours, dialer.*, voice.*) moves it into callingWindow. Read the current value with conduyt_get_calling_hours first. A stale settings revision is handed back as a conflict with the current settings.", {
127
+ enabled: z.boolean().describe("false switches the calling-hours check off; it then needs acknowledgeAnyHour: true"),
128
+ start: z.string().regex(CALLING_TIME, "start must be HH:MM, 24 hour").describe("HH:MM, 24 hour"),
129
+ end: z.string().regex(CALLING_TIME, "end must be HH:MM, 24 hour").describe("HH:MM, 24 hour; must be after start"),
130
+ timezone: z.string().nullable().optional().describe("IANA zone the hours are judged in (trimmed, recognised by the API). Omit: the stored zone is kept. null: clears it, so the hours follow the contact's zone, then the number's area code, then the account's."),
131
+ acknowledgeAnyHour: z.boolean().optional().describe("Required true when enabled is false: the tenant allows calls at any hour and owns the calling-time rules. Not stored; written to the audit log."),
132
+ }, async ({ enabled, start, end, timezone, acknowledgeAnyHour }) => {
133
+ const fail = (text) => ({ content: [{ type: "text", text }], isError: true });
134
+ if (!(start < end))
135
+ return fail(CALLING_HOURS_ORDER_MESSAGE);
136
+ if (enabled === false && acknowledgeAnyHour !== true)
137
+ return fail(CALLING_HOURS_ACK_MESSAGE);
138
+ const callingWindow = { enabled, start, end };
139
+ if (timezone !== undefined)
140
+ callingWindow.timezone = timezone === null ? null : timezone.trim();
141
+ if (acknowledgeAnyHour !== undefined)
142
+ callingWindow.acknowledgeAnyHour = acknowledgeAnyHour;
143
+ return patchGeneralSettings(client, { callingWindow });
144
+ }));
145
+ }
@@ -0,0 +1,15 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ConduytClient } from "../client.js";
3
+ /**
4
+ * The workspace's email opt-out list (#191; app routes /api/v1/email/opt-out-list and /resubscribe). The list is hash-only:
5
+ * the API never stores or returns an address, a row is { id, source, reason, createdAt }. All three routes are owner / admin
6
+ * only. The validators below mirror the route's own (bareEmailAddress + EMAIL_SHAPE, 320 characters, reason 1-200, source
7
+ * "manual" only) so a bad value fails here with the API's message; the API stays the authority and its refusals
8
+ * (400, 403, 409 with the existing row) are surfaced verbatim and never retried.
9
+ */
10
+ export declare const EMAIL_OPT_OUT_REASON_MAX = 200;
11
+ export declare const EMAIL_INVALID_MESSAGE = "email must be a valid email address";
12
+ /** the route's check (bareEmailAddress: "Name <a@b.com>" reads as a@b.com, trimmed, lowercased): throws the API's own 400 text.
13
+ * The address is sent as given; the API normalizes it the same way. */
14
+ export declare function assertOptOutEmail(raw: string): string;
15
+ export declare function registerEmailOptOutTools(server: McpServer, client: ConduytClient): void;
@@ -0,0 +1,47 @@
1
+ import { z } from "zod";
2
+ import { formatResult } from "../client.js";
3
+ import { strictInput } from "./strict.js";
4
+ /**
5
+ * The workspace's email opt-out list (#191; app routes /api/v1/email/opt-out-list and /resubscribe). The list is hash-only:
6
+ * the API never stores or returns an address, a row is { id, source, reason, createdAt }. All three routes are owner / admin
7
+ * only. The validators below mirror the route's own (bareEmailAddress + EMAIL_SHAPE, 320 characters, reason 1-200, source
8
+ * "manual" only) so a bad value fails here with the API's message; the API stays the authority and its refusals
9
+ * (400, 403, 409 with the existing row) are surfaced verbatim and never retried.
10
+ */
11
+ export const EMAIL_OPT_OUT_REASON_MAX = 200;
12
+ const EMAIL_SHAPE = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/;
13
+ export const EMAIL_INVALID_MESSAGE = "email must be a valid email address";
14
+ /** the route's check (bareEmailAddress: "Name <a@b.com>" reads as a@b.com, trimmed, lowercased): throws the API's own 400 text.
15
+ * The address is sent as given; the API normalizes it the same way. */
16
+ export function assertOptOutEmail(raw) {
17
+ const match = /<([^>]+)>/.exec(raw);
18
+ const email = (match ? match[1] : raw).trim().toLowerCase();
19
+ if (!email || email.length > 320 || !EMAIL_SHAPE.test(email))
20
+ throw new Error(EMAIL_INVALID_MESSAGE);
21
+ return raw;
22
+ }
23
+ export function registerEmailOptOutTools(server, client) {
24
+ strictInput(server.tool("conduyt_list_email_opt_outs", "List the workspace's email opt-out list (GET /api/v1/email/opt-out-list; owner / admin key), newest first. The list is hash-only, so no address is returned: each row is { id, source, reason, createdAt } where source says how the address got there ('manual' = added by hand with conduyt_add_email_opt_out; other values are an unsubscribe link, an address-confirmed unsubscribe or a Mailchimp sync) and reason is the note recorded. Paged: page (default 1) and perPage (1-100, default 25); the answer carries meta { page, perPage, total, totalPages }. Every customer-facing email send (campaigns, sequences, one-off, automation, transactional and booking mail) refuses a listed address. 403 for a non-admin key is surfaced verbatim.", {
25
+ page: z.number().int().min(1).optional().describe("Page number (default 1)"),
26
+ perPage: z.number().int().min(1).max(100).optional().describe("Rows per page, 1-100 (default 25)"),
27
+ }, async ({ page, perPage }) => {
28
+ const params = new URLSearchParams();
29
+ if (page !== undefined)
30
+ params.set("page", String(page));
31
+ if (perPage !== undefined)
32
+ params.set("perPage", String(perPage));
33
+ // two literal paths: the coverage gate derives the route from a literal, and an unpaged call carries no "?"
34
+ return formatResult(params.toString() ? await client.get(`/api/v1/email/opt-out-list?${params}`) : await client.get("/api/v1/email/opt-out-list"));
35
+ }));
36
+ strictInput(server.tool("conduyt_add_email_opt_out", "Put an email address on the workspace's opt-out list by hand (POST /api/v1/email/opt-out-list; owner / admin key): the email twin of conduyt_add_dnc, for a vendor or customer 'do not email' request that has no contact. The address is trimmed and lowercased exactly as the readers do, so every customer-facing send refuses it at once (campaigns, sequences, one-off and automation emails, transactional and booking mail); staff and platform mail is not affected. reason is REQUIRED (1-200 characters) and goes into the audit trail with the address hash, never the address itself. Answers: 201 with the new row { id, source: 'manual', reason, createdAt }; 200 with the row when the address was already listed by another source (unsubscribe link, Mailchimp): it is PROMOTED in place to a manual row; 409 with the existing row when it is already listed by hand (nothing written). 400 invalid address, reason missing or over 200 characters, or source other than 'manual'. All refusals are surfaced verbatim and never retried. Take an address off again with conduyt_resubscribe_email_opt_out.", {
37
+ email: z.string().min(1).describe("The address to block, e.g. person@example.com (trimmed and lowercased by the API, 320 characters at most)"),
38
+ reason: z.string().trim().min(1, "reason is required").max(EMAIL_OPT_OUT_REASON_MAX, `reason must be ${EMAIL_OPT_OUT_REASON_MAX} characters or fewer`).describe("Why it is listed (1-200 characters), kept in the audit trail"),
39
+ source: z.literal("manual").optional().describe("Optional; 'manual' is the only accepted value"),
40
+ }, async ({ email, reason, source }) => {
41
+ // an undefined source drops out of the JSON body
42
+ return formatResult(await client.post("/api/v1/email/opt-out-list", { email: assertOptOutEmail(email), reason, source }));
43
+ }));
44
+ strictInput(server.tool("conduyt_resubscribe_email_opt_out", "Take an email address off the workspace's opt-out list (POST /api/v1/email/opt-out-list/resubscribe; owner / admin key, audited with the address hash only): the address is sendable again. Use it only when the address's owner has confirmed they want email again. Answers { removed: boolean } (false = the address was not on the list). A contact's OWN email opt-out flag is not touched here: clear that on the contact. 400 invalid address and 403 for a non-admin key are surfaced verbatim and never retried.", {
45
+ email: z.string().min(1).describe("The address to take off the list"),
46
+ }, async ({ email }) => formatResult(await client.post("/api/v1/email/opt-out-list/resubscribe", { email: assertOptOutEmail(email) }))));
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt-mcp",
3
- "version": "4.97.0",
3
+ "version": "4.98.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",
@@ -5,6 +5,11 @@
5
5
  "planned": "callable in principle; a typed tool is wanted but not built yet"
6
6
  },
7
7
  "rules": [
8
+ {
9
+ "match": "POST /api/v1/short-links",
10
+ "status": "planned",
11
+ "reason": "composer short-links route; the tri-surface rule owes it a real conduyt_* tool, board #466 (MCP/CLI parity for the composer short-links route)"
12
+ },
8
13
  {
9
14
  "match": "GET /api/v1/call-flows/functions/:id",
10
15
  "status": "planned",
@@ -1,10 +1,10 @@
1
1
  {
2
- "generatedAt": "2026-10-23T19:52:32.945Z",
3
- "toolCount": 359,
4
- "totalEndpoints": 1103,
2
+ "generatedAt": "2026-10-25T02:48:35.925Z",
3
+ "toolCount": 364,
4
+ "totalEndpoints": 1107,
5
5
  "counts": {
6
- "covered": 317,
7
- "excluded": 329,
6
+ "covered": 320,
7
+ "excluded": 330,
8
8
  "planned": 457,
9
9
  "uncategorized": 0
10
10
  },
@@ -2766,9 +2766,23 @@
2766
2766
  "status": "planned",
2767
2767
  "reason": "arrived with the app main merge before f132ed6f0, outside the unknown-caller feature; a typed tool is wanted but not built (4.90.0)"
2768
2768
  },
2769
+ "GET /api/v1/email/opt-out-list": {
2770
+ "status": "covered",
2771
+ "tools": [
2772
+ "conduyt_list_email_opt_outs"
2773
+ ]
2774
+ },
2775
+ "POST /api/v1/email/opt-out-list": {
2776
+ "status": "covered",
2777
+ "tools": [
2778
+ "conduyt_add_email_opt_out"
2779
+ ]
2780
+ },
2769
2781
  "POST /api/v1/email/opt-out-list/resubscribe": {
2770
- "status": "planned",
2771
- "reason": "bulk email send surfaces"
2782
+ "status": "covered",
2783
+ "tools": [
2784
+ "conduyt_resubscribe_email_opt_out"
2785
+ ]
2772
2786
  },
2773
2787
  "POST /api/v1/email/send": {
2774
2788
  "status": "covered",
@@ -4150,6 +4164,7 @@
4150
4164
  "conduyt_get_contact_table_defaults",
4151
4165
  "conduyt_set_contact_table_defaults",
4152
4166
  "conduyt_get_call_recording_settings",
4167
+ "conduyt_get_calling_hours",
4153
4168
  "conduyt_get_lead_pool_settings",
4154
4169
  "conduyt_update_lead_pool_settings",
4155
4170
  "conduyt_get_lifecycle_settings",
@@ -4407,6 +4422,10 @@
4407
4422
  "conduyt_zoom_test"
4408
4423
  ]
4409
4424
  },
4425
+ "POST /api/v1/short-links": {
4426
+ "status": "planned",
4427
+ "reason": "composer short-links route; the tri-surface rule owes it a real conduyt_* tool, board #466 (MCP/CLI parity for the composer short-links route)"
4428
+ },
4410
4429
  "GET /api/v1/smart-lists": {
4411
4430
  "status": "covered",
4412
4431
  "tools": [
@@ -5018,6 +5037,10 @@
5018
5037
  "status": "excluded",
5019
5038
  "reason": "inbound provider callback receivers (Twilio/Resend/etc.), not client-callable"
5020
5039
  },
5040
+ "POST /api/v1/webhooks/voice/upgrade-join": {
5041
+ "status": "excluded",
5042
+ "reason": "inbound provider callback receivers (Twilio/Resend/etc.), not client-callable"
5043
+ },
5021
5044
  "POST /api/v1/webhooks/voice/vendor-greeting": {
5022
5045
  "status": "excluded",
5023
5046
  "reason": "inbound provider callback receivers (Twilio/Resend/etc.), not client-callable"