conduyt-mcp 4.96.1 → 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
@@ -465,3 +467,7 @@ Scopes can be restricted to specific modules (contacts, deals, tasks, etc.).
465
467
  |----------|----------|-------------|
466
468
  | `CONDUYT_API_URL` | Yes | Base URL (e.g. `https://conduyt.app`) |
467
469
  | `CONDUYT_API_KEY` | Yes | API key starting with `cdy_` |
470
+
471
+ ## Release: the contract gate's catalog pin
472
+
473
+ CI (`.github/workflows/contract-gate.yml`) runs `ENFORCE=1 node scripts/verify-contracts.mjs` against the app's `generated-api-catalog.json` at the commit pinned in `contracts/catalog-source.json` (`repo`, full 40-character `commit`, `catalogPath`). The workflow checks that commit out with the read-only deploy key on the app repository, stored as the repository secret `CONDUYT_APP_DEPLOY_KEY` (the private half; the checkout uses `ssh-key`) and fails closed if the secret or the file is missing. When you publish, set `commit` to the app commit the refreshed `scripts/contracts-snapshot.json` was generated from (the same one named in the CHANGELOG entry), in the same commit as the version bump. CI never uses a live API key. Under `ENFORCE=1` with a catalog source the gate also fails if `scripts/contracts-snapshot.json` differs from that catalog's endpoints in any field, so a pin and snapshot that disagree cannot pass.
package/dist/index.js CHANGED
@@ -3,60 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { createRequire } from "node:module";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { ConduytClient } from "./client.js";
6
- import { registerDiscoveryTools } from "./tools/discovery.js";
7
- import { registerContactTools } from "./tools/contacts.js";
8
- import { registerDealTools } from "./tools/deals.js";
9
- import { registerTaskTools } from "./tools/tasks.js";
10
- import { registerPipelineTools } from "./tools/pipelines.js";
11
- import { registerActivityTools } from "./tools/activities.js";
12
- import { registerAiTools } from "./tools/ai.js";
13
- import { registerAutomationTools } from "./tools/automations.js";
14
- import { registerSequenceTools } from "./tools/sequences.js";
15
- import { registerMessagingTools } from "./tools/messaging.js";
16
- import { registerMessageCheckSettingsTools } from "./tools/message-check-settings.js";
17
- import { registerCallTools } from "./tools/calls.js";
18
- import { registerDialerSettingsTools } from "./tools/dialer-settings.js";
19
- import { registerCompanyTools } from "./tools/companies.js";
20
- import { registerTagTools } from "./tools/tags.js";
21
- import { registerCalendarTools } from "./tools/calendar.js";
22
- import { registerFormTools } from "./tools/forms.js";
23
- import { registerInvoiceTools } from "./tools/invoices.js";
24
- import { registerWebhookTools } from "./tools/webhooks.js";
25
- import { registerFormIntakeTools } from "./tools/form-intakes.js";
26
- import { registerCalendlyTools } from "./tools/calendly.js";
27
- import { registerBoardSettingsTools } from "./tools/board-settings.js";
28
- import { registerMailchimpTools } from "./tools/mailchimp.js";
29
- import { registerStatusMetadataTools } from "./tools/status-metadata.js";
30
- import { registerNotificationTools } from "./tools/notifications.js";
31
- import { registerChatTools } from "./tools/chat.js";
32
- import { registerCustomFieldTools } from "./tools/custom-fields.js";
33
- import { registerSmartListTools } from "./tools/smart-lists.js";
34
- import { registerUserTools } from "./tools/users.js";
35
- import { registerBulkTools } from "./tools/bulk.js";
36
- import { registerBatchOperationTools } from "./tools/batch-operations.js";
37
- import { registerDncTools } from "./tools/dnc.js";
38
- import { registerSavedFilterTools } from "./tools/saved-filters.js";
39
- import { registerDripCampaignTools } from "./tools/drip-campaigns.js";
40
- import { registerScoringTools } from "./tools/scoring.js";
41
- import { registerProductTools } from "./tools/products.js";
42
- import { registerTemplateTools } from "./tools/templates.js";
43
- import { registerDashboardTools } from "./tools/dashboard.js";
44
- import { registerAiExtendedTools } from "./tools/ai-extended.js";
45
- import { registerWorkflowSandboxTools } from "./tools/workflow-sandbox.js";
46
- import { registerPrivacyTools } from "./tools/privacy.js";
47
- import { registerSmartViewTools } from "./tools/smart-views.js";
48
- import { registerAccountExportTools } from "./tools/account-exports.js";
49
- import { registerLifecycleTools } from "./tools/lifecycle.js";
50
- import { registerLeadPoolTools } from "./tools/lead-pool.js";
51
- import { registerReplyCaptureTools } from "./tools/reply-capture.js";
52
- import { registerCallFlowTools } from "./tools/call-flows.js";
53
- import { registerProjectBlueTools } from "./tools/project-blue.js";
54
- import { registerWhatsappTools } from "./tools/whatsapp.js";
55
- import { registerFromkTools } from "./tools/fromk.js";
56
- import { registerZoomTools } from "./tools/zoom.js";
57
- import { registerBookingPageTools } from "./tools/booking-pages.js";
58
- import { registerReportTools } from "./tools/reports.js";
59
- import { registerReportDashboardTools } from "./tools/dashboards.js";
6
+ import { registerAllTools } from "./register-all.js";
60
7
  const apiUrl = process.env.CONDUYT_API_URL;
61
8
  const apiKey = process.env.CONDUYT_API_KEY;
62
9
  if (!apiUrl || !apiKey) {
@@ -82,59 +29,6 @@ const server = new McpServer({
82
29
  name: "conduyt",
83
30
  version: pkg.version,
84
31
  });
85
- registerDiscoveryTools(server, client);
86
- registerContactTools(server, client);
87
- registerDealTools(server, client);
88
- registerTaskTools(server, client);
89
- registerPipelineTools(server, client);
90
- registerActivityTools(server, client);
91
- registerAiTools(server, client);
92
- registerAutomationTools(server, client);
93
- registerSequenceTools(server, client);
94
- registerMessagingTools(server, client);
95
- registerMessageCheckSettingsTools(server, client);
96
- registerCallTools(server, client);
97
- registerDialerSettingsTools(server, client);
98
- registerCompanyTools(server, client);
99
- registerTagTools(server, client);
100
- registerCalendarTools(server, client);
101
- registerFormTools(server, client);
102
- registerInvoiceTools(server, client);
103
- registerWebhookTools(server, client);
104
- registerFormIntakeTools(server, client);
105
- registerCalendlyTools(server, client);
106
- registerBoardSettingsTools(server, client);
107
- registerMailchimpTools(server, client);
108
- registerStatusMetadataTools(server, client);
109
- registerNotificationTools(server, client);
110
- registerChatTools(server, client);
111
- registerCustomFieldTools(server, client);
112
- registerSmartListTools(server, client);
113
- registerUserTools(server, client);
114
- registerBulkTools(server, client);
115
- registerBatchOperationTools(server, client);
116
- registerDncTools(server, client);
117
- registerSavedFilterTools(server, client);
118
- registerDripCampaignTools(server, client);
119
- registerScoringTools(server, client);
120
- registerProductTools(server, client);
121
- registerTemplateTools(server, client);
122
- registerDashboardTools(server, client);
123
- registerAiExtendedTools(server, client);
124
- registerWorkflowSandboxTools(server, client);
125
- registerPrivacyTools(server, client);
126
- registerSmartViewTools(server, client);
127
- registerAccountExportTools(server, client);
128
- registerLifecycleTools(server, client);
129
- registerLeadPoolTools(server, client);
130
- registerReplyCaptureTools(server, client);
131
- registerCallFlowTools(server, client);
132
- registerProjectBlueTools(server, client);
133
- registerWhatsappTools(server, client);
134
- registerFromkTools(server, client);
135
- registerZoomTools(server, client);
136
- registerBookingPageTools(server, client);
137
- registerReportTools(server, client);
138
- registerReportDashboardTools(server, client);
32
+ registerAllTools(server, client);
139
33
  const transport = new StdioServerTransport();
140
34
  await server.connect(transport);
@@ -0,0 +1,4 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { ConduytClient } from "./client.js";
3
+ /** Registers every Conduyt tool on the server; each tool gets its title + MCP annotations from src/tool-annotations.ts. */
4
+ export declare function registerAllTools(server: McpServer, client: ConduytClient): void;
@@ -0,0 +1,116 @@
1
+ import { annotateRegistrations } from "./tool-annotations.js";
2
+ import { registerDiscoveryTools } from "./tools/discovery.js";
3
+ import { registerContactTools } from "./tools/contacts.js";
4
+ import { registerDealTools } from "./tools/deals.js";
5
+ import { registerTaskTools } from "./tools/tasks.js";
6
+ import { registerPipelineTools } from "./tools/pipelines.js";
7
+ import { registerActivityTools } from "./tools/activities.js";
8
+ import { registerAiTools } from "./tools/ai.js";
9
+ import { registerAutomationTools } from "./tools/automations.js";
10
+ import { registerSequenceTools } from "./tools/sequences.js";
11
+ import { registerMessagingTools } from "./tools/messaging.js";
12
+ import { registerMessageCheckSettingsTools } from "./tools/message-check-settings.js";
13
+ import { registerCallTools } from "./tools/calls.js";
14
+ import { registerDialerSettingsTools, registerCallingHoursTools } from "./tools/dialer-settings.js";
15
+ import { registerCompanyTools } from "./tools/companies.js";
16
+ import { registerTagTools } from "./tools/tags.js";
17
+ import { registerCalendarTools } from "./tools/calendar.js";
18
+ import { registerFormTools } from "./tools/forms.js";
19
+ import { registerInvoiceTools } from "./tools/invoices.js";
20
+ import { registerWebhookTools } from "./tools/webhooks.js";
21
+ import { registerFormIntakeTools } from "./tools/form-intakes.js";
22
+ import { registerCalendlyTools } from "./tools/calendly.js";
23
+ import { registerBoardSettingsTools } from "./tools/board-settings.js";
24
+ import { registerMailchimpTools } from "./tools/mailchimp.js";
25
+ import { registerStatusMetadataTools } from "./tools/status-metadata.js";
26
+ import { registerNotificationTools } from "./tools/notifications.js";
27
+ import { registerChatTools } from "./tools/chat.js";
28
+ import { registerCustomFieldTools } from "./tools/custom-fields.js";
29
+ import { registerSmartListTools } from "./tools/smart-lists.js";
30
+ import { registerUserTools } from "./tools/users.js";
31
+ import { registerBulkTools } from "./tools/bulk.js";
32
+ import { registerBatchOperationTools } from "./tools/batch-operations.js";
33
+ import { registerDncTools } from "./tools/dnc.js";
34
+ import { registerEmailOptOutTools } from "./tools/email-opt-out.js";
35
+ import { registerSavedFilterTools } from "./tools/saved-filters.js";
36
+ import { registerDripCampaignTools } from "./tools/drip-campaigns.js";
37
+ import { registerScoringTools } from "./tools/scoring.js";
38
+ import { registerProductTools } from "./tools/products.js";
39
+ import { registerTemplateTools } from "./tools/templates.js";
40
+ import { registerDashboardTools } from "./tools/dashboard.js";
41
+ import { registerAiExtendedTools } from "./tools/ai-extended.js";
42
+ import { registerWorkflowSandboxTools } from "./tools/workflow-sandbox.js";
43
+ import { registerPrivacyTools } from "./tools/privacy.js";
44
+ import { registerSmartViewTools } from "./tools/smart-views.js";
45
+ import { registerAccountExportTools } from "./tools/account-exports.js";
46
+ import { registerLifecycleTools } from "./tools/lifecycle.js";
47
+ import { registerLeadPoolTools } from "./tools/lead-pool.js";
48
+ import { registerReplyCaptureTools } from "./tools/reply-capture.js";
49
+ import { registerCallFlowTools } from "./tools/call-flows.js";
50
+ import { registerProjectBlueTools } from "./tools/project-blue.js";
51
+ import { registerWhatsappTools } from "./tools/whatsapp.js";
52
+ import { registerFromkTools } from "./tools/fromk.js";
53
+ import { registerZoomTools } from "./tools/zoom.js";
54
+ import { registerBookingPageTools } from "./tools/booking-pages.js";
55
+ import { registerReportTools } from "./tools/reports.js";
56
+ import { registerReportDashboardTools } from "./tools/dashboards.js";
57
+ /** Registers every Conduyt tool on the server; each tool gets its title + MCP annotations from src/tool-annotations.ts. */
58
+ export function registerAllTools(server, client) {
59
+ annotateRegistrations(server);
60
+ registerDiscoveryTools(server, client);
61
+ registerContactTools(server, client);
62
+ registerDealTools(server, client);
63
+ registerTaskTools(server, client);
64
+ registerPipelineTools(server, client);
65
+ registerActivityTools(server, client);
66
+ registerAiTools(server, client);
67
+ registerAutomationTools(server, client);
68
+ registerSequenceTools(server, client);
69
+ registerMessagingTools(server, client);
70
+ registerMessageCheckSettingsTools(server, client);
71
+ registerCallTools(server, client);
72
+ registerDialerSettingsTools(server, client);
73
+ registerCallingHoursTools(server, client);
74
+ registerCompanyTools(server, client);
75
+ registerTagTools(server, client);
76
+ registerCalendarTools(server, client);
77
+ registerFormTools(server, client);
78
+ registerInvoiceTools(server, client);
79
+ registerWebhookTools(server, client);
80
+ registerFormIntakeTools(server, client);
81
+ registerCalendlyTools(server, client);
82
+ registerBoardSettingsTools(server, client);
83
+ registerMailchimpTools(server, client);
84
+ registerStatusMetadataTools(server, client);
85
+ registerNotificationTools(server, client);
86
+ registerChatTools(server, client);
87
+ registerCustomFieldTools(server, client);
88
+ registerSmartListTools(server, client);
89
+ registerUserTools(server, client);
90
+ registerBulkTools(server, client);
91
+ registerBatchOperationTools(server, client);
92
+ registerDncTools(server, client);
93
+ registerEmailOptOutTools(server, client);
94
+ registerSavedFilterTools(server, client);
95
+ registerDripCampaignTools(server, client);
96
+ registerScoringTools(server, client);
97
+ registerProductTools(server, client);
98
+ registerTemplateTools(server, client);
99
+ registerDashboardTools(server, client);
100
+ registerAiExtendedTools(server, client);
101
+ registerWorkflowSandboxTools(server, client);
102
+ registerPrivacyTools(server, client);
103
+ registerSmartViewTools(server, client);
104
+ registerAccountExportTools(server, client);
105
+ registerLifecycleTools(server, client);
106
+ registerLeadPoolTools(server, client);
107
+ registerReplyCaptureTools(server, client);
108
+ registerCallFlowTools(server, client);
109
+ registerProjectBlueTools(server, client);
110
+ registerWhatsappTools(server, client);
111
+ registerFromkTools(server, client);
112
+ registerZoomTools(server, client);
113
+ registerBookingPageTools(server, client);
114
+ registerReportTools(server, client);
115
+ registerReportDashboardTools(server, client);
116
+ }
@@ -0,0 +1,33 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ /**
3
+ * MCP tool title + annotations for EVERY registered tool (#359). Hosts and connector directories read these hints to decide
4
+ * which tools may run without asking, and ChatGPT's directory requires all four hints to be explicit booleans.
5
+ *
6
+ * Every tool talks to the Conduyt API only, so openWorldHint is false on every row. The four kinds:
7
+ * ro reads only (readOnly true, destructive false, idempotent true)
8
+ * create adds records / sends / enqueues (readOnly false, destructive false, idempotent false)
9
+ * update changes existing state, same call repeated (readOnly false, destructive false, idempotent true)
10
+ * lands on the same state (PATCH / PUT / settings / reversible archive, disable, unassign)
11
+ * destroy deletes, erases, merges or tears down data (readOnly false, destructive true, idempotent true)
12
+ * Policy (Codex mcp359-r1): readOnlyHint means no Conduyt domain state is written by the call, including side-effect GETs
13
+ * (membership bootstrap, snapshot refresh, run ledger, Activity rows). Operational bookkeeping that is not CRM data (AI-guard audit
14
+ * rows, expiring dry-run confirmation tokens, rate limits) is deliberately outside that policy, so conduyt_ai_compose_email,
15
+ * conduyt_ai_enrich_contact and the conduyt_ai_task_* dry runs stay read-only. destructiveHint is true when ANY branch can delete
16
+ * or irreversibly change data (final decisions, terminal cancels, consent changes, token revocation, replace-mode deletes).
17
+ * A tool whose callback uses more than one HTTP method takes the most conservative combination. A new tool without a row
18
+ * here fails registration (annotateRegistrations) and the build-time test.
19
+ */
20
+ export interface ToolAnnotation {
21
+ title: string;
22
+ readOnlyHint: boolean;
23
+ destructiveHint: boolean;
24
+ idempotentHint: boolean;
25
+ openWorldHint: boolean;
26
+ }
27
+ export declare const TOOL_ANNOTATIONS: Record<string, ToolAnnotation>;
28
+ /**
29
+ * Wraps server.tool so each tool registered afterwards gets its title and annotations from TOOL_ANNOTATIONS. The registered
30
+ * tool object is mutated in place (the same object strictInput() already swaps the input schema on), so the rest of each
31
+ * tool's registration is untouched. A tool with no row throws at registration.
32
+ */
33
+ export declare function annotateRegistrations(server: McpServer): void;
@@ -0,0 +1,391 @@
1
+ const row = (title, readOnlyHint, destructiveHint, idempotentHint) => ({
2
+ title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint: false,
3
+ });
4
+ const ro = (title) => row(title, true, false, true);
5
+ const create = (title) => row(title, false, false, false);
6
+ const update = (title) => row(title, false, false, true);
7
+ const destroy = (title) => row(title, false, true, true);
8
+ export const TOOL_ANNOTATIONS = {
9
+ conduyt_api_catalog: ro("Get API Catalog"),
10
+ conduyt_check_permissions: ro("Check Permissions"),
11
+ conduyt_search: ro("Search CRM Records"),
12
+ conduyt_mcp_coverage: ro("Get MCP Coverage Matrix"),
13
+ conduyt_list_contacts: ro("List Contacts"),
14
+ conduyt_get_contact: ro("Get Contact"),
15
+ conduyt_create_contact: create("Create Contact"),
16
+ conduyt_update_contact: update("Update Contact"),
17
+ conduyt_add_contact_collaborator: update("Add Contact Collaborator"),
18
+ conduyt_remove_contact_collaborator: update("Remove Contact Collaborator"),
19
+ conduyt_delete_contact: destroy("Delete Contact"),
20
+ conduyt_add_tags: update("Add Tags"),
21
+ conduyt_link_contact_to_deal: update("Link Contact to Deal"),
22
+ conduyt_verify_line_type: create("Verify Phone Line Type"),
23
+ conduyt_merge_contacts: destroy("Merge Contacts"),
24
+ conduyt_list_duplicate_proposals: ro("List Duplicate Proposals"),
25
+ conduyt_decide_duplicate_proposal: destroy("Decide Duplicate Proposal"),
26
+ conduyt_list_import_duplicate_proposals: ro("List Import Duplicate Proposals"),
27
+ conduyt_list_deals: ro("List Deals"),
28
+ conduyt_pipeline_health: ro("Get Pipeline Health"),
29
+ conduyt_pipeline_health_fix_deal: update("Fix Deal From Pipeline Health"),
30
+ conduyt_pipeline_health_fix_next_action: create("Set Next Action From Pipeline Health"),
31
+ conduyt_get_deal: ro("Get Deal"),
32
+ conduyt_create_deal: create("Create Deal"),
33
+ conduyt_update_deal: row("Update Deal", false, false, false), // only a repeated close is silent; any other identical PATCH re-dispatches deal.updated and writes a deal.updated audit row (deals/[id]/route.ts:1330,1346)
34
+ conduyt_delete_deal: destroy("Delete Deal"),
35
+ conduyt_import_deals: create("Import Deals"),
36
+ conduyt_add_deal_tags: update("Add Deal Tags"),
37
+ conduyt_list_deal_views: ro("List Deal Views"),
38
+ conduyt_list_tasks: ro("List Tasks"),
39
+ conduyt_create_task: create("Create Task"),
40
+ conduyt_get_task: ro("Get Task"),
41
+ conduyt_update_task: update("Update Task"),
42
+ conduyt_complete_task: update("Complete Task"),
43
+ conduyt_delete_task: destroy("Delete Task"),
44
+ conduyt_list_pipelines: ro("List Pipelines"),
45
+ conduyt_list_archived_pipelines: ro("List Archived Pipelines"),
46
+ conduyt_create_pipeline: create("Create Pipeline"),
47
+ conduyt_update_pipeline: update("Update Pipeline"),
48
+ conduyt_delete_pipeline: destroy("Delete Pipeline"),
49
+ conduyt_archive_pipeline: update("Archive Pipeline"),
50
+ conduyt_restore_pipeline: update("Restore Pipeline"),
51
+ conduyt_reorder_pipelines: update("Reorder Pipelines"),
52
+ conduyt_create_stage: create("Create Stage"),
53
+ conduyt_update_stage: update("Update Stage"),
54
+ conduyt_reorder_stages: update("Reorder Stages"),
55
+ conduyt_delete_stage: destroy("Delete Stage"),
56
+ conduyt_list_activities: ro("List Activities"),
57
+ conduyt_create_activity: create("Create Activity"),
58
+ conduyt_ai_insights: create("Query AI Insights"),
59
+ conduyt_list_automations: ro("List Automations"),
60
+ conduyt_get_automation: ro("Get Automation"),
61
+ conduyt_create_automation: create("Create Automation"),
62
+ conduyt_update_automation: row("Update Automation", false, true, false), // webhook secret rotate/clear revokes the old credential; every config write bumps configRevision (automations/[id]/route.ts:248-275,726-739,1576-1579)
63
+ conduyt_publish_automation: row("Publish Automation", false, false, false), // each repeat can publish another numbered version (automations/[id]/publish/route.ts:218,291)
64
+ conduyt_resolve_names: ro("Resolve Names to IDs"),
65
+ conduyt_list_sequences: ro("List Sequences"),
66
+ conduyt_get_sequence: ro("Get Sequence"),
67
+ conduyt_create_sequence: create("Create Sequence"),
68
+ conduyt_update_sequence: row("Update Sequence", false, false, false), // archived:true assigns a fresh archivedAt on every call (emails/sequences/[id]/route.ts:124-128,235-238)
69
+ conduyt_list_sequence_senders: ro("List Sequence Senders"),
70
+ conduyt_preflight_sequence_enrollment: ro("Preflight Sequence Enrollment"),
71
+ conduyt_pick_sequence_list: ro("Pick Sequence List"),
72
+ conduyt_enroll_in_sequence: create("Enroll in Sequence"),
73
+ conduyt_unenroll_from_sequence: destroy("Unenroll from Sequence"),
74
+ conduyt_list_sequence_enrollments: ro("List Sequence Enrollments"),
75
+ conduyt_get_sequence_stats: ro("Get Sequence Stats"),
76
+ conduyt_check_message: ro("Check Message Grade"),
77
+ conduyt_list_sms_provider_options: ro("List SMS Provider Options"),
78
+ conduyt_sms_transports: ro("List SMS Transports"),
79
+ conduyt_whatsapp_templates: ro("List WhatsApp Templates"),
80
+ conduyt_voice_drop: create("Send Voice Drop"),
81
+ conduyt_send_sms: create("Send SMS"),
82
+ conduyt_send_email: create("Send Email"),
83
+ conduyt_get_conversation: ro("Get Conversation"),
84
+ conduyt_list_messages: ro("List Messages"),
85
+ conduyt_log_message: row("Log Message", false, true, false),
86
+ conduyt_sms_delivery_report: ro("Get SMS Delivery Report"),
87
+ conduyt_sms_drip_conversion_report: ro("Get SMS Drip Conversion Report"),
88
+ conduyt_speed_to_lead_report: ro("Get Speed to Lead Report"),
89
+ conduyt_attribution_report: ro("Get Attribution Report"),
90
+ conduyt_agent_performance_report: ro("Get Agent Performance Report"),
91
+ conduyt_list_targets: ro("List Targets"),
92
+ conduyt_set_targets: update("Set Targets"),
93
+ conduyt_call_performance_report: ro("Get Call Performance Report"),
94
+ conduyt_appointment_report: ro("Get Appointment Report"),
95
+ conduyt_funnel_report: ro("Get Funnel Report"),
96
+ conduyt_get_message_check_minimum_grade: ro("Get Message Check Minimum Grade"),
97
+ conduyt_update_message_check_minimum_grade: update("Update Message Check Minimum Grade"),
98
+ conduyt_list_calls: ro("List Calls"),
99
+ conduyt_search_dialer_history: ro("Search Dialer History"),
100
+ conduyt_search_dialer_calls: ro("Search Dialer Calls"),
101
+ conduyt_count_unknown_callers: ro("Count Unknown Callers"),
102
+ conduyt_link_call: update("Link Call"),
103
+ conduyt_get_call: ro("Get Call"),
104
+ conduyt_get_post_call_insight: ro("Get Post Call Insight"),
105
+ conduyt_sync_local_presence: row("Sync Local Presence", false, false, false), // every call writes lastSyncedAt (local-presence-sync.ts:225)
106
+ conduyt_get_dialer_settings: ro("Get Dialer Settings"),
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
110
+ conduyt_get_call_recording_settings: ro("Get Call Recording Settings"),
111
+ conduyt_update_call_recording_settings: update("Update Call Recording Settings"),
112
+ conduyt_list_companies: ro("List Companies"),
113
+ conduyt_get_company: ro("Get Company"),
114
+ conduyt_create_company: create("Create Company"),
115
+ conduyt_update_company: row("Update Company", false, false, false), // every identical PATCH creates a new company_updated Activity and re-dispatches the update fan-out (companies/[id]/route.ts:362-375)
116
+ conduyt_list_tags: ro("List Tags"),
117
+ conduyt_create_tag: create("Create Tag"),
118
+ conduyt_update_tag: update("Update Tag"),
119
+ conduyt_delete_tag: destroy("Delete Tag"),
120
+ conduyt_merge_tags: destroy("Merge Tags"),
121
+ conduyt_list_calendars: ro("List Calendars"),
122
+ conduyt_list_appointments: ro("List Appointments"),
123
+ conduyt_get_appointment: ro("Get Appointment"),
124
+ conduyt_create_appointment: create("Create Appointment"),
125
+ conduyt_update_appointment: row("Update Appointment", false, true, true), // cancellation permanently removes operational artifacts and cannot be reopened (appointments/[id]/route.ts:331); a repeat lands the same state
126
+ conduyt_appointment_outcome: row("Record Appointment Outcome", false, true, true), // cancelled outcomes cannot be undone; an identical outcome takes the explicit replay path (appointment-outcome-service.ts:515,633)
127
+ conduyt_appointment_outcome_undo: update("Undo Appointment Outcome"),
128
+ conduyt_list_forms: ro("List Forms"),
129
+ conduyt_get_form: ro("Get Form"),
130
+ conduyt_create_form: create("Create Form"),
131
+ conduyt_list_form_submissions: ro("List Form Submissions"),
132
+ conduyt_list_invoices: ro("List Invoices"),
133
+ conduyt_get_invoice: ro("Get Invoice"),
134
+ conduyt_create_invoice: create("Create Invoice"),
135
+ conduyt_update_invoice: row("Update Invoice", false, true, false), // line-item replacement deletes and recreates items with new ids every call (invoices/[id]/route.ts:413)
136
+ conduyt_send_invoice: create("Send Invoice"),
137
+ conduyt_list_webhooks: ro("List Webhooks"),
138
+ conduyt_create_webhook: create("Create Webhook"),
139
+ conduyt_update_webhook: update("Update Webhook"),
140
+ conduyt_delete_webhook: destroy("Delete Webhook"),
141
+ conduyt_cancel_webhook_log: destroy("Cancel Webhook Log"),
142
+ conduyt_get_form_intake_status: ro("Get Form Intake Status"),
143
+ conduyt_list_form_intakes: ro("List Form Intakes"),
144
+ conduyt_create_form_intake: create("Create Form Intake"),
145
+ conduyt_revoke_form_intake: destroy("Revoke Form Intake"),
146
+ conduyt_get_calendly_status: ro("Get Calendly Status"),
147
+ conduyt_get_calendly_import_progress: ro("Get Calendly Import Progress"),
148
+ conduyt_list_calendly_event_types: row("List Calendly Event Types", false, false, false), // refreshes and persists the live provider snapshot (calendly-sync.ts:430); not read-only per the round-2 policy
149
+ conduyt_set_calendly_event_types: row("Set Calendly Event Types", false, false, false), // always advances selectionSavedAt and can retry webhook/import setup (calendly-sync.ts:475)
150
+ conduyt_disconnect_calendly: destroy("Disconnect Calendly"),
151
+ conduyt_get_stage_rules: ro("Get Stage Rules"),
152
+ conduyt_set_stage_rules: update("Set Stage Rules"),
153
+ conduyt_get_deal_card_fields: ro("Get Deal Card Fields"),
154
+ conduyt_set_deal_card_fields: update("Set Deal Card Fields"),
155
+ conduyt_get_settings_revision: ro("Get Settings Revision"),
156
+ conduyt_get_pipeline_rot_days: ro("Get Pipeline Rot Days"),
157
+ conduyt_set_pipeline_rot_days: update("Set Pipeline Rot Days"),
158
+ conduyt_get_mailchimp_settings: ro("Get Mailchimp Settings"),
159
+ conduyt_update_mailchimp_settings: row("Update Mailchimp Settings", false, true, false), // audience change retires the old webhook/member generation; every save advances configGeneration (mailchimp-sync.ts:3684,3725)
160
+ conduyt_list_mailchimp_audiences: ro("List Mailchimp Audiences"),
161
+ conduyt_mailchimp_push_now: create("Push Contacts to Mailchimp Now"),
162
+ conduyt_mailchimp_retry_unsubscribes: row("Retry Mailchimp Unsubscribes", false, false, false), // one pass per call; a repeat can deliver more pending unsubscribes
163
+ conduyt_mailchimp_reconcile_statuses: row("Reconcile Mailchimp Statuses", false, false, false), // each call advances one pass (up to three); restart: true replaces a completed report
164
+ conduyt_mailchimp_confirm_reconciliation: row("Confirm Mailchimp Reconciliation", false, true, true), // final operator decision: deletes recovery ids, clears the report/block, advances the cursor; a repeat fails the precondition with no further effect (mailchimp-sync.ts:4606)
165
+ conduyt_list_status_metadata: ro("List Status Metadata"),
166
+ conduyt_get_status_metadata: ro("Get Status Metadata"),
167
+ conduyt_create_status_metadata: create("Create Status Metadata"),
168
+ conduyt_update_status_metadata: row("Update Status Metadata", false, false, false), // every successful write advances the concurrency version (status-metadata/[id]/route.ts:201)
169
+ conduyt_delete_status_metadata: destroy("Delete Status Metadata"),
170
+ conduyt_import_status_metadata: row("Import Status Metadata", false, true, false), // replace mode deletes and recreates rows with new ids; merge mode advances updatedAt (the concurrency version) on every call
171
+ conduyt_list_notifications: ro("List Notifications"),
172
+ conduyt_create_notification: create("Create Notification"),
173
+ conduyt_list_chat_channels: update("List Chat Channels"),
174
+ conduyt_get_chat_unread: ro("Get Chat Unread Counts"),
175
+ conduyt_list_chat_messages: update("List Chat Messages"),
176
+ conduyt_mark_chat_read: row("Mark Chat Read", false, false, false), // every call re-announces the read receipt to the reader's other tabs and devices, even when the marker did not move (chat/channels/[id]/messages/route.ts:126)
177
+ conduyt_get_chat_realtime_token: ro("Get Chat Realtime Token"),
178
+ conduyt_get_chat_notifications: update("Get Chat Notifications"),
179
+ conduyt_set_chat_notifications: update("Set Chat Notifications"),
180
+ conduyt_update_team_chat_settings: update("Update Team Chat Settings"),
181
+ conduyt_list_custom_fields: ro("List Custom Fields"),
182
+ conduyt_create_custom_field: create("Create Custom Field"),
183
+ conduyt_update_custom_field: update("Update Custom Field"),
184
+ conduyt_delete_custom_field: destroy("Delete Custom Field"),
185
+ conduyt_get_contact_table_defaults: ro("Get Contact Table Defaults"),
186
+ conduyt_set_contact_table_defaults: update("Set Contact Table Defaults"),
187
+ conduyt_list_smart_lists: ro("List Smart Lists"),
188
+ conduyt_get_smart_list: ro("Get Smart List"),
189
+ conduyt_create_smart_list: create("Create Smart List"),
190
+ conduyt_update_smart_list: update("Update Smart List"),
191
+ conduyt_add_contacts_to_smart_list: update("Add Contacts to Smart List"),
192
+ conduyt_get_smart_list_contacts: ro("Get Smart List Contacts"),
193
+ conduyt_list_users: ro("List Users"),
194
+ conduyt_get_user: ro("Get User"),
195
+ conduyt_create_user: create("Create User"),
196
+ conduyt_update_user: row("Update User", false, false, false), // phone supplied deletes and recreates the auto QuickConnect row, a fresh UUID each call (users/[id]/route.ts:579-594)
197
+ conduyt_get_user_stats: ro("Get User Stats"),
198
+ conduyt_invite_user: create("Invite User"),
199
+ conduyt_deactivate_user: update("Deactivate User"),
200
+ conduyt_reactivate_user: update("Reactivate User"),
201
+ conduyt_ai_import_contacts: create("Import Contacts With AI"),
202
+ conduyt_export_contacts: create("Export Contacts"),
203
+ conduyt_export_deals: create("Export Deals"),
204
+ conduyt_bulk_tag_contacts: update("Bulk Tag Contacts"),
205
+ conduyt_bulk_untag_contacts: update("Bulk Untag Contacts"),
206
+ conduyt_bulk_edit_contacts: row("Bulk Edit Contacts", false, true, true), // optedOutSms / optedOutEmail / doNotContact can clear consent and re-enable outreach; switches are state-predicated absolute writes, so a repeat of the same body lands the same state
207
+ conduyt_bulk_update_contacts: row("Bulk Update Contacts", false, true, true), // same consent fields per row as bulk edit; state-predicated switches, a repeat lands the same state
208
+ conduyt_bulk_delete_contacts: destroy("Bulk Delete Contacts"),
209
+ conduyt_bulk_delete_contacts_matching: destroy("Bulk Delete Contacts Matching"),
210
+ conduyt_bulk_dnc_contacts: row("Bulk Set Do Not Contact", false, true, true), // action "remove" tombstones suppression rows (re-enables outreach); add skips rows already suppressed and remove matches only removedAt null, so a repeat changes nothing
211
+ conduyt_bulk_edit_deals: row("Bulk Edit Deals", false, false, false), // a stage-only update to the existing won/lost stage assigns a fresh wonAt/lostAt on every call (app/api/v1/bulk/deals/edit/route.ts:216-233)
212
+ conduyt_bulk_update_deals: row("Bulk Update Deals", false, false, false), // a stage-only update to the existing won/lost stage assigns a fresh wonAt/lostAt on every call (app/api/v1/bulk/deals/update/route.ts:195-212)
213
+ conduyt_import_preflight: ro("Check Contact Import Before Running"),
214
+ conduyt_create_batch_operation: row("Create Batch Operation", false, true, false),
215
+ conduyt_list_batch_operations: ro("List Batch Operations"),
216
+ conduyt_get_batch_operation: ro("Get Batch Operation"),
217
+ conduyt_cancel_batch_operation: destroy("Cancel Batch Operation"),
218
+ conduyt_list_dnc: ro("List DNC"),
219
+ conduyt_add_dnc: update("Add DNC"),
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
224
+ conduyt_list_saved_filters: ro("List Saved Filters"),
225
+ conduyt_create_saved_filter: create("Create Saved Filter"),
226
+ conduyt_update_saved_filter: update("Update Saved Filter"),
227
+ conduyt_delete_saved_filter: destroy("Delete Saved Filter"),
228
+ conduyt_list_drip_campaigns: ro("List Drip Campaigns"),
229
+ conduyt_get_drip_campaign: ro("Get Drip Campaign"),
230
+ conduyt_create_drip_campaign: create("Create Drip Campaign"),
231
+ conduyt_update_drip_campaign: update("Update Drip Campaign"),
232
+ conduyt_list_drip_enrollments: ro("List Drip Enrollments"),
233
+ conduyt_batch_pause_enrollments: update("Pause Drip Enrollments in Bulk"),
234
+ conduyt_batch_resume_enrollments: update("Resume Drip Enrollments in Bulk"),
235
+ conduyt_batch_stop_enrollments: destroy("Stop Drip Enrollments in Bulk"),
236
+ conduyt_cancel_drip_enrollment_barrier: row("Cancel Drip Enrollment Send Barrier", false, true, false),
237
+ conduyt_list_scoring_rules: ro("List Scoring Rules"),
238
+ conduyt_create_scoring_rule: create("Create Scoring Rule"),
239
+ conduyt_update_scoring_rule: row("Update Scoring Rule", false, false, false), // queues a recalculation on every update (scoring-rules/[id]/route.ts:78-87; data-model-jobs.ts:133-165)
240
+ conduyt_delete_scoring_rule: destroy("Delete Scoring Rule"),
241
+ conduyt_recalculate_scores: row("Recalculate Scores", false, false, false), // after a prior job completes the same call creates another job (data-model-jobs.ts:133)
242
+ conduyt_list_products: ro("List Products"),
243
+ conduyt_get_product: ro("Get Product"),
244
+ conduyt_create_product: create("Create Product"),
245
+ conduyt_update_product: update("Update Product"),
246
+ conduyt_list_templates: ro("List Templates"),
247
+ conduyt_get_template: ro("Get Template"),
248
+ conduyt_create_template: create("Create Template"),
249
+ conduyt_update_template: update("Update Template"),
250
+ conduyt_dashboard: ro("Get CRM Dashboard Summary"),
251
+ conduyt_ai_compose_email: ro("Compose Email With AI"),
252
+ conduyt_ai_enrich_contact: ro("Enrich Contact With AI"),
253
+ conduyt_ai_summarize_contact: ro("Summarize Contact With AI"),
254
+ conduyt_ai_chat: ro("Chat With AI Assistant"),
255
+ conduyt_ai_improve_email: ro("Improve Email With AI"),
256
+ conduyt_ai_deal_insights: ro("Get AI Deal Insights"),
257
+ conduyt_ai_conversation_intelligence: create("Analyze Conversation With AI"),
258
+ conduyt_ai_call_disposition: ro("Suggest Call Disposition"),
259
+ conduyt_ai_agenda: ro("Get AI Agenda"),
260
+ conduyt_ai_plan_actions: ro("Plan Copilot Actions"),
261
+ conduyt_ai_execute_actions: create("Execute Copilot Actions"),
262
+ conduyt_reply_suggestion: ro("Get Suggested Reply"),
263
+ conduyt_reply_suggestion_decide: row("Record Suggested Reply Decision", false, true, true), // final decision: dismissed and sent cannot be reversed; a repeat is refused before any further write (message-insights.ts:480), so idempotent
264
+ conduyt_ai_daily_brief: ro("Get AI Daily Brief"),
265
+ conduyt_ai_next_actions: ro("Get AI Next Actions"),
266
+ conduyt_ai_feed: ro("Get AI Feed"),
267
+ conduyt_ai_usage: ro("Get AI Usage"),
268
+ conduyt_ai_task_import_and_enroll: ro("Preview AI Import and Enroll"),
269
+ conduyt_ai_task_segment_and_act: ro("Preview AI Segment and Act"),
270
+ conduyt_ai_task_pipeline_cleanup: ro("Preview AI Pipeline Cleanup"),
271
+ conduyt_workflow_validate: ro("Validate Workflow"),
272
+ conduyt_workflow_simulate: ro("Simulate Workflow"),
273
+ conduyt_workflow_promote: row("Validate and Publish Workflow", false, false, false), // each repeat can publish another numbered version (automations/[id]/publish/route.ts:218,291)
274
+ conduyt_export_contact_data: create("Export Contact Data"),
275
+ conduyt_forget_contact: destroy("Erase Contact (GDPR)"),
276
+ conduyt_list_smart_views: ro("List Smart Views"),
277
+ conduyt_update_smart_view_dialing: update("Update Smart View Dialing"),
278
+ conduyt_get_smart_dialing_order: ro("Get Smart Dialing Order"),
279
+ conduyt_set_smart_dialing_order: update("Set Smart Dialing Order"),
280
+ conduyt_set_smart_view_audience: row("Set Smart View Audience", false, false, false), // the tool always sends members (src/tools/smart-views.ts:135); app/api/v1/smart-views/[id]/route.ts:254-279 rewrites the audience on every call
281
+ conduyt_reorder_smart_views: update("Reorder Smart Views"),
282
+ conduyt_move_smart_view: update("Move Smart View"),
283
+ conduyt_scheduled_export_get_settings: ro("Get Scheduled Export Settings"),
284
+ conduyt_scheduled_export_update_settings: update("Update Scheduled Export Settings"),
285
+ conduyt_account_exports_list: ro("List Account Exports"),
286
+ conduyt_get_lifecycle_settings: ro("Get Lifecycle Settings"),
287
+ conduyt_update_intake_deals: update("Update Intake Deals Setting"),
288
+ conduyt_update_deal_working_mode: update("Update Deal Working Mode"),
289
+ conduyt_update_lost_propagation: update("Update Lost Propagation Setting"),
290
+ conduyt_update_booking_creates_own_card: update("Update Booking Creates Own Card Setting"),
291
+ conduyt_update_disposition_status_map: update("Update Disposition Status Map"),
292
+ conduyt_get_agent_statuses: ro("Get Agent Statuses"),
293
+ conduyt_update_agent_statuses: update("Update Agent Statuses"),
294
+ conduyt_update_master_statuses: update("Update Master Statuses"),
295
+ conduyt_list_pool_filter_sets: ro("List Pool Filter Sets"),
296
+ conduyt_create_pool_filter_set: create("Create Pool Filter Set"),
297
+ conduyt_update_pool_filter_set: update("Update Pool Filter Set"),
298
+ conduyt_delete_pool_filter_set: destroy("Delete Pool Filter Set"),
299
+ conduyt_get_lead_pool_settings: ro("Get Lead Pool Settings"),
300
+ conduyt_update_lead_pool_settings: update("Update Lead Pool Settings"),
301
+ conduyt_list_up_for_grabs: ro("List Up for Grabs Leads"),
302
+ conduyt_reply_capture_get: row("Get Reply Capture", false, true, false), // reconciliation can move a live domain to degraded, which retires it permanently; the outcome depends on provider state at call time
303
+ conduyt_reply_capture_setup: update("Set Up Reply Capture"),
304
+ conduyt_reply_capture_verify: row("Verify Reply Capture", false, false, false), // kicks provider verification; a later identical poll can promote the domain (email-domains/reply-capture/verify/route.ts:96)
305
+ conduyt_reply_capture_remove: destroy("Remove Reply Capture"),
306
+ conduyt_list_call_flows: ro("List Call Flows"),
307
+ conduyt_get_call_flow: ro("Get Call Flow"),
308
+ conduyt_create_call_flow: create("Create Call Flow"),
309
+ conduyt_update_call_flow: update("Update Call Flow"),
310
+ conduyt_delete_call_flow: destroy("Delete Call Flow"),
311
+ conduyt_validate_call_flow: ro("Validate Call Flow"),
312
+ conduyt_publish_call_flow: update("Publish Call Flow"),
313
+ conduyt_unpublish_call_flow: update("Unpublish Call Flow"),
314
+ conduyt_simulate_call_flow: ro("Simulate Call Flow"),
315
+ conduyt_call_flow_roster: ro("Get Call Flow Roster"),
316
+ conduyt_list_ring_groups: ro("List Ring Groups"),
317
+ conduyt_get_ring_group: ro("Get Ring Group"),
318
+ conduyt_create_ring_group: create("Create Ring Group"),
319
+ conduyt_update_ring_group: update("Update Ring Group"),
320
+ conduyt_delete_ring_group: destroy("Delete Ring Group"),
321
+ conduyt_release_ring_group_number: destroy("Release Ring Group Number"),
322
+ conduyt_list_call_flow_numbers: ro("List Call Flow Numbers"),
323
+ conduyt_get_call_flow_wiring: ro("Get Call Flow Wiring"),
324
+ conduyt_wire_call_flow_number: update("Wire Call Flow Number"),
325
+ conduyt_release_call_flow_tombstone: destroy("Release Call Flow Tombstone"),
326
+ conduyt_upload_call_flow_audio: create("Upload Call Flow Audio"),
327
+ conduyt_list_call_flow_functions: ro("List Call Flow Functions"),
328
+ conduyt_create_call_flow_function: create("Create Call Flow Function"),
329
+ conduyt_update_call_flow_function: row("Update Call Flow Function", false, true, true), // a new secret revokes the old signing credential; the secret is caller-supplied and the PATCH is a pure set, so a repeat of the same body lands the same state
330
+ conduyt_delete_call_flow_function: destroy("Delete Call Flow Function"),
331
+ conduyt_test_call_flow_function: create("Test Call Flow Function"),
332
+ conduyt_project_blue_status: ro("Project Blue Status"),
333
+ conduyt_project_blue_lines: ro("Project Blue Lines"),
334
+ conduyt_project_blue_assign_line: row("Assign Project Blue Line", false, false, false), // lib/project-blue-lines.ts:186-195 deletes the current same-line/same-user assignment and creates a new row (new UUID, lastInboundSyncAt cleared) on every identical call
335
+ conduyt_project_blue_voice_status: ro("Project Blue Voice Status"),
336
+ conduyt_project_blue_voice_connect: row("Connect or Disconnect Project Blue Voice", false, true, false), // rewrites health timestamps and reopens unreconcilable vendor calls (project-blue-voice.ts:146, settings/project-blue/voice/route.ts:100)
337
+ conduyt_whatsapp_status: ro("WhatsApp Status"),
338
+ conduyt_whatsapp_senders: ro("WhatsApp Senders"),
339
+ conduyt_whatsapp_connect: destroy("Connect or Disconnect WhatsApp"),
340
+ conduyt_fromk_status: ro("FromK.ai Status"),
341
+ conduyt_fromk_lines: row("FromK.ai Lines", false, false, false), // persists a provider-dependent line snapshot (fromk-transport.ts:110)
342
+ conduyt_fromk_connect: row("Connect or Disconnect FromK.ai", false, true, false), // even the same key refreshes lines and deletes all contact links (fromk-transport.ts:87)
343
+ conduyt_fromk_webhook_secret: destroy("Set or Clear FromK.ai Webhook Secret"),
344
+ conduyt_zoom_status: ro("Zoom Status"),
345
+ conduyt_zoom_connect: destroy("Connect or Disconnect Zoom"),
346
+ conduyt_zoom_test: ro("Test Zoom Connection"),
347
+ conduyt_list_booking_pages: ro("List Booking Pages"),
348
+ conduyt_get_booking_page: ro("Get Booking Page"),
349
+ conduyt_create_booking_page: create("Create Booking Page"),
350
+ conduyt_update_booking_page: row("Update Booking Page", false, true, true), // owner transfer clears the iCal feed token (booking-pages/[id]/route.ts:266); the tool no longer offers enableIcalFeed (the route refuses it for API-key calls, #401), so a repeat lands the same state
351
+ conduyt_report_fields: ro("Report Fields"),
352
+ conduyt_list_reports: ro("List Reports"),
353
+ conduyt_create_report: create("Create Report"),
354
+ conduyt_run_report: create("Run Saved Report"),
355
+ conduyt_preview_report: ro("Preview Report"),
356
+ conduyt_update_report: update("Update Report"),
357
+ conduyt_export_report: create("Export Report File"),
358
+ conduyt_list_report_schedules: ro("List Report Schedules"),
359
+ conduyt_create_report_schedule: create("Create Report Schedule"),
360
+ conduyt_update_report_schedule: row("Update Report Schedule", false, false, false), // nextRunAt recomputed from now, slotAt/retryCount reset on any schedule field (reports/subscriptions/[sid]/route.ts:64-79)
361
+ conduyt_delete_report_schedule: destroy("Delete Report Schedule"),
362
+ conduyt_send_report_schedule_now: create("Send Report Schedule Now"),
363
+ conduyt_report_schedule_deliveries: ro("Report Schedule Deliveries"),
364
+ conduyt_list_dashboards: ro("List Dashboards"),
365
+ conduyt_create_dashboard: create("Create Dashboard"),
366
+ conduyt_get_dashboard: ro("Get Dashboard"),
367
+ conduyt_update_dashboard: update("Update Dashboard"),
368
+ conduyt_delete_dashboard: destroy("Delete Dashboard"),
369
+ conduyt_add_dashboard_tile: create("Add Dashboard Tile"),
370
+ conduyt_update_dashboard_tile: update("Update Dashboard Tile"),
371
+ conduyt_remove_dashboard_tile: destroy("Remove Dashboard Tile"),
372
+ conduyt_run_dashboard: ro("Run Dashboard"),
373
+ };
374
+ /**
375
+ * Wraps server.tool so each tool registered afterwards gets its title and annotations from TOOL_ANNOTATIONS. The registered
376
+ * tool object is mutated in place (the same object strictInput() already swaps the input schema on), so the rest of each
377
+ * tool's registration is untouched. A tool with no row throws at registration.
378
+ */
379
+ export function annotateRegistrations(server) {
380
+ const original = server.tool.bind(server);
381
+ server.tool = (name, ...rest) => {
382
+ const a = TOOL_ANNOTATIONS[name];
383
+ if (!a)
384
+ throw new Error(`tool ${name} has no row in src/tool-annotations.ts: add a title and its four MCP hints`);
385
+ const registered = original(name, ...rest);
386
+ const { title, ...hints } = a;
387
+ registered.title = title;
388
+ registered.annotations = { title, ...hints };
389
+ return registered;
390
+ };
391
+ }
@@ -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 : {};
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { formatResult } from "../client.js";
3
+ import { strictInput } from "./strict.js";
3
4
  /**
4
5
  * Booking pages (Conduyt #72, 2026-09-15): the public scheduling pages. Until now an AI operator could read appointments
5
6
  * but not the pages that create them; these tools cover the page itself and its video option (videoProvider "zoom" =
@@ -67,7 +68,6 @@ const updateOnlyFields = {
67
68
  gdprText: z.string().max(2000).nullable().optional(),
68
69
  gdprLinkUrl: z.string().url().nullable().optional(),
69
70
  waitlistEnabled: z.boolean().optional(),
70
- enableIcalFeed: z.boolean().optional().describe("true issues a new iCal feed token, false revokes it"),
71
71
  };
72
72
  export function registerBookingPageTools(server, client) {
73
73
  server.tool("conduyt_list_booking_pages", "List the account's booking pages (GET /api/v1/booking-pages): slug, title, host, duration, active, video option. Requires calendar:view.", { activeOnly: z.boolean().optional().describe("only pages accepting bookings") }, async ({ activeOnly }) => {
@@ -78,12 +78,12 @@ export function registerBookingPageTools(server, client) {
78
78
  const result = await client.get(`/api/v1/booking-pages/${id}`);
79
79
  return formatResult(result);
80
80
  });
81
- server.tool("conduyt_create_booking_page", "Create a booking page (POST /api/v1/booking-pages): title, slug, userId (host) and durationMinutes at least; optional written location, videoProvider 'zoom' + videoHostEmail for one Zoom meeting per booking, availabilityRules override, embedAllowedOrigins, leadSource, brandName. Requires calendar:edit (admin/owner).", { ...pageFields, title: z.string().min(1).max(200), slug: z.string().min(1).max(100), userId: z.string().uuid(), durationMinutes: z.number().int().min(5).max(480) }, async (params) => {
81
+ strictInput(server.tool("conduyt_create_booking_page", "Create a booking page (POST /api/v1/booking-pages): title, slug, userId (host) and durationMinutes at least; optional written location, videoProvider 'zoom' + videoHostEmail for one Zoom meeting per booking, availabilityRules override, embedAllowedOrigins, leadSource, brandName. Requires calendar:edit (admin/owner).", { ...pageFields, title: z.string().min(1).max(200), slug: z.string().min(1).max(100), userId: z.string().uuid(), durationMinutes: z.number().int().min(5).max(480) }, async (params) => {
82
82
  const result = await client.post("/api/v1/booking-pages", params);
83
83
  return formatResult(result);
84
- });
85
- server.tool("conduyt_update_booking_page", "Update a booking page (PATCH /api/v1/booking-pages/{id}): only the fields given change. videoProvider 'zoom' turns on one Zoom meeting per booking (null turns it off and clears the host); videoHostEmail must be a user of the connected Zoom account (null = the app's own user). Requires calendar:edit (admin/owner).", { id: z.string().uuid().describe("booking page UUID"), ...pageFields, ...updateOnlyFields }, async ({ id, ...updates }) => {
84
+ }));
85
+ strictInput(server.tool("conduyt_update_booking_page", "Update a booking page (PATCH /api/v1/booking-pages/{id}): only the fields given change. videoProvider 'zoom' turns on one Zoom meeting per booking (null turns it off and clears the host); videoHostEmail must be a user of the connected Zoom account (null = the app's own user). The iCal feed token cannot be issued or revoked through the API; the page owner does that in the app. Requires calendar:edit (admin/owner).", { id: z.string().uuid().describe("booking page UUID"), ...pageFields, ...updateOnlyFields }, async ({ id, ...updates }) => {
86
86
  const result = await client.patch(`/api/v1/booking-pages/${id}`, updates);
87
87
  return formatResult(result);
88
- });
88
+ }));
89
89
  }
@@ -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.96.1",
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-22T20:22:51.966Z",
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"