conduyt-mcp 4.74.0 → 4.76.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 +38 -5
- package/dist/client.js +16 -3
- package/dist/index.js +6 -0
- package/dist/tools/automations.js +2 -2
- package/dist/tools/board-settings.d.ts +16 -0
- package/dist/tools/board-settings.js +90 -0
- package/dist/tools/deals.d.ts +2 -0
- package/dist/tools/deals.js +50 -3
- package/dist/tools/lifecycle.d.ts +12 -0
- package/dist/tools/lifecycle.js +28 -0
- package/dist/tools/mailchimp.d.ts +12 -0
- package/dist/tools/mailchimp.js +39 -0
- package/dist/tools/pipelines.d.ts +6 -0
- package/dist/tools/pipelines.js +64 -8
- package/dist/tools/status-metadata.d.ts +11 -0
- package/dist/tools/status-metadata.js +84 -0
- package/dist/tools/strict.d.ts +9 -0
- package/dist/tools/strict.js +19 -0
- package/package.json +1 -1
- package/scripts/coverage-manifest.json +5 -0
- package/scripts/coverage-matrix.json +129 -20
package/README.md
CHANGED
|
@@ -23,7 +23,12 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
|
|
|
23
23
|
### Deals
|
|
24
24
|
| Tool | Description |
|
|
25
25
|
|------|-------------|
|
|
26
|
-
| `conduyt_list_deals` | List deals by pipeline, stage, or assignee |
|
|
26
|
+
| `conduyt_list_deals` | List deals by pipeline, stage, or assignee; rows carry `hasNextStep` and `nextAppointmentAt`. With `health_summary=true` it answers the Pipeline health strip for the same filtered set (`totalOpenDeals`, `atRiskValue`, `checks[]`, `deals`); `health_check`, `health_limit`, `health_offset`, `health_cursor` (the previous `nextCursor`, passed back unchanged) and `health_rows_only` page one check's deals (rows-only answers `{data:{deals:{check, offset, items, hasMore, nextCursor}}}`) |
|
|
27
|
+
| `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` |
|
|
28
|
+
| `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`); other stages untouched; revision-aware, retried once on a 409 |
|
|
29
|
+
| `conduyt_get_deal_card_fields` / `conduyt_set_deal_card_fields` | Read / replace the kanban card fields (1-8, built-ins or `custom:<fieldKey>`) |
|
|
30
|
+
| `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` |
|
|
31
|
+
| `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 |
|
|
27
32
|
| `conduyt_get_deal` | Get deal details with history, including custom-field values |
|
|
28
33
|
| `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 |
|
|
29
34
|
| `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 |
|
|
@@ -38,13 +43,17 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
|
|
|
38
43
|
### Pipelines
|
|
39
44
|
| Tool | Description |
|
|
40
45
|
|------|-------------|
|
|
41
|
-
| `conduyt_list_pipelines` | List pipelines with stages |
|
|
42
|
-
| `
|
|
43
|
-
| `
|
|
46
|
+
| `conduyt_list_pipelines` | List active pipelines with stages (each carries `color`, `automationOnly`, `sortOrder`) |
|
|
47
|
+
| `conduyt_list_archived_pipelines` | List archived pipelines (`GET /pipelines?archived=true`, owner / admin key): `id`, `name`, `archivedName`, `color`, `archivedAt`, `openDeals`, `stageCount` |
|
|
48
|
+
| `conduyt_create_pipeline` | Create a pipeline, optionally with its full set of stages (no stage-count cap; up to 50) and a palette `color` |
|
|
49
|
+
| `conduyt_update_pipeline` | Rename a pipeline, change its description, set it as default, set `automationOnly` (only automations, API keys and owners / admins move or edit its cards), `color` (one of the 10 palette colours) or `sortOrder` (owner / admin key) |
|
|
50
|
+
| `conduyt_archive_pipeline` | Archive a pipeline and move its open deals to another pipeline in one transaction (`moveOpenDealsTo`, optional `expectedSettingsRevision`); refusals carry `stale_settings_revision`, `target_not_open`, `dpp_post_close`, `archive_too_large`, `target_archived`, `intake_target`, `pipeline_archived`; an already-archived pipeline answers `alreadyArchived` |
|
|
51
|
+
| `conduyt_restore_pipeline` | Restore an archived pipeline (appended last, never the default; 409 `name_taken` when an active pipeline has its name) |
|
|
52
|
+
| `conduyt_reorder_pipelines` / `conduyt_reorder_stages` | Set the board-menu order of the pipelines / a pipeline's column order in one transaction with `expectedRevision` (409 `order_changed` carries the current revision) |
|
|
44
53
|
| `conduyt_delete_pipeline` | Delete a pipeline (move/remove its deals first) |
|
|
45
54
|
| `conduyt_create_stage` | Append a stage to a pipeline (e.g. 'On Hold', 'Declined/Dead') |
|
|
46
55
|
| `conduyt_update_stage` | Rename, recolor, reorder, or change won/lost flags on a stage |
|
|
47
|
-
| `conduyt_delete_stage` | Delete
|
|
56
|
+
| `conduyt_delete_stage` | Delete a stage from a pipeline; `moveTo` moves its live deals to another stage first, `expectedSettingsRevision` refuses the delete when settings changed (409 `stale_settings_revision`; the answer then carries the new `settingsRevision`) |
|
|
48
57
|
|
|
49
58
|
### Activities
|
|
50
59
|
| Tool | Description |
|
|
@@ -159,6 +168,30 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
|
|
|
159
168
|
| `conduyt_create_form_intake` | Create an intake address for one Gravity Forms / WPForms / Elementor form (returns its full URL) |
|
|
160
169
|
| `conduyt_revoke_form_intake` | Revoke an intake address (the row is kept for history) |
|
|
161
170
|
|
|
171
|
+
### Status metadata
|
|
172
|
+
| Tool | Description |
|
|
173
|
+
|------|-------------|
|
|
174
|
+
| `conduyt_list_status_metadata` / `conduyt_get_status_metadata` | Read the status taxonomy rows (one per stage and code); `updatedAt` is the version for `expectedUpdatedAt` |
|
|
175
|
+
| `conduyt_create_status_metadata` | Create a row (`stageId`, `code`, `bucket` slugs, `lifecycleStage`, drip fields, `isTerminal`, `notes`) |
|
|
176
|
+
| `conduyt_update_status_metadata` | Update a row; `expectedUpdatedAt` refuses a row changed or moved since (409 `metadata_changed` with the current row); 404 `status_metadata_not_found` |
|
|
177
|
+
| `conduyt_delete_status_metadata` | Delete a row (`expectedUpdatedAt` as above) |
|
|
178
|
+
| `conduyt_import_status_metadata` | Set-based import (max 1000; `merge` or `replace`); read `errors[]` even on a 200; 409 `pipeline_archived` when an item names a stage of an archived pipeline |
|
|
179
|
+
|
|
180
|
+
### Mailchimp (API key scope `integrations`)
|
|
181
|
+
| Tool | Description |
|
|
182
|
+
|------|-------------|
|
|
183
|
+
| `conduyt_get_mailchimp_settings` | The Mailchimp connection: audience, source, settings and sync status (`connected: false` when none) |
|
|
184
|
+
| `conduyt_update_mailchimp_settings` | `PUT` the audience (`listId`), source (smart list or tag), `statusIfNew`, `unsubscribeSetsOptOut`; only the fields sent change |
|
|
185
|
+
| `conduyt_list_mailchimp_audiences` | The connected account's audiences (`id`, `name`, `memberCount`) |
|
|
186
|
+
| `conduyt_mailchimp_push_now` | Run one sync pass now (`ran` is false while another pass holds the connection) |
|
|
187
|
+
| `conduyt_mailchimp_retry_unsubscribes` | Retry undelivered unsubscribes (one per account per 10 minutes: 429 `retry_cooldown`) |
|
|
188
|
+
| `conduyt_mailchimp_reconcile_statuses` | Full reconciliation of a blocked unsubscribe check (`restart: true` replaces a completed run; else 409 `reconciliation_completed`); owner / admin key |
|
|
189
|
+
| `conduyt_mailchimp_confirm_reconciliation` | Confirm the reconciliation report and resume the check; owner / admin key |
|
|
190
|
+
|
|
191
|
+
Timeouts: the Mailchimp write/action tools and `conduyt_import_status_metadata` use a 60 s per-call timeout and `conduyt_archive_pipeline` 75 s (the client default is 30 s), above the server budgets; if a call still reports a timeout the server may have applied it, so re-read (`conduyt_get_mailchimp_settings`, `conduyt_list_pipelines` / `conduyt_list_archived_pipelines`, `conduyt_list_status_metadata`) before repeating it. The destructive tools (both deletes, archive, restore, reorders, status-metadata import and writes, Mailchimp settings and reconcile) take a strict input: a wire spelling such as `expected_updated_at` or a typo is InvalidParams and nothing is sent; an import item with an unknown key is refused as a whole, and `mode: "replace"` with no items (which would delete every row) is refused before any request. Tools without arguments (push, retry, confirm-reconciliation, the reads) reject any argument too. A body that stalls after the headers until the deadline is reported as the same "may have applied it" timeout, never retried for a mutation.
|
|
192
|
+
|
|
193
|
+
The Mailchimp OAuth connect and callback are browser-only: connect Mailchimp in the app (Settings > Integrations), then configure it here.
|
|
194
|
+
|
|
162
195
|
### Notifications
|
|
163
196
|
| Tool | Description |
|
|
164
197
|
|------|-------------|
|
package/dist/client.js
CHANGED
|
@@ -29,6 +29,9 @@ const DEFAULT_RETRY_BASE_MS = 500;
|
|
|
29
29
|
const MAX_GET_RETRIES = 2;
|
|
30
30
|
const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
|
|
31
31
|
const MAX_RETRY_AFTER_MS = 10_000;
|
|
32
|
+
const isAbort = (err) => err?.name === "TimeoutError" || err?.name === "AbortError";
|
|
33
|
+
/** what a timeout of a MUTATION means: the server may have applied it */
|
|
34
|
+
const AMBIGUOUS = " The server may have applied it: read the current state before repeating it.";
|
|
32
35
|
export class ConduytClient {
|
|
33
36
|
baseUrl;
|
|
34
37
|
apiKey;
|
|
@@ -76,11 +79,11 @@ export class ConduytClient {
|
|
|
76
79
|
});
|
|
77
80
|
}
|
|
78
81
|
catch (err) {
|
|
79
|
-
if (err
|
|
82
|
+
if (isAbort(err)) {
|
|
80
83
|
throw new ConduytApiError({
|
|
81
|
-
message: `Conduyt API timeout: no response within ${timeoutMs}ms for ${method} ${path}
|
|
84
|
+
message: `Conduyt API timeout: no response within ${timeoutMs}ms for ${method} ${path}` + (method === "GET" ? "" : AMBIGUOUS),
|
|
82
85
|
code: "timeout",
|
|
83
|
-
retryable:
|
|
86
|
+
retryable: method === "GET",
|
|
84
87
|
cause: err,
|
|
85
88
|
});
|
|
86
89
|
}
|
|
@@ -126,6 +129,16 @@ export class ConduytClient {
|
|
|
126
129
|
catch (err) {
|
|
127
130
|
if (err instanceof ConduytApiError)
|
|
128
131
|
throw err;
|
|
132
|
+
// headers arrived but the body stalled until the deadline: for a mutation the write may well have been applied
|
|
133
|
+
if (isAbort(err)) {
|
|
134
|
+
throw new ConduytApiError({
|
|
135
|
+
message: `Conduyt API timeout: the response body for ${method} ${path} did not finish within ${timeoutMs}ms` + (method === "GET" ? "" : AMBIGUOUS),
|
|
136
|
+
status: res.status,
|
|
137
|
+
code: "timeout",
|
|
138
|
+
retryable: method === "GET",
|
|
139
|
+
cause: err,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
129
142
|
throw new ConduytApiError({
|
|
130
143
|
message: `Conduyt API network error reading response for ${method} ${path}: ${err?.message ?? String(err)}`,
|
|
131
144
|
code: "network",
|
package/dist/index.js
CHANGED
|
@@ -22,6 +22,9 @@ import { registerFormTools } from "./tools/forms.js";
|
|
|
22
22
|
import { registerInvoiceTools } from "./tools/invoices.js";
|
|
23
23
|
import { registerWebhookTools } from "./tools/webhooks.js";
|
|
24
24
|
import { registerFormIntakeTools } from "./tools/form-intakes.js";
|
|
25
|
+
import { registerBoardSettingsTools } from "./tools/board-settings.js";
|
|
26
|
+
import { registerMailchimpTools } from "./tools/mailchimp.js";
|
|
27
|
+
import { registerStatusMetadataTools } from "./tools/status-metadata.js";
|
|
25
28
|
import { registerNotificationTools } from "./tools/notifications.js";
|
|
26
29
|
import { registerCustomFieldTools } from "./tools/custom-fields.js";
|
|
27
30
|
import { registerSmartListTools } from "./tools/smart-lists.js";
|
|
@@ -95,6 +98,9 @@ registerFormTools(server, client);
|
|
|
95
98
|
registerInvoiceTools(server, client);
|
|
96
99
|
registerWebhookTools(server, client);
|
|
97
100
|
registerFormIntakeTools(server, client);
|
|
101
|
+
registerBoardSettingsTools(server, client);
|
|
102
|
+
registerMailchimpTools(server, client);
|
|
103
|
+
registerStatusMetadataTools(server, client);
|
|
98
104
|
registerNotificationTools(server, client);
|
|
99
105
|
registerCustomFieldTools(server, client);
|
|
100
106
|
registerSmartListTools(server, client);
|
|
@@ -26,7 +26,7 @@ export function registerAutomationTools(server, client) {
|
|
|
26
26
|
kind: z.enum(["native", "n8n"]).optional().describe("Automation type — 'native' for the built-in graph engine (use `actions`), 'n8n' for an external n8n webhook (requires n8nWorkflowId + n8nWebhookUrl). For agent-built automations use 'native'."),
|
|
27
27
|
n8nWorkflowId: z.string().optional().describe("n8n workflow ID (required when kind='n8n')"),
|
|
28
28
|
n8nWebhookUrl: z.string().optional().describe("n8n webhook URL (required when kind='n8n')"),
|
|
29
|
-
actions: z.any().optional().describe("Actions graph — v1: [{type, config}], v2: {nodes:[], startNodeId}. A sequence step is the action enroll_in_sequence with config {sequenceId, enrollAction: 'add' (default) | 'remove'}: add puts the contact into the sequence (the sequence must allow automation enrollment, allowAutomationEnroll), remove ends their run."),
|
|
29
|
+
actions: z.any().optional().describe("Actions graph — v1: [{type, config}], v2: {nodes:[], startNodeId}. A sequence step is the action enroll_in_sequence with config {sequenceId, enrollAction: 'add' (default) | 'remove'}: add puts the contact into the sequence (the sequence must allow automation enrollment, allowAutomationEnroll), remove ends their run. A delay node waits for a fixed duration by default; waitType 'until_field_date' (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately)."),
|
|
30
30
|
graphVersion: z.number().optional().describe("Graph version — 1 (legacy array) or 2 (node graph). Default 1"),
|
|
31
31
|
schedule: z.string().optional().describe("5-field cron expression (required if triggerEvent='scheduled')"),
|
|
32
32
|
scheduleTimezone: z.string().optional().describe("IANA timezone for scheduled triggers (e.g. 'America/Los_Angeles')"),
|
|
@@ -48,7 +48,7 @@ export function registerAutomationTools(server, client) {
|
|
|
48
48
|
.optional()
|
|
49
49
|
.describe("Updated trigger conditions (replaces the object). Same keys as create — e.g. { untouchedDays: 7 } on 'contact.untouched'. (#16 tri-surface parity: the UI and API could set this; MCP could not.)"),
|
|
50
50
|
isActive: z.boolean().optional().describe("Enable or disable the automation"),
|
|
51
|
-
actions: z.any().optional().describe("Updated actions graph (same shape as create; e.g. enroll_in_sequence {sequenceId, enrollAction: 'add' | 'remove'})"),
|
|
51
|
+
actions: z.any().optional().describe("Updated actions graph (same shape as create; e.g. enroll_in_sequence {sequenceId, enrollAction: 'add' | 'remove'}) A delay node waits for a fixed duration by default; waitType 'until_field_date' (with fieldKey = a deal DATE / DATETIME custom field key and offsetHours = signed hours added to its date, default 0, e.g. 24 = a day after it) waits until that date stored on the deal, re-read at every wake (an empty or past value continues immediately)."),
|
|
52
52
|
schedule: z.string().optional().describe("Updated cron schedule"),
|
|
53
53
|
scheduleTimezone: z.string().optional().describe("Updated timezone"),
|
|
54
54
|
stopOnResponse: z.boolean().optional().describe("Stop drip if contact responds"),
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { ConduytClient } from "../client.js";
|
|
3
|
+
/**
|
|
4
|
+
* Deal board settings (app 10-08, BDA board): per-stage rules (settings.pipelineStageRules[pipelineId][stageId]) and the
|
|
5
|
+
* kanban card fields (settings.dealCardFields). The validators mirror the API's settingsPatchSchema entries, which are
|
|
6
|
+
* the source of truth: the built-in names below are the app's lists, `custom:<fieldKey>` names one of the account's DEAL
|
|
7
|
+
* custom fields (the API checks it exists at save), and the stage rule object is strict (no other keys).
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEAL_STAGE_REQUIRED_FIELDS: readonly ["value", "expectedCloseDate", "contactId", "assignedTo", "lostReason"];
|
|
10
|
+
export declare const DEAL_CARD_FIELDS: readonly ["value", "contact", "phones", "company", "owner", "priority", "expectedCloseDate", "daysInStage", "nextAction", "tags", "stageHealth", "source"];
|
|
11
|
+
export declare const MAX_DEAL_CARD_FIELDS = 8;
|
|
12
|
+
export declare const MAX_STAGE_REQUIRED_FIELDS = 25;
|
|
13
|
+
export declare const MAX_STAGE_REQUIRED_DOCUMENTS = 20;
|
|
14
|
+
export declare const MIN_PIPELINE_ROT_DAYS = 1;
|
|
15
|
+
export declare const MAX_PIPELINE_ROT_DAYS = 90;
|
|
16
|
+
export declare function registerBoardSettingsTools(server: McpServer, client: ConduytClient): void;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { formatResult } from "../client.js";
|
|
3
|
+
import { strictInput } from "./strict.js";
|
|
4
|
+
import { patchGeneralSettingsMerged } from "./lifecycle.js";
|
|
5
|
+
/**
|
|
6
|
+
* Deal board settings (app 10-08, BDA board): per-stage rules (settings.pipelineStageRules[pipelineId][stageId]) and the
|
|
7
|
+
* kanban card fields (settings.dealCardFields). The validators mirror the API's settingsPatchSchema entries, which are
|
|
8
|
+
* the source of truth: the built-in names below are the app's lists, `custom:<fieldKey>` names one of the account's DEAL
|
|
9
|
+
* custom fields (the API checks it exists at save), and the stage rule object is strict (no other keys).
|
|
10
|
+
*/
|
|
11
|
+
export const DEAL_STAGE_REQUIRED_FIELDS = ["value", "expectedCloseDate", "contactId", "assignedTo", "lostReason"];
|
|
12
|
+
export const DEAL_CARD_FIELDS = ["value", "contact", "phones", "company", "owner", "priority", "expectedCloseDate", "daysInStage", "nextAction", "tags", "stageHealth", "source"];
|
|
13
|
+
export const MAX_DEAL_CARD_FIELDS = 8;
|
|
14
|
+
export const MAX_STAGE_REQUIRED_FIELDS = 25;
|
|
15
|
+
export const MAX_STAGE_REQUIRED_DOCUMENTS = 20;
|
|
16
|
+
const CUSTOM_FIELD_RE = /^custom:[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
17
|
+
const RULE_KEYS = ["requiredFields", "requiredDocuments", "lostReasonLabel", "lostReasonOptions", "lostReasonAllowOther"];
|
|
18
|
+
export const MIN_PIPELINE_ROT_DAYS = 1;
|
|
19
|
+
export const MAX_PIPELINE_ROT_DAYS = 90;
|
|
20
|
+
const requiredField = z.union([
|
|
21
|
+
z.enum(DEAL_STAGE_REQUIRED_FIELDS),
|
|
22
|
+
z.string().regex(CUSTOM_FIELD_RE, "custom required fields are custom:<deal custom field key>"),
|
|
23
|
+
]);
|
|
24
|
+
const cardField = z.union([
|
|
25
|
+
z.enum(DEAL_CARD_FIELDS),
|
|
26
|
+
z.string().regex(CUSTOM_FIELD_RE, "custom card fields are custom:<deal custom field key>"),
|
|
27
|
+
]);
|
|
28
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
29
|
+
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 } } }, 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. Set one stage with conduyt_set_stage_rules. Pipelines your key cannot see are left out.", {
|
|
31
|
+
pipelineId: z.string().uuid().optional().describe("Only this pipeline's stage rules"),
|
|
32
|
+
stageId: z.string().uuid().optional().describe("Only this stage's rule (needs pipelineId)"),
|
|
33
|
+
}, async ({ pipelineId, stageId }) => {
|
|
34
|
+
if (stageId && !pipelineId)
|
|
35
|
+
throw new Error("stageId needs pipelineId.");
|
|
36
|
+
const result = (await client.get("/api/v1/settings"));
|
|
37
|
+
const all = isObj(result?.data?.pipelineStageRules) ? result.data.pipelineStageRules : {};
|
|
38
|
+
let out = all;
|
|
39
|
+
if (pipelineId) {
|
|
40
|
+
const stages = isObj(all[pipelineId]) ? all[pipelineId] : {};
|
|
41
|
+
out = { [pipelineId]: stageId ? (stages[stageId] !== undefined ? { [stageId]: stages[stageId] } : {}) : stages };
|
|
42
|
+
}
|
|
43
|
+
return formatResult({ data: { pipelineStageRules: out } });
|
|
44
|
+
}));
|
|
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). 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
|
+
pipelineId: z.string().uuid().describe("Pipeline UUID"),
|
|
47
|
+
stageId: z.string().uuid().describe("Stage UUID within that pipeline"),
|
|
48
|
+
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."),
|
|
49
|
+
requiredDocuments: z.number().int().min(0).max(MAX_STAGE_REQUIRED_DOCUMENTS).optional().describe("Documents that must be attached to the deal before it enters the stage (integer 0-20)"),
|
|
50
|
+
lostReasonLabel: z.string().trim().min(1).max(60).optional().describe("Label of the stage's lost-reason prompt, e.g. 'Disqualification Reason'"),
|
|
51
|
+
lostReasonOptions: z.array(z.string()).max(50).optional().describe("Dropdown options of the lost-reason prompt (replaces the list)"),
|
|
52
|
+
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
|
+
}, async ({ pipelineId, stageId, ...given }) => {
|
|
54
|
+
const change = Object.fromEntries(Object.entries(given).filter(([, v]) => v !== undefined));
|
|
55
|
+
if (Object.keys(change).length === 0) {
|
|
56
|
+
throw new Error("Nothing to set. Pass at least one of requiredFields, requiredDocuments, lostReasonLabel, lostReasonOptions, lostReasonAllowOther.");
|
|
57
|
+
}
|
|
58
|
+
return patchGeneralSettingsMerged(client, (settings) => {
|
|
59
|
+
const rules = isObj(settings.pipelineStageRules) ? settings.pipelineStageRules : {};
|
|
60
|
+
const stages = isObj(rules[pipelineId]) ? rules[pipelineId] : {};
|
|
61
|
+
const current = isObj(stages[stageId]) ? stages[stageId] : {};
|
|
62
|
+
const kept = Object.fromEntries(RULE_KEYS.filter((k) => current[k] !== undefined).map((k) => [k, current[k]]));
|
|
63
|
+
// the API merges per pipeline and replaces that pipeline's stage map: send the whole map with this stage changed
|
|
64
|
+
return { pipelineStageRules: { [pipelineId]: { ...stages, [stageId]: { ...kept, ...change } } } };
|
|
65
|
+
});
|
|
66
|
+
}));
|
|
67
|
+
server.tool("conduyt_get_deal_card_fields", "Read the fields shown on each kanban deal card, in order (settings.dealCardFields; max 8): built-in names (value, contact, phones, company, owner, priority, expectedCloseDate, daysInStage, nextAction, tags, stageHealth, source) and custom:<fieldKey> entries for the account's deal custom fields. Change with conduyt_set_deal_card_fields.", {}, async () => {
|
|
68
|
+
const result = (await client.get("/api/v1/settings"));
|
|
69
|
+
return formatResult({ data: { dealCardFields: result?.data?.dealCardFields ?? null } });
|
|
70
|
+
});
|
|
71
|
+
strictInput(server.tool("conduyt_set_deal_card_fields", "Set the fields shown on each kanban deal card, in order (settings.dealCardFields; admin key, settings:edit). REPLACES the list: 1 to 8 entries, each a built-in (value, contact, phones, company, owner, priority, expectedCloseDate, daysInStage, nextAction, tags, stageHealth, source) or custom:<fieldKey> for one of the account's DEAL custom fields (the API refuses an unknown key). Read the current list with conduyt_get_deal_card_fields. Saved with the settings revision; a stale revision is re-read and retried once.", {
|
|
72
|
+
fields: z.array(cardField).min(1).max(MAX_DEAL_CARD_FIELDS).describe("The complete ordered list, 1-8 entries"),
|
|
73
|
+
}, async ({ fields }) => patchGeneralSettingsMerged(client, () => ({ dealCardFields: fields }))));
|
|
74
|
+
strictInput(server.tool("conduyt_get_settings_revision", "Read the account settings revision (GET /api/v1/settings): { data: { settingsRevision, pipelineStageRules, pipelineRotDays } }. settingsRevision is the value to pass as expectedSettingsRevision to conduyt_archive_pipeline and conduyt_delete_stage so they are refused (409 stale_settings_revision) when settings changed after you read them; read it right before the mutation. The stage rules and stale-in-stage days it already reads are returned with it (null when none are stored).", {}, async () => {
|
|
75
|
+
const result = (await client.get("/api/v1/settings"));
|
|
76
|
+
const d = result?.data ?? {};
|
|
77
|
+
return formatResult({ data: { settingsRevision: typeof d.settingsRevision === "string" ? d.settingsRevision : null, pipelineStageRules: d.pipelineStageRules ?? null, pipelineRotDays: d.pipelineRotDays ?? null } });
|
|
78
|
+
}));
|
|
79
|
+
server.tool("conduyt_get_pipeline_rot_days", "Read the stale-in-stage threshold per pipeline (settings.pipelineRotDays) as { pipelineId: days }, optionally for one pipeline: the days without activity before a deal in that pipeline reads \"Stale in stage\" on the board health strip and rots on its card. A pipeline with no entry has no custom threshold set. Change one with conduyt_set_pipeline_rot_days.", {
|
|
80
|
+
pipelineId: z.string().uuid().optional().describe("Only this pipeline's threshold"),
|
|
81
|
+
}, async ({ pipelineId }) => {
|
|
82
|
+
const result = (await client.get("/api/v1/settings"));
|
|
83
|
+
const all = isObj(result?.data?.pipelineRotDays) ? result.data.pipelineRotDays : {};
|
|
84
|
+
const out = pipelineId ? (all[pipelineId] !== undefined ? { [pipelineId]: all[pipelineId] } : {}) : all;
|
|
85
|
+
return formatResult({ data: { pipelineRotDays: out } });
|
|
86
|
+
});
|
|
87
|
+
strictInput(server.tool("conduyt_set_pipeline_rot_days", "Set the stale-in-stage threshold of ONE or more pipelines (settings.pipelineRotDays; admin key, settings:edit): days without activity (integer 1-90) before a deal in the pipeline reads \"Stale in stage\". Only the pipelines you send change; every other pipeline keeps its value. The pipeline must be an ACTIVE pipeline of the account (422 for an unknown or archived one). Saved with the settings revision; a stale revision is re-read and retried once, then the conflict is returned with the current settings.", {
|
|
88
|
+
days: z.record(z.string().uuid(), z.number().int().min(MIN_PIPELINE_ROT_DAYS).max(MAX_PIPELINE_ROT_DAYS)).refine((d) => Object.keys(d).length > 0, "Pass at least one pipeline").describe("Pipeline UUID to days (1-90), e.g. { \"<pipelineId>\": 14 }"),
|
|
89
|
+
}, async ({ days }) => patchGeneralSettingsMerged(client, () => ({ pipelineRotDays: days }))));
|
|
90
|
+
}
|
package/dist/tools/deals.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { ConduytClient } from "../client.js";
|
|
3
|
+
/** The six Pipeline health checks (GET /deals health_check, GET /deals/inspection check). */
|
|
4
|
+
export declare const PIPELINE_HEALTH_CHECKS: readonly ["high_value_idle", "stale", "unassigned", "needs_action", "missing_close_date", "missing_contact"];
|
|
3
5
|
export declare function registerDealTools(server: McpServer, client: ConduytClient): void;
|
package/dist/tools/deals.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatResult } from "../client.js";
|
|
3
|
+
import { strictInput } from "./strict.js";
|
|
4
|
+
/** The six Pipeline health checks (GET /deals health_check, GET /deals/inspection check). */
|
|
5
|
+
export const PIPELINE_HEALTH_CHECKS = ["high_value_idle", "stale", "unassigned", "needs_action", "missing_close_date", "missing_contact"];
|
|
6
|
+
const healthCheckSchema = z.enum(PIPELINE_HEALTH_CHECKS);
|
|
7
|
+
const HEALTH_ERRORS = "A 400 carries a machine-readable code in the error text: health_cursor_invalid (forged cursor, or cursor with offset), health_cursor_context (cursor from another list or the view/thresholds changed), health_cursor_blank, health_cursor_needs_check (cursor without a check), health_filter_conflict (a filter given twice with different values).";
|
|
3
8
|
export function registerDealTools(server, client) {
|
|
4
|
-
server.tool("conduyt_list_deals", "List deals with filtering by pipeline, stage, assignee, or status. Returns deal title, value, stage, contact, and dates. Filter by pipeline/stage NAME (the same names you import with) or by UUID — both camelCase (pipelineName) and snake_case (pipeline_name) argument names are accepted. An unknown name returns a clear error, never the whole account.", {
|
|
9
|
+
strictInput(server.tool("conduyt_list_deals", "List deals with filtering by pipeline, stage, assignee, or status. Returns deal title, value, stage, contact, and dates. Filter by pipeline/stage NAME (the same names you import with) or by UUID — both camelCase (pipelineName) and snake_case (pipeline_name) argument names are accepted. An unknown name returns a clear error, never the whole account. Each deal row carries hasNextStep (boolean: an open task or a booked appointment from a day ago on; the Needs next action rule's own answer) and nextAppointmentAt (ISO string or null; null unless your key can view the calendar, even when hasNextStep is true). PIPELINE HEALTH: pass health_summary=true to get the Pipeline health strip for the same filtered set instead of the deal list: { totalOpenDeals, atRiskValue, checks[] (key, label, severity, color, rule, count, value; the six checks high_value_idle, stale, unassigned, needs_action, missing_close_date, missing_contact, ordered by money at risk), deals }. Add health_check to also get that check's first page of deals (highest value first) as deals: { check, offset, items, hasMore, nextCursor, total }; deals is null without a check. Page with health_cursor (the previous nextCursor, passed back UNCHANGED: an opaque signed token) or health_offset, and health_rows_only=true to get just the page without the summary: the answer is {data:{deals:{check, offset, items, hasMore, nextCursor}}} (the page sits under data.deals). " + HEALTH_ERRORS, {
|
|
5
10
|
pipeline: z.string().optional().describe("Filter by pipeline UUID (canonical). Aliases: pipelineId, pipeline_id."),
|
|
6
11
|
pipelineId: z.string().optional().describe("Filter by pipeline UUID (alias of pipeline)."),
|
|
7
12
|
pipeline_id: z.string().optional().describe("snake_case alias of pipelineId."),
|
|
@@ -17,6 +22,12 @@ export function registerDealTools(server, client) {
|
|
|
17
22
|
search: z.string().optional().describe("Search deal titles"),
|
|
18
23
|
page: z.number().optional().describe("Page number (default 1)"),
|
|
19
24
|
per_page: z.number().optional().describe("Results per page (default 25, max 100)"),
|
|
25
|
+
health_summary: z.boolean().optional().describe("true: answer the Pipeline health shape (totalOpenDeals, atRiskValue, checks[], deals) for the filtered set instead of the deal list."),
|
|
26
|
+
health_check: healthCheckSchema.optional().describe("With health_summary: one check whose deals are added to `deals`, highest value first."),
|
|
27
|
+
health_limit: z.number().int().min(1).max(100).optional().describe("With health_summary and health_check: deals per page, 1-100 (default 5)."),
|
|
28
|
+
health_offset: z.number().int().min(0).optional().describe("With health_summary and health_check: deals to skip (default 0). Prefer health_cursor; never combine the two."),
|
|
29
|
+
health_cursor: z.string().optional().describe("With health_summary and health_check: continue after this cursor, the nextCursor of the previous page, passed back UNCHANGED (opaque signed token bound to the list; a blank, forged or foreign one is a 400 with its code)."),
|
|
30
|
+
health_rows_only: z.boolean().optional().describe("With health_summary and health_check: true answers only the requested page of that check's deals, without the summary (for the next page of a list); the answer is {data:{deals:{check, offset, items, hasMore, nextCursor}}}."),
|
|
20
31
|
}, async (a) => {
|
|
21
32
|
const params = new URLSearchParams();
|
|
22
33
|
// Accept every reasonable casing of each filter (canonical bare name +
|
|
@@ -62,10 +73,46 @@ export function registerDealTools(server, client) {
|
|
|
62
73
|
params.set("page", String(page));
|
|
63
74
|
if (per_page)
|
|
64
75
|
params.set("per_page", String(per_page));
|
|
76
|
+
// Pipeline health strip: sent only when given, the cursor byte for byte (it is signed; never trimmed or re-encoded by hand)
|
|
77
|
+
if (a.health_summary !== undefined)
|
|
78
|
+
params.set("health_summary", String(a.health_summary));
|
|
79
|
+
if (a.health_check !== undefined)
|
|
80
|
+
params.set("health_check", a.health_check);
|
|
81
|
+
if (a.health_limit !== undefined)
|
|
82
|
+
params.set("health_limit", String(a.health_limit));
|
|
83
|
+
if (a.health_offset !== undefined)
|
|
84
|
+
params.set("health_offset", String(a.health_offset));
|
|
85
|
+
if (a.health_cursor !== undefined)
|
|
86
|
+
params.set("health_cursor", a.health_cursor);
|
|
87
|
+
if (a.health_rows_only !== undefined)
|
|
88
|
+
params.set("health_rows_only", String(a.health_rows_only));
|
|
65
89
|
const result = await client.get(`/api/v1/deals?${params}`);
|
|
66
90
|
return formatResult(result);
|
|
67
|
-
});
|
|
68
|
-
server.tool("
|
|
91
|
+
}));
|
|
92
|
+
strictInput(server.tool("conduyt_pipeline_health", "Pipeline health for one pipeline (or every visible active pipeline) over its OPEN deals (GET /api/v1/deals/inspection): { totalOpenDeals, atRiskValue (total value of the High-value idle deals), checks[] (key, label, severity, color, rule, count, value; the six checks high_value_idle, stale, unassigned, needs_action, missing_close_date, missing_contact, ordered by money at risk), deals, summary (count per check), labels (label per check) }. With check, deals is that check's page { check, offset, items, hasMore, nextCursor, total } (highest value first, each item with its issues); without it deals is null. Page with cursor (the previous nextCursor, passed back UNCHANGED) or offset; never both. For the board's other filters (stage, assignee, saved view) use conduyt_list_deals with health_summary=true. " + HEALTH_ERRORS, {
|
|
93
|
+
pipeline: z.string().optional().describe("Pipeline UUID; omit for every visible active pipeline."),
|
|
94
|
+
check: healthCheckSchema.optional().describe("Adds `deals` for that check."),
|
|
95
|
+
limit: z.number().int().min(1).max(100).optional().describe("With check: deals per page, 1-100 (default 5)."),
|
|
96
|
+
offset: z.number().int().min(0).optional().describe("With check: deals to skip (default 0)."),
|
|
97
|
+
cursor: z.string().optional().describe("With check: continue after this cursor, the nextCursor of the previous page, passed back UNCHANGED. Not combined with offset."),
|
|
98
|
+
}, async ({ pipeline, check, limit, offset, cursor }) => {
|
|
99
|
+
const params = new URLSearchParams();
|
|
100
|
+
if (pipeline !== undefined)
|
|
101
|
+
params.set("pipeline", pipeline);
|
|
102
|
+
if (check !== undefined)
|
|
103
|
+
params.set("check", check);
|
|
104
|
+
if (limit !== undefined)
|
|
105
|
+
params.set("limit", String(limit));
|
|
106
|
+
if (offset !== undefined)
|
|
107
|
+
params.set("offset", String(offset));
|
|
108
|
+
if (cursor !== undefined)
|
|
109
|
+
params.set("cursor", cursor);
|
|
110
|
+
const qs = params.toString();
|
|
111
|
+
// literal paths on purpose: the coverage scanner reads the first string after client.get(, and a template with a nested template hid this endpoint from it
|
|
112
|
+
const result = qs ? await client.get("/api/v1/deals/inspection?" + qs) : await client.get("/api/v1/deals/inspection");
|
|
113
|
+
return formatResult(result);
|
|
114
|
+
}));
|
|
115
|
+
server.tool("conduyt_get_deal", "Get a single deal with full details — value, currency, probability, stage, contact, expected close date, custom-field values, activities, tasks, and history. Use this to read structured custom-field data back out of a deal (e.g. for a P&L or balance-sheet model). Deal rows also carry hasNextStep and nextAppointmentAt (null unless your key can view the calendar) when the API returns them.", {
|
|
69
116
|
id: z.string().describe("Deal UUID"),
|
|
70
117
|
}, async ({ id }) => {
|
|
71
118
|
const result = await client.get(`/api/v1/deals/${id}`);
|
|
@@ -8,4 +8,16 @@ export declare function patchGeneralSettings(client: ConduytClient, body: Record
|
|
|
8
8
|
text: string;
|
|
9
9
|
}>;
|
|
10
10
|
}>;
|
|
11
|
+
/**
|
|
12
|
+
* Read-merge-write for a settings change that depends on what is stored (a per-stage rule inside settings.pipelineStageRules):
|
|
13
|
+
* `build` derives the PATCH body from the settings just read, and the save carries that read's settingsRevision. A stale
|
|
14
|
+
* revision (409: another save landed first) re-reads and re-derives ONCE; a second 409 hands back the conflict with the
|
|
15
|
+
* current settings, like patchGeneralSettings. Anything other than a 409 is thrown. `build` may throw a plain Error to refuse.
|
|
16
|
+
*/
|
|
17
|
+
export declare function patchGeneralSettingsMerged(client: ConduytClient, build: (settings: Record<string, unknown>) => Record<string, unknown>): Promise<{
|
|
18
|
+
content: Array<{
|
|
19
|
+
type: "text";
|
|
20
|
+
text: string;
|
|
21
|
+
}>;
|
|
22
|
+
}>;
|
|
11
23
|
export declare function registerLifecycleTools(server: McpServer, client: ConduytClient): void;
|
package/dist/tools/lifecycle.js
CHANGED
|
@@ -19,6 +19,34 @@ export async function patchGeneralSettings(client, body) {
|
|
|
19
19
|
throw err;
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Read-merge-write for a settings change that depends on what is stored (a per-stage rule inside settings.pipelineStageRules):
|
|
24
|
+
* `build` derives the PATCH body from the settings just read, and the save carries that read's settingsRevision. A stale
|
|
25
|
+
* revision (409: another save landed first) re-reads and re-derives ONCE; a second 409 hands back the conflict with the
|
|
26
|
+
* current settings, like patchGeneralSettings. Anything other than a 409 is thrown. `build` may throw a plain Error to refuse.
|
|
27
|
+
*/
|
|
28
|
+
export async function patchGeneralSettingsMerged(client, build) {
|
|
29
|
+
let lastRevision = null;
|
|
30
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
31
|
+
const read = (await client.get("/api/v1/settings"));
|
|
32
|
+
const settings = read?.data ?? {};
|
|
33
|
+
const revision = typeof settings.settingsRevision === "string" ? settings.settingsRevision : null;
|
|
34
|
+
lastRevision = revision;
|
|
35
|
+
const body = build(settings);
|
|
36
|
+
try {
|
|
37
|
+
return formatResult(await client.patch("/api/v1/settings", { ...body, ...(revision ? { expectedSettingsRevision: revision } : {}) }));
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (!(err instanceof ConduytApiError && err.status === 409))
|
|
41
|
+
throw err;
|
|
42
|
+
if (attempt === 1) {
|
|
43
|
+
const current = (await client.get("/api/v1/settings"));
|
|
44
|
+
return formatResult({ conflict: true, message: err.message, expectedSettingsRevision: lastRevision, current: current?.data ?? null, hint: "the account settings changed twice while this change was being saved — read them again and call again" });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
throw new Error("unreachable");
|
|
49
|
+
}
|
|
22
50
|
/**
|
|
23
51
|
* Lifecycle v2 settings (master lead status + intake deals). The master lead
|
|
24
52
|
* is the acquisition truth (first-write-wins source, one lifecycle status);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { ConduytClient } from "../client.js";
|
|
3
|
+
/**
|
|
4
|
+
* Mailchimp audience sync (app 10-07/10-08, API key scope `integrations`, a non-default scope). Everything under
|
|
5
|
+
* /api/v1/integrations/mailchimp except the OAuth connect + callback, which are browser-only (the owner signs in to
|
|
6
|
+
* Mailchimp in a browser; there is no key-callable equivalent), and the Mailchimp webhook + cron routes, which
|
|
7
|
+
* belong to Mailchimp / the platform. Errors carry the server's message untouched; where the API sends a `code` it
|
|
8
|
+
* is in the error text.
|
|
9
|
+
*/
|
|
10
|
+
/** the admin routes' server budgets are 60 s (settings save: 45 s administrative deadline); the client default is 30 s */
|
|
11
|
+
export declare const MAILCHIMP_ADMIN_TIMEOUT_MS = 60000;
|
|
12
|
+
export declare function registerMailchimpTools(server: McpServer, client: ConduytClient): void;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { formatResult } from "../client.js";
|
|
3
|
+
import { strictInput } from "./strict.js";
|
|
4
|
+
/**
|
|
5
|
+
* Mailchimp audience sync (app 10-07/10-08, API key scope `integrations`, a non-default scope). Everything under
|
|
6
|
+
* /api/v1/integrations/mailchimp except the OAuth connect + callback, which are browser-only (the owner signs in to
|
|
7
|
+
* Mailchimp in a browser; there is no key-callable equivalent), and the Mailchimp webhook + cron routes, which
|
|
8
|
+
* belong to Mailchimp / the platform. Errors carry the server's message untouched; where the API sends a `code` it
|
|
9
|
+
* is in the error text.
|
|
10
|
+
*/
|
|
11
|
+
/** the admin routes' server budgets are 60 s (settings save: 45 s administrative deadline); the client default is 30 s */
|
|
12
|
+
export const MAILCHIMP_ADMIN_TIMEOUT_MS = 60_000;
|
|
13
|
+
const ADMIN_TIMEOUT_NOTE = " The call can take up to 60 s (a 60 s per-call timeout is used); if it still reports a timeout the server may have applied it: re-read conduyt_get_mailchimp_settings before repeating.";
|
|
14
|
+
const NOT_CONNECTED = "404 Mailchimp is not connected for this account (connect it in the app: Settings > Integrations > Mailchimp, the OAuth step is browser-only); 409 the access was revoked (reconnect in the app).";
|
|
15
|
+
export function registerMailchimpTools(server, client) {
|
|
16
|
+
strictInput(server.tool("conduyt_get_mailchimp_settings", "Read the account's Mailchimp connection (GET /api/v1/integrations/mailchimp/settings; scope integrations): the Mailchimp account, the audience, the source (one smart list or one tag), statusIfNew and unsubscribeSetsOptOut, and the sync status (backfill progress, pending pushes, last pushed, last webhook received, the plain-words error Mailchimp's last refusal left, and statusSweepBlocked when Conduyt's check of Mailchimp's unsubscribes is blocked). Answers `connected: false, status: \"not_connected\"` when there is no connection. Connecting itself is browser-only (OAuth).", {}, async () => formatResult(await client.get("/api/v1/integrations/mailchimp/settings"))));
|
|
17
|
+
strictInput(server.tool("conduyt_update_mailchimp_settings", "Configure the Mailchimp audience sync (PUT /api/v1/integrations/mailchimp/settings; scope integrations, settings:edit, active billing). Every field is optional and only the ones you send change; an unknown field is a 400. listId: the Mailchimp audience id (conduyt_list_mailchimp_audiences). source: { type: \"smart_list\" | \"tag\", id: uuid }, the contacts that go to the audience. statusIfNew: \"subscribed\" (default) or \"pending\" (Mailchimp sends a confirmation email). unsubscribeSetsOptOut (default true): a Mailchimp unsubscribe also opts the contact out of Conduyt email (Conduyt checks Mailchimp every 15 minutes; an unsubscribe reversed in Mailchimp before that check is not applied). A new audience gets Conduyt's audience webhook (the old one is removed) and a new audience or source starts the backfill; saving after a refusal resumes a stopped backfill. Answers the settings as conduyt_get_mailchimp_settings does. " + NOT_CONNECTED + " 400 an unknown tag / smart list / audience; 409 a sync pass is finishing or members are still being confirmed (try again in a few minutes); 502 Mailchimp refused." + ADMIN_TIMEOUT_NOTE, {
|
|
18
|
+
listId: z.string().min(1).optional().describe("Mailchimp audience id (conduyt_list_mailchimp_audiences)"),
|
|
19
|
+
source: z.object({
|
|
20
|
+
type: z.enum(["smart_list", "tag"]).describe("smart_list or tag"),
|
|
21
|
+
id: z.string().uuid().describe("The smart list or tag UUID"),
|
|
22
|
+
}).strict().optional().describe("The contacts that go to the audience: exactly one smart list or one tag"),
|
|
23
|
+
statusIfNew: z.enum(["subscribed", "pending"]).optional().describe("How new people are added: subscribed (default) or pending (Mailchimp sends a confirmation email)"),
|
|
24
|
+
unsubscribeSetsOptOut: z.boolean().optional().describe("Default true: a Mailchimp unsubscribe also opts the contact out of Conduyt email"),
|
|
25
|
+
}, async ({ listId, source, statusIfNew, unsubscribeSetsOptOut }) => {
|
|
26
|
+
if (listId === undefined && source === undefined && statusIfNew === undefined && unsubscribeSetsOptOut === undefined) {
|
|
27
|
+
throw new Error("Nothing to set. Pass at least one of listId, source, statusIfNew, unsubscribeSetsOptOut.");
|
|
28
|
+
}
|
|
29
|
+
// undefined fields are dropped by JSON.stringify, so only the ones given are sent
|
|
30
|
+
return formatResult(await client.put("/api/v1/integrations/mailchimp/settings", { listId, source, statusIfNew, unsubscribeSetsOptOut }, { timeoutMs: MAILCHIMP_ADMIN_TIMEOUT_MS }));
|
|
31
|
+
}));
|
|
32
|
+
strictInput(server.tool("conduyt_list_mailchimp_audiences", "List the connected Mailchimp account's audiences for choosing one (GET /api/v1/integrations/mailchimp/audiences; scope integrations): { data: [{ id, name, memberCount }] }. Use an id as listId in conduyt_update_mailchimp_settings. " + NOT_CONNECTED + " 502 Mailchimp refused.", {}, async () => formatResult(await client.get("/api/v1/integrations/mailchimp/audiences"))));
|
|
33
|
+
strictInput(server.tool("conduyt_mailchimp_push_now", "Run one Mailchimp sync pass now instead of waiting for the next minute (POST /api/v1/integrations/mailchimp/push; scope integrations, settings:edit, active billing): contacts newly in the source are queued, the backfill takes a step, due pushes are sent. Answers { data: { ran, result, settings } }; ran is false while another pass holds the connection or Mailchimp asked Conduyt to slow down (429), so check ran before assuming anything was pushed. " + NOT_CONNECTED + " 409 also when the connection is in error or not configured (choose an audience and a source first)." + ADMIN_TIMEOUT_NOTE, {}, async () => formatResult(await client.post("/api/v1/integrations/mailchimp/push", {}, { timeoutMs: MAILCHIMP_ADMIN_TIMEOUT_MS }))));
|
|
34
|
+
strictInput(server.tool("conduyt_mailchimp_retry_unsubscribes", "Try again, in one pass now, the unsubscribes Conduyt could not deliver to Mailchimp after its retries (POST /api/v1/integrations/mailchimp/unsubscribes/retry; scope integrations, settings:edit; no active billing needed, delivering an owed unsubscribe is never a paid effect). They are also retried daily. Answers { data: { ran, pending, delivered, undelivered, result, settings } }; pending is true when no pass could run (another pass holds the connection, Mailchimp asked to slow down, or the connection needs attention) and nothing was tried. One retry per account per 10 minutes: otherwise 429 with code retry_cooldown (the error text carries the minutes to wait). " + NOT_CONNECTED + ADMIN_TIMEOUT_NOTE, {}, async () => formatResult(await client.post("/api/v1/integrations/mailchimp/unsubscribes/retry", {}, { timeoutMs: MAILCHIMP_ADMIN_TIMEOUT_MS }))));
|
|
35
|
+
strictInput(server.tool("conduyt_mailchimp_reconcile_statuses", "Run the full reconciliation of Mailchimp's unsubscribes (POST /api/v1/integrations/mailchimp/reconcile-statuses; owner / admin key, scope integrations; no active billing needed). Use it when conduyt_get_mailchimp_settings shows statusSweepBlocked: more members than one page changed in the same second, so Conduyt's check is blocked. It reads every member Mailchimp shows as unsubscribed or cleaned up to that second and applies them (it ONLY ever opts people out), in up to 3 passes, and records a report; it never resumes the check on its own: review the report, then call conduyt_mailchimp_confirm_reconciliation. Starts the reconciliation (or resumes the one in progress) and runs one pass now; call again until done is true. Answers { data: { ran, done, reconciling, blocked, report, result, settings } }. restart=true replaces a COMPLETED run's report with a new run; without it a completed run is refused 409 with code reconciliation_completed (confirm it, or restart explicitly). Other refusals: 404 not connected; 409 access revoked, nothing blocked (\"Nothing to reconcile\"), or a sync pass running." + ADMIN_TIMEOUT_NOTE, {
|
|
36
|
+
restart: z.boolean().optional().describe("true replaces a completed run's report with a new run; without it a completed run answers 409 reconciliation_completed"),
|
|
37
|
+
}, async ({ restart }) => formatResult(await client.post("/api/v1/integrations/mailchimp/reconcile-statuses", { restart }, { timeoutMs: MAILCHIMP_ADMIN_TIMEOUT_MS }))));
|
|
38
|
+
strictInput(server.tool("conduyt_mailchimp_confirm_reconciliation", "Confirm a completed full reconciliation and resume Conduyt's check of Mailchimp's unsubscribes after the blocked second (POST /api/v1/integrations/mailchimp/confirm-reconciliation; owner / admin key, scope integrations): \"Confirm and resume\" on the manage page. Mailchimp cannot prove its member list was read completely, so this is the operator's explicit decision after reviewing the report from conduyt_mailchimp_reconcile_statuses; audited with the report's counts. Answers { data: { confirmed, report, settings } }. 404 not connected; 409 access revoked, nothing blocked, a reconciliation still running, or none completed since the block began (run conduyt_mailchimp_reconcile_statuses first)." + ADMIN_TIMEOUT_NOTE, {}, async () => formatResult(await client.post("/api/v1/integrations/mailchimp/confirm-reconciliation", {}, { timeoutMs: MAILCHIMP_ADMIN_TIMEOUT_MS }))));
|
|
39
|
+
}
|
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { ConduytClient } from "../client.js";
|
|
3
|
+
/** The 10 palette colours a pipeline can be painted (API: PIPELINE_COLOR_PALETTE; any other value is a 400). */
|
|
4
|
+
export declare const PIPELINE_COLORS: readonly ["#3845C9", "#43DFF6", "#2DD6B5", "#22C58B", "#9BD13B", "#F2B544", "#F07A2F", "#E6465A", "#FD88C0", "#956EFA"];
|
|
5
|
+
/** The machine-readable codes the archive route answers with (the API puts `code` in the error body; the client carries it in the error text). */
|
|
6
|
+
export declare const ARCHIVE_ERROR_CODES = "A refusal carries a machine-readable code in the error text: stale_settings_revision (409, settings changed since expectedSettingsRevision was read; nothing moved), target_not_open (409, the destination's first stage is won/lost), dpp_post_close (409, the DPP feed's Post-Close pipeline), archive_too_large (409, too many open deals for one call: move or close some first), target_archived (409, the destination was archived meanwhile), intake_target (409, enabled intake creates its cards in this pipeline), pipeline_archived (409), open_deals (409, open deals but no moveOpenDealsTo), default_pipeline (409), stage_requirements (409, a deal fails the destination's stage-entry rules; nothing moved), archive_raced (409, retry), pipeline_automation_only (403). Archiving a pipeline that is already archived is idempotent: { archived: true, alreadyArchived: true }. The message is the server's, never swallowed. The call can take up to 75 s (a 75 s per-call timeout is used); if it still reports a timeout the archive may have committed: re-read with conduyt_list_pipelines / conduyt_list_archived_pipelines before retrying (a retry of a committed archive answers alreadyArchived, but its open deals have already moved).";
|
|
7
|
+
/** above the archive route's 60 s transaction (up to 500 deal moves) plus post-commit events; the client default is 30 s */
|
|
8
|
+
export declare const ARCHIVE_PIPELINE_TIMEOUT_MS = 75000;
|
|
3
9
|
export declare function registerPipelineTools(server: McpServer, client: ConduytClient): void;
|
package/dist/tools/pipelines.js
CHANGED
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatResult } from "../client.js";
|
|
3
|
+
import { strictInput } from "./strict.js";
|
|
4
|
+
/** The 10 palette colours a pipeline can be painted (API: PIPELINE_COLOR_PALETTE; any other value is a 400). */
|
|
5
|
+
export const PIPELINE_COLORS = ["#3845C9", "#43DFF6", "#2DD6B5", "#22C58B", "#9BD13B", "#F2B544", "#F07A2F", "#E6465A", "#FD88C0", "#956EFA"];
|
|
6
|
+
const pipelineColor = z.enum(PIPELINE_COLORS);
|
|
7
|
+
/** The machine-readable codes the archive route answers with (the API puts `code` in the error body; the client carries it in the error text). */
|
|
8
|
+
export const ARCHIVE_ERROR_CODES = "A refusal carries a machine-readable code in the error text: stale_settings_revision (409, settings changed since expectedSettingsRevision was read; nothing moved), target_not_open (409, the destination's first stage is won/lost), dpp_post_close (409, the DPP feed's Post-Close pipeline), archive_too_large (409, too many open deals for one call: move or close some first), target_archived (409, the destination was archived meanwhile), intake_target (409, enabled intake creates its cards in this pipeline), pipeline_archived (409), open_deals (409, open deals but no moveOpenDealsTo), default_pipeline (409), stage_requirements (409, a deal fails the destination's stage-entry rules; nothing moved), archive_raced (409, retry), pipeline_automation_only (403). Archiving a pipeline that is already archived is idempotent: { archived: true, alreadyArchived: true }. The message is the server's, never swallowed. The call can take up to 75 s (a 75 s per-call timeout is used); if it still reports a timeout the archive may have committed: re-read with conduyt_list_pipelines / conduyt_list_archived_pipelines before retrying (a retry of a committed archive answers alreadyArchived, but its open deals have already moved).";
|
|
9
|
+
/** above the archive route's 60 s transaction (up to 500 deal moves) plus post-commit events; the client default is 30 s */
|
|
10
|
+
export const ARCHIVE_PIPELINE_TIMEOUT_MS = 75_000;
|
|
3
11
|
export function registerPipelineTools(server, client) {
|
|
4
|
-
server.tool("conduyt_list_pipelines", "List all sales pipelines with their stages. Use this to discover pipeline and stage UUIDs before creating or moving deals.", {}, async () => {
|
|
12
|
+
server.tool("conduyt_list_pipelines", "List all active sales pipelines with their stages. Use this to discover pipeline and stage UUIDs before creating or moving deals. Each pipeline carries color (one of the 10 palette hex colours, or null until the settings page saves one), automationOnly and sortOrder (the board-menu order); an archived pipeline's pre-archive name is archivedFromName (see conduyt_list_archived_pipelines).", {}, async () => {
|
|
5
13
|
const result = await client.get("/api/v1/pipelines");
|
|
6
14
|
return formatResult(result);
|
|
7
15
|
});
|
|
16
|
+
strictInput(server.tool("conduyt_list_archived_pipelines", "List the ARCHIVED pipelines (GET /api/v1/pipelines?archived=true; owner / admin key only; newest first, max 200): [{ id, name (the pre-archive name), archivedName, color, archivedAt, openDeals, stageCount }]. Archived pipelines are hidden from conduyt_list_pipelines. Bring one back with conduyt_restore_pipeline.", {}, async () => {
|
|
17
|
+
const result = await client.get("/api/v1/pipelines?archived=true");
|
|
18
|
+
return formatResult(result);
|
|
19
|
+
}));
|
|
8
20
|
server.tool("conduyt_create_pipeline", "Create a new sales pipeline, optionally with its full set of stages in one call. If stages are omitted, default New/Won/Lost stages are created. There is no fixed limit on stage count — pass as many as your funnel needs (e.g. eight active stages plus 'On Hold' and 'Declined/Dead').", {
|
|
9
21
|
name: z.string().describe("Pipeline name (e.g. 'New Business Book')"),
|
|
10
22
|
description: z.string().optional().describe("Optional pipeline description"),
|
|
11
23
|
isDefault: z.boolean().optional().describe("Make this the account's default pipeline"),
|
|
24
|
+
color: pipelineColor.optional().describe("Pipeline colour: one of the 10 palette hex colours (case-insensitive). Omit to leave it unset."),
|
|
12
25
|
stages: z
|
|
13
26
|
.array(z.object({
|
|
14
27
|
name: z.string().describe("Stage name (e.g. 'Quote Sent')"),
|
|
@@ -20,8 +33,10 @@ export function registerPipelineTools(server, client) {
|
|
|
20
33
|
}))
|
|
21
34
|
.optional()
|
|
22
35
|
.describe("Ordered list of stages to create with the pipeline. A stage cannot be both isWon and isLost."),
|
|
23
|
-
}, async ({ name, description, isDefault, stages }) => {
|
|
36
|
+
}, async ({ name, description, isDefault, color, stages }) => {
|
|
24
37
|
const body = { name };
|
|
38
|
+
if (color !== undefined)
|
|
39
|
+
body.color = color;
|
|
25
40
|
if (description !== undefined)
|
|
26
41
|
body.description = description;
|
|
27
42
|
if (isDefault !== undefined)
|
|
@@ -35,21 +50,46 @@ export function registerPipelineTools(server, client) {
|
|
|
35
50
|
const result = await client.post("/api/v1/pipelines", body);
|
|
36
51
|
return formatResult(result);
|
|
37
52
|
});
|
|
38
|
-
server.tool("conduyt_update_pipeline", "Update a pipeline's name, description, or
|
|
53
|
+
strictInput(server.tool("conduyt_update_pipeline", "Update a pipeline's name, description, default flag, colour, board-menu position (sortOrder) or automationOnly flag (owner/admin key only). An archived pipeline cannot be edited (410). An automation-only pipeline's cards are moved and edited only by automations, API keys and owners / admins; a member's edit is refused with 403 pipeline_automation_only. conduyt_list_pipelines returns the flag on each pipeline. To add/edit/remove stages, use the stage tools.", {
|
|
39
54
|
id: z.string().describe("Pipeline UUID"),
|
|
40
55
|
name: z.string().optional().describe("Updated pipeline name"),
|
|
41
56
|
description: z.string().optional().describe("Updated description"),
|
|
42
57
|
isDefault: z.boolean().optional().describe("Set as the account's default pipeline"),
|
|
58
|
+
automationOnly: z.boolean().optional().describe("Automation-only pipeline: only automations, API keys and owners / admins move or edit its cards; a member's edit is refused with 403 pipeline_automation_only. Owner / admin only."),
|
|
59
|
+
color: pipelineColor.optional().describe("Pipeline colour: one of the 10 palette hex colours (any other value is a 400)."),
|
|
60
|
+
sortOrder: z.number().int().min(0).max(2147483647).optional().describe("Board-menu position (0-based, non-negative integer). To reorder every pipeline at once use conduyt_reorder_pipelines."),
|
|
43
61
|
}, async ({ id, ...updates }) => {
|
|
44
62
|
const result = await client.patch(`/api/v1/pipelines/${id}`, updates);
|
|
45
63
|
return formatResult(result);
|
|
46
|
-
});
|
|
64
|
+
}));
|
|
47
65
|
server.tool("conduyt_delete_pipeline", "Delete a pipeline. Deals in the pipeline must be moved or removed first. Cannot delete the default or an archived pipeline.", {
|
|
48
66
|
id: z.string().describe("Pipeline UUID to delete"),
|
|
49
67
|
}, async ({ id }) => {
|
|
50
68
|
const result = await client.del(`/api/v1/pipelines/${id}`);
|
|
51
69
|
return formatResult(result);
|
|
52
70
|
});
|
|
71
|
+
strictInput(server.tool("conduyt_archive_pipeline", "Archive a pipeline (POST /api/v1/pipelines/{id}/archive; owner / admin key). The pipeline is renamed with the archived prefix, never deleted: its closed (won / lost) deals stay in it as history and conduyt_restore_pipeline brings it back. Every OPEN deal moves in the same transaction to moveOpenDealsTo's first rep-facing stage through the normal stage-entry rules; any refusal aborts the whole thing (nothing moves, nothing is archived). Pass moveOpenDealsTo=null only when the pipeline has no open deals. The default pipeline cannot be archived. To guard against settings that changed since you looked, read conduyt_get_settings_revision right before and pass its settingsRevision as expectedSettingsRevision. " + ARCHIVE_ERROR_CODES, {
|
|
72
|
+
id: z.string().uuid().describe("Pipeline UUID to archive"),
|
|
73
|
+
moveOpenDealsTo: z.string().uuid().nullable().describe("Pipeline UUID the open deals move to (another active pipeline whose first stage is open), or null when there are no open deals"),
|
|
74
|
+
expectedSettingsRevision: z.string().regex(/^\d+$/).optional().describe("The settingsRevision from conduyt_get_settings_revision, read right before; the archive is refused 409 stale_settings_revision when settings changed since"),
|
|
75
|
+
}, async ({ id, moveOpenDealsTo, expectedSettingsRevision }) => {
|
|
76
|
+
// an undefined expectedSettingsRevision is dropped by JSON.stringify
|
|
77
|
+
const result = await client.post(`/api/v1/pipelines/${id}/archive`, { moveOpenDealsTo, expectedSettingsRevision }, { timeoutMs: ARCHIVE_PIPELINE_TIMEOUT_MS });
|
|
78
|
+
return formatResult(result);
|
|
79
|
+
}));
|
|
80
|
+
strictInput(server.tool("conduyt_restore_pipeline", "Restore an archived pipeline (POST /api/v1/pipelines/{id}/restore; owner / admin key): it loses the archived prefix, is appended at the end of the board-menu order, is never the default, and its stages and closed deals come back with it. 404 when the pipeline is not an archived one of this account; 409 name_taken (code in the error text) when an active pipeline already has its name (rename that one first); 403 PLAN_LIMIT_REACHED when the plan's pipeline limit is full (a restore counts like a create).", {
|
|
81
|
+
id: z.string().uuid().describe("Archived pipeline UUID (conduyt_list_archived_pipelines)"),
|
|
82
|
+
}, async ({ id }) => {
|
|
83
|
+
const result = await client.post(`/api/v1/pipelines/${id}/restore`, {});
|
|
84
|
+
return formatResult(result);
|
|
85
|
+
}));
|
|
86
|
+
strictInput(server.tool("conduyt_reorder_pipelines", "Set the board-menu order of the active pipelines in one transaction (POST /api/v1/pipelines/reorder; owner / admin key). order must list EVERY active pipeline exactly once (400 otherwise); expectedRevision is the current order as the pipeline ids by position, comma-joined (what the previous reorder answered as `revision`): 409 order_changed (with the current revision) when the order moved since, nothing written. Answers { order, revision }.", {
|
|
87
|
+
order: z.array(z.string().uuid()).min(1).max(1000).describe("Every active pipeline id, in the new order"),
|
|
88
|
+
expectedRevision: z.string().min(1).describe("The order you last read: pipeline ids by position, comma-joined (lowercase)"),
|
|
89
|
+
}, async ({ order, expectedRevision }) => {
|
|
90
|
+
const result = await client.post("/api/v1/pipelines/reorder", { order, expectedRevision });
|
|
91
|
+
return formatResult(result);
|
|
92
|
+
}));
|
|
53
93
|
server.tool("conduyt_create_stage", "Add a new stage to an existing pipeline. The stage is appended after the current last stage. Use this to extend a funnel (e.g. add 'On Hold' or 'Declined/Dead').", {
|
|
54
94
|
pipelineId: z.string().describe("Pipeline UUID to add the stage to"),
|
|
55
95
|
name: z.string().describe("Stage name"),
|
|
@@ -80,11 +120,27 @@ export function registerPipelineTools(server, client) {
|
|
|
80
120
|
const result = await client.patch(`/api/v1/pipelines/${pipelineId}/stages/${stageId}`, updates);
|
|
81
121
|
return formatResult(result);
|
|
82
122
|
});
|
|
83
|
-
server.tool("
|
|
123
|
+
strictInput(server.tool("conduyt_reorder_stages", "Set a pipeline's column order in one transaction (POST /api/v1/pipelines/{id}/stages/reorder; owner / admin key). order must list EVERY stage of the pipeline exactly once and must not put a system holding stage first that was not first (400 otherwise); expectedRevision is the current order as the stage ids by position, comma-joined: 409 order_changed (with the current revision) when it moved since, 409 pipeline_archived when the pipeline was archived meanwhile. Answers { order, revision }.", {
|
|
124
|
+
pipelineId: z.string().uuid().describe("Pipeline UUID"),
|
|
125
|
+
order: z.array(z.string().uuid()).min(1).max(1000).describe("Every stage id of the pipeline, in the new order"),
|
|
126
|
+
expectedRevision: z.string().min(1).describe("The order you last read: stage ids by position, comma-joined (lowercase)"),
|
|
127
|
+
}, async ({ pipelineId, order, expectedRevision }) => {
|
|
128
|
+
const result = await client.post(`/api/v1/pipelines/${pipelineId}/stages/reorder`, { order, expectedRevision });
|
|
129
|
+
return formatResult(result);
|
|
130
|
+
}));
|
|
131
|
+
strictInput(server.tool("conduyt_delete_stage", "Delete a stage from a pipeline. Strict input: the guards are spelled moveTo and expectedSettingsRevision, any other spelling (move_to, expected_settings_revision, a typo) is InvalidParams and nothing is deleted. The stage must be empty (no deals) unless moveTo names another stage of the same pipeline (same won / lost setting, not a system stage): its live deals then move there and the stage is deleted in one transaction. A stage a soft-deleted deal still points at is refused. Pass expectedSettingsRevision (the settingsRevision from conduyt_get_settings_revision, read right before) to refuse the delete when settings changed since you read them; the answer is then { id, deleted: true, settingsRevision } with the revision after the delete. A refusal carries a machine-readable code in the error text and the server's message, never swallowed: stale_settings_revision (409), pipeline_archived (409), intake_target (409, enabled intake routes deals into this stage); other 409s (DPP status map, unattended-lead automations, deals that used the stage) have no code but name the cause.", {
|
|
84
132
|
pipelineId: z.string().describe("Pipeline UUID"),
|
|
85
133
|
stageId: z.string().describe("Stage UUID to delete"),
|
|
86
|
-
|
|
87
|
-
|
|
134
|
+
moveTo: z.string().uuid().optional().describe("Another stage of the same pipeline the live deals move to (sent as move_to)"),
|
|
135
|
+
expectedSettingsRevision: z.string().regex(/^\d+$/).optional().describe("The settingsRevision from conduyt_get_settings_revision; a stale one is refused 409 stale_settings_revision (sent as expected_settings_revision)"),
|
|
136
|
+
}, async ({ pipelineId, stageId, moveTo, expectedSettingsRevision }) => {
|
|
137
|
+
const qs = new URLSearchParams();
|
|
138
|
+
if (moveTo !== undefined)
|
|
139
|
+
qs.set("move_to", moveTo);
|
|
140
|
+
if (expectedSettingsRevision !== undefined)
|
|
141
|
+
qs.set("expected_settings_revision", expectedSettingsRevision);
|
|
142
|
+
const q = qs.toString();
|
|
143
|
+
const result = await client.del(`/api/v1/pipelines/${pipelineId}/stages/${stageId}` + (q ? "?" + q : ""));
|
|
88
144
|
return formatResult(result);
|
|
89
|
-
});
|
|
145
|
+
}));
|
|
90
146
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { ConduytClient } from "../client.js";
|
|
3
|
+
/**
|
|
4
|
+
* Status metadata (the status taxonomy rows that attach a lead-status code + bucket to a pipeline STAGE; app 10-08
|
|
5
|
+
* pipeline settings). One row per stage and code. Updates and deletes take the row's updatedAt as `expectedUpdatedAt`
|
|
6
|
+
* (the version you read) so a row someone else changed or moved meanwhile is refused instead of overwritten.
|
|
7
|
+
*/
|
|
8
|
+
export declare const LIFECYCLE_STAGES: readonly ["pre_sale", "sale", "underwriting", "active", "resolution", "terminal"];
|
|
9
|
+
/** above the import route's 20 s transaction budget plus lock waits; the client default is 30 s */
|
|
10
|
+
export declare const STATUS_METADATA_IMPORT_TIMEOUT_MS = 60000;
|
|
11
|
+
export declare function registerStatusMetadataTools(server: McpServer, client: ConduytClient): void;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { formatResult } from "../client.js";
|
|
3
|
+
import { strictInput } from "./strict.js";
|
|
4
|
+
/**
|
|
5
|
+
* Status metadata (the status taxonomy rows that attach a lead-status code + bucket to a pipeline STAGE; app 10-08
|
|
6
|
+
* pipeline settings). One row per stage and code. Updates and deletes take the row's updatedAt as `expectedUpdatedAt`
|
|
7
|
+
* (the version you read) so a row someone else changed or moved meanwhile is refused instead of overwritten.
|
|
8
|
+
*/
|
|
9
|
+
export const LIFECYCLE_STAGES = ["pre_sale", "sale", "underwriting", "active", "resolution", "terminal"];
|
|
10
|
+
/** above the import route's 20 s transaction budget plus lock waits; the client default is 30 s */
|
|
11
|
+
export const STATUS_METADATA_IMPORT_TIMEOUT_MS = 60_000;
|
|
12
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9_]{0,79}$/;
|
|
13
|
+
const slug = z.string().regex(SLUG_RE, "lowercase machine slug: letters, numbers and underscores, max 80");
|
|
14
|
+
const METADATA_ERRORS = "A refusal carries a machine-readable code in the error text where the API sends one: status_metadata_not_found (404, no such row for you), metadata_changed (409, the row changed or moved since expectedUpdatedAt; the error text carries the `current` row to reload), pipeline_archived (409, the stage's pipeline was archived meanwhile), stage_not_found (404), drip_track_not_found (404). A duplicate stage + code is a 409 without a code. The message is the server's, never swallowed.";
|
|
15
|
+
export function registerStatusMetadataTools(server, client) {
|
|
16
|
+
strictInput(server.tool("conduyt_list_status_metadata", "List the status metadata rows (GET /api/v1/status-metadata; settings:view), each { id, stageId, code, bucket, lifecycleStage, dripEligible, dripTrackId, isTerminal, notes, updatedAt, stage, dripTrack }. Filter by stageId, bucket or code. updatedAt is the version to pass back as expectedUpdatedAt on update / delete. Rows of pipelines your key cannot see are left out.", {
|
|
17
|
+
stageId: z.string().uuid().optional().describe("Only this stage's rows"),
|
|
18
|
+
bucket: z.string().optional().describe("Only this bucket"),
|
|
19
|
+
code: z.string().optional().describe("Only this code"),
|
|
20
|
+
}, async ({ stageId, bucket, code }) => {
|
|
21
|
+
const qs = new URLSearchParams();
|
|
22
|
+
if (stageId !== undefined)
|
|
23
|
+
qs.set("stageId", stageId);
|
|
24
|
+
if (bucket !== undefined)
|
|
25
|
+
qs.set("bucket", bucket);
|
|
26
|
+
if (code !== undefined)
|
|
27
|
+
qs.set("code", code);
|
|
28
|
+
const q = qs.toString();
|
|
29
|
+
return formatResult(await client.get("/api/v1/status-metadata" + (q ? "?" + q : "")));
|
|
30
|
+
}));
|
|
31
|
+
strictInput(server.tool("conduyt_get_status_metadata", "Get one status metadata row with its stage and drip track (GET /api/v1/status-metadata/{id}); 404 status_metadata_not_found. Its updatedAt is the expectedUpdatedAt for an update or delete.", {
|
|
32
|
+
id: z.string().uuid().describe("Status metadata UUID"),
|
|
33
|
+
}, async ({ id }) => formatResult(await client.get(`/api/v1/status-metadata/${id}`))));
|
|
34
|
+
strictInput(server.tool("conduyt_create_status_metadata", "Create a status metadata row for a stage (POST /api/v1/status-metadata; settings:edit, active billing). code and bucket are lowercase machine slugs (letters, numbers, underscores, max 80); one row per stage and code (a duplicate is a 409). lifecycleStage is one of pre_sale, sale, underwriting, active, resolution, terminal. " + METADATA_ERRORS, {
|
|
35
|
+
stageId: z.string().uuid().describe("Pipeline stage UUID the row attaches to"),
|
|
36
|
+
code: slug.describe("Status code slug, e.g. 'appt_set'"),
|
|
37
|
+
bucket: slug.describe("Bucket slug, e.g. 'working'"),
|
|
38
|
+
lifecycleStage: z.enum(LIFECYCLE_STAGES).nullable().optional().describe("Lifecycle stage, or null"),
|
|
39
|
+
dripEligible: z.boolean().optional().describe("Eligible for a drip track"),
|
|
40
|
+
dripTrackId: z.string().uuid().nullable().optional().describe("Drip track UUID, or null"),
|
|
41
|
+
isTerminal: z.boolean().optional().describe("Terminal status"),
|
|
42
|
+
notes: z.string().max(5000).nullable().optional().describe("Free-text notes"),
|
|
43
|
+
},
|
|
44
|
+
// undefined fields are dropped by JSON.stringify, so only the ones given are sent
|
|
45
|
+
async ({ stageId, code, bucket, lifecycleStage, dripEligible, dripTrackId, isTerminal, notes }) => formatResult(await client.post("/api/v1/status-metadata", { stageId, code, bucket, lifecycleStage, dripEligible, dripTrackId, isTerminal, notes }))));
|
|
46
|
+
strictInput(server.tool("conduyt_update_status_metadata", "Update a status metadata row (PATCH /api/v1/status-metadata/{id}; settings:edit, active billing). Only the fields you pass change; at least one is needed (400 otherwise). Pass expectedUpdatedAt (the row's updatedAt as you read it) to refuse the update when the row was changed or moved to another stage since: 409 metadata_changed with the current row. " + METADATA_ERRORS, {
|
|
47
|
+
id: z.string().uuid().describe("Status metadata UUID"),
|
|
48
|
+
expectedUpdatedAt: z.string().datetime({ offset: true }).optional().describe("The row's updatedAt as you read it (ISO date); a stale one is refused 409 metadata_changed"),
|
|
49
|
+
stageId: z.string().uuid().optional().describe("Move the row to another stage"),
|
|
50
|
+
code: slug.optional().describe("Status code slug"),
|
|
51
|
+
bucket: slug.optional().describe("Bucket slug"),
|
|
52
|
+
lifecycleStage: z.enum(LIFECYCLE_STAGES).nullable().optional().describe("Lifecycle stage, or null"),
|
|
53
|
+
dripEligible: z.boolean().optional().describe("Eligible for a drip track"),
|
|
54
|
+
dripTrackId: z.string().uuid().nullable().optional().describe("Drip track UUID, or null"),
|
|
55
|
+
isTerminal: z.boolean().optional().describe("Terminal status"),
|
|
56
|
+
notes: z.string().max(5000).nullable().optional().describe("Free-text notes"),
|
|
57
|
+
}, async ({ id, expectedUpdatedAt, stageId, code, bucket, lifecycleStage, dripEligible, dripTrackId, isTerminal, notes }) => formatResult(await client.patch(`/api/v1/status-metadata/${id}`, { expectedUpdatedAt, stageId, code, bucket, lifecycleStage, dripEligible, dripTrackId, isTerminal, notes }))));
|
|
58
|
+
strictInput(server.tool("conduyt_delete_status_metadata", "Delete a status metadata row (DELETE /api/v1/status-metadata/{id}; settings:edit, active billing). Strict input: the guard is spelled expectedUpdatedAt, any other spelling (expected_updated_at, a typo) is InvalidParams and nothing is deleted. Pass expectedUpdatedAt (the row's updatedAt as you read it) to refuse the delete when the row changed or moved since: 409 metadata_changed with the current row. " + METADATA_ERRORS, {
|
|
59
|
+
id: z.string().uuid().describe("Status metadata UUID"),
|
|
60
|
+
expectedUpdatedAt: z.string().datetime({ offset: true }).optional().describe("The row's updatedAt as you read it (ISO date); sent as expected_updated_at"),
|
|
61
|
+
}, async ({ id, expectedUpdatedAt }) => {
|
|
62
|
+
const result = await client.del(`/api/v1/status-metadata/${id}` + (expectedUpdatedAt !== undefined ? "?expected_updated_at=" + encodeURIComponent(expectedUpdatedAt) : ""));
|
|
63
|
+
return formatResult(result ?? { deleted: true, id });
|
|
64
|
+
}));
|
|
65
|
+
strictInput(server.tool("conduyt_import_status_metadata", "Import status metadata rows in one set-based call (POST /api/v1/status-metadata/import; owner / admin key, active billing; max 1000 items). mode \"merge\" (default) upserts the valid items and reports the rest in errors[]; mode \"replace\" replaces the rows of the ACTIVE pipelines with the items (an EMPTY items list with replace would delete them all and is refused before any request) and, if any item is invalid, imports nothing (imported 0, every item skipped, errors[] says which). Rows of archived pipelines are kept by a replace. Answers { imported, skipped, errors: [{ stageId, reason }] }; check errors even on a 200. An item naming a stage of an ARCHIVED pipeline refuses the whole import: 409 pipeline_archived with the stageIds. Items name a drip track by dripTrackName (not id); an item with a key outside stageId, code, bucket, lifecycleStage, dripEligible, dripTrackName, isTerminal, notes (a misspelled isTerminal, say) is refused as InvalidParams before any request, never stored with defaults. The call can take up to 60 s (a 60 s per-call timeout is used); if it still reports a timeout, the import may have been applied: re-list the rows with conduyt_list_status_metadata before importing again.", {
|
|
66
|
+
mode: z.enum(["merge", "replace"]).optional().describe("merge (default) or replace"),
|
|
67
|
+
items: z.array(z.object({
|
|
68
|
+
stageId: z.string().uuid().describe("Pipeline stage UUID"),
|
|
69
|
+
code: slug.describe("Status code slug"),
|
|
70
|
+
bucket: slug.describe("Bucket slug"),
|
|
71
|
+
lifecycleStage: z.enum(LIFECYCLE_STAGES).nullable().optional().describe("Lifecycle stage, or null"),
|
|
72
|
+
dripEligible: z.boolean().optional().describe("Eligible for a drip track (default false)"),
|
|
73
|
+
dripTrackName: slug.nullable().optional().describe("Drip track NAME (slug), or null"),
|
|
74
|
+
isTerminal: z.boolean().optional().describe("Terminal status (default false)"),
|
|
75
|
+
notes: z.string().max(5000).nullable().optional().describe("Free-text notes"),
|
|
76
|
+
}).strict()).max(1000).describe("The rows, max 1000; one per stageId and one per code within the call"),
|
|
77
|
+
}, async ({ mode, items }) => {
|
|
78
|
+
// mode replace with no items deletes the whole active taxonomy on the server: refused here, before any request
|
|
79
|
+
if (mode === "replace" && items.length === 0) {
|
|
80
|
+
throw new Error("mode replace with no items would delete every status metadata row of the active pipelines; refused. Pass the rows to keep (at least one), or use mode merge.");
|
|
81
|
+
}
|
|
82
|
+
return formatResult(await client.post("/api/v1/status-metadata/import", { ...(mode !== undefined ? { mode } : {}), items }, { timeoutMs: STATUS_METADATA_IMPORT_TIMEOUT_MS }));
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SDK wraps a tool's raw Zod shape in a NON-strict object, so an argument the tool does not declare (a typo, or a
|
|
3
|
+
* field the API does not take, e.g. lostReasonAllowOther) is silently stripped and the call "succeeds" without it. For a
|
|
4
|
+
* tool where that would widen or drop a change, `strictInput(server.tool(...))` swaps the registered input schema for
|
|
5
|
+
* its strict form: an unknown key is an InvalidParams error and the advertised JSON schema says additionalProperties: false.
|
|
6
|
+
* (The SDK's server.tool() only takes a raw shape, so the swap is done on the registered tool.) A server stub that
|
|
7
|
+
* returns nothing is left alone.
|
|
8
|
+
*/
|
|
9
|
+
export declare function strictInput<T>(tool: T): T;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z as zod } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* The SDK wraps a tool's raw Zod shape in a NON-strict object, so an argument the tool does not declare (a typo, or a
|
|
4
|
+
* field the API does not take, e.g. lostReasonAllowOther) is silently stripped and the call "succeeds" without it. For a
|
|
5
|
+
* tool where that would widen or drop a change, `strictInput(server.tool(...))` swaps the registered input schema for
|
|
6
|
+
* its strict form: an unknown key is an InvalidParams error and the advertised JSON schema says additionalProperties: false.
|
|
7
|
+
* (The SDK's server.tool() only takes a raw shape, so the swap is done on the registered tool.) A server stub that
|
|
8
|
+
* returns nothing is left alone.
|
|
9
|
+
*/
|
|
10
|
+
export function strictInput(tool) {
|
|
11
|
+
const t = tool;
|
|
12
|
+
if (t && typeof t.inputSchema?.strict === "function")
|
|
13
|
+
t.inputSchema = t.inputSchema.strict();
|
|
14
|
+
// a tool registered with an EMPTY raw shape gets the SDK's own non-strict empty object (zod mini, no .strict()), which strips any
|
|
15
|
+
// argument (a `dryRun: true` sent to a push or a confirm would be silently dropped): swap in an explicit strict empty object
|
|
16
|
+
else if (t && typeof t === "object" && "handler" in t && Object.keys(t.inputSchema?.shape ?? {}).length === 0)
|
|
17
|
+
t.inputSchema = zod.object({}).strict();
|
|
18
|
+
return tool;
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -380,6 +380,11 @@
|
|
|
380
380
|
"status": "planned",
|
|
381
381
|
"reason": "email warm-up engine surfaces"
|
|
382
382
|
},
|
|
383
|
+
{
|
|
384
|
+
"match": "/api/v1/integrations/mailchimp/oauth",
|
|
385
|
+
"status": "excluded",
|
|
386
|
+
"reason": "browser-only OAuth connect + callback: the owner signs in to Mailchimp in a browser and the callback is a redirect, nothing a key can do"
|
|
387
|
+
},
|
|
383
388
|
{
|
|
384
389
|
"match": "/api/v1/integrations",
|
|
385
390
|
"status": "planned",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-10-
|
|
3
|
-
"toolCount":
|
|
4
|
-
"totalEndpoints":
|
|
2
|
+
"generatedAt": "2026-10-09T01:29:38.747Z",
|
|
3
|
+
"toolCount": 328,
|
|
4
|
+
"totalEndpoints": 1064,
|
|
5
5
|
"counts": {
|
|
6
|
-
"covered":
|
|
6
|
+
"covered": 294,
|
|
7
7
|
"excluded": 318,
|
|
8
|
-
"planned":
|
|
8
|
+
"planned": 452,
|
|
9
9
|
"uncategorized": 0
|
|
10
10
|
},
|
|
11
11
|
"matrix": {
|
|
@@ -1773,6 +1773,10 @@
|
|
|
1773
1773
|
"status": "excluded",
|
|
1774
1774
|
"reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
|
|
1775
1775
|
},
|
|
1776
|
+
"GET /api/v1/cron/process-google-ads-conversions": {
|
|
1777
|
+
"status": "excluded",
|
|
1778
|
+
"reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
|
|
1779
|
+
},
|
|
1776
1780
|
"GET /api/v1/cron/process-import-side-effects": {
|
|
1777
1781
|
"status": "excluded",
|
|
1778
1782
|
"reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
|
|
@@ -1789,6 +1793,10 @@
|
|
|
1789
1793
|
"status": "excluded",
|
|
1790
1794
|
"reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
|
|
1791
1795
|
},
|
|
1796
|
+
"GET /api/v1/cron/process-mailchimp-sync": {
|
|
1797
|
+
"status": "excluded",
|
|
1798
|
+
"reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
|
|
1799
|
+
},
|
|
1792
1800
|
"GET /api/v1/cron/process-message-insights": {
|
|
1793
1801
|
"status": "excluded",
|
|
1794
1802
|
"reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
|
|
@@ -2206,8 +2214,10 @@
|
|
|
2206
2214
|
"reason": "deal subroutes beyond core CRUD (history, files, links)"
|
|
2207
2215
|
},
|
|
2208
2216
|
"GET /api/v1/deals/inspection": {
|
|
2209
|
-
"status": "
|
|
2210
|
-
"
|
|
2217
|
+
"status": "covered",
|
|
2218
|
+
"tools": [
|
|
2219
|
+
"conduyt_pipeline_health"
|
|
2220
|
+
]
|
|
2211
2221
|
},
|
|
2212
2222
|
"POST /api/v1/deals/probabilities": {
|
|
2213
2223
|
"status": "planned",
|
|
@@ -2977,6 +2987,56 @@
|
|
|
2977
2987
|
"status": "planned",
|
|
2978
2988
|
"reason": "integration management"
|
|
2979
2989
|
},
|
|
2990
|
+
"GET /api/v1/integrations/mailchimp/audiences": {
|
|
2991
|
+
"status": "covered",
|
|
2992
|
+
"tools": [
|
|
2993
|
+
"conduyt_list_mailchimp_audiences"
|
|
2994
|
+
]
|
|
2995
|
+
},
|
|
2996
|
+
"POST /api/v1/integrations/mailchimp/confirm-reconciliation": {
|
|
2997
|
+
"status": "covered",
|
|
2998
|
+
"tools": [
|
|
2999
|
+
"conduyt_mailchimp_confirm_reconciliation"
|
|
3000
|
+
]
|
|
3001
|
+
},
|
|
3002
|
+
"GET /api/v1/integrations/mailchimp/oauth/callback": {
|
|
3003
|
+
"status": "excluded",
|
|
3004
|
+
"reason": "browser-only OAuth connect + callback: the owner signs in to Mailchimp in a browser and the callback is a redirect, nothing a key can do"
|
|
3005
|
+
},
|
|
3006
|
+
"GET /api/v1/integrations/mailchimp/oauth/install": {
|
|
3007
|
+
"status": "excluded",
|
|
3008
|
+
"reason": "browser-only OAuth connect + callback: the owner signs in to Mailchimp in a browser and the callback is a redirect, nothing a key can do"
|
|
3009
|
+
},
|
|
3010
|
+
"POST /api/v1/integrations/mailchimp/push": {
|
|
3011
|
+
"status": "covered",
|
|
3012
|
+
"tools": [
|
|
3013
|
+
"conduyt_mailchimp_push_now"
|
|
3014
|
+
]
|
|
3015
|
+
},
|
|
3016
|
+
"POST /api/v1/integrations/mailchimp/reconcile-statuses": {
|
|
3017
|
+
"status": "covered",
|
|
3018
|
+
"tools": [
|
|
3019
|
+
"conduyt_mailchimp_reconcile_statuses"
|
|
3020
|
+
]
|
|
3021
|
+
},
|
|
3022
|
+
"GET /api/v1/integrations/mailchimp/settings": {
|
|
3023
|
+
"status": "covered",
|
|
3024
|
+
"tools": [
|
|
3025
|
+
"conduyt_get_mailchimp_settings"
|
|
3026
|
+
]
|
|
3027
|
+
},
|
|
3028
|
+
"PUT /api/v1/integrations/mailchimp/settings": {
|
|
3029
|
+
"status": "covered",
|
|
3030
|
+
"tools": [
|
|
3031
|
+
"conduyt_update_mailchimp_settings"
|
|
3032
|
+
]
|
|
3033
|
+
},
|
|
3034
|
+
"POST /api/v1/integrations/mailchimp/unsubscribes/retry": {
|
|
3035
|
+
"status": "covered",
|
|
3036
|
+
"tools": [
|
|
3037
|
+
"conduyt_mailchimp_retry_unsubscribes"
|
|
3038
|
+
]
|
|
3039
|
+
},
|
|
2980
3040
|
"GET /api/v1/integrations/slack/channels": {
|
|
2981
3041
|
"status": "planned",
|
|
2982
3042
|
"reason": "integration management"
|
|
@@ -3204,7 +3264,8 @@
|
|
|
3204
3264
|
"GET /api/v1/pipelines": {
|
|
3205
3265
|
"status": "covered",
|
|
3206
3266
|
"tools": [
|
|
3207
|
-
"conduyt_list_pipelines"
|
|
3267
|
+
"conduyt_list_pipelines",
|
|
3268
|
+
"conduyt_list_archived_pipelines"
|
|
3208
3269
|
]
|
|
3209
3270
|
},
|
|
3210
3271
|
"POST /api/v1/pipelines": {
|
|
@@ -3229,6 +3290,18 @@
|
|
|
3229
3290
|
"conduyt_delete_pipeline"
|
|
3230
3291
|
]
|
|
3231
3292
|
},
|
|
3293
|
+
"POST /api/v1/pipelines/:id/archive": {
|
|
3294
|
+
"status": "covered",
|
|
3295
|
+
"tools": [
|
|
3296
|
+
"conduyt_archive_pipeline"
|
|
3297
|
+
]
|
|
3298
|
+
},
|
|
3299
|
+
"POST /api/v1/pipelines/:id/restore": {
|
|
3300
|
+
"status": "covered",
|
|
3301
|
+
"tools": [
|
|
3302
|
+
"conduyt_restore_pipeline"
|
|
3303
|
+
]
|
|
3304
|
+
},
|
|
3232
3305
|
"GET /api/v1/pipelines/:id/stages": {
|
|
3233
3306
|
"status": "planned",
|
|
3234
3307
|
"reason": "pipeline subroutes beyond core CRUD"
|
|
@@ -3255,6 +3328,18 @@
|
|
|
3255
3328
|
"conduyt_delete_stage"
|
|
3256
3329
|
]
|
|
3257
3330
|
},
|
|
3331
|
+
"POST /api/v1/pipelines/:id/stages/reorder": {
|
|
3332
|
+
"status": "covered",
|
|
3333
|
+
"tools": [
|
|
3334
|
+
"conduyt_reorder_stages"
|
|
3335
|
+
]
|
|
3336
|
+
},
|
|
3337
|
+
"POST /api/v1/pipelines/reorder": {
|
|
3338
|
+
"status": "covered",
|
|
3339
|
+
"tools": [
|
|
3340
|
+
"conduyt_reorder_pipelines"
|
|
3341
|
+
]
|
|
3342
|
+
},
|
|
3258
3343
|
"GET /api/v1/playbook-enrollments": {
|
|
3259
3344
|
"status": "planned",
|
|
3260
3345
|
"reason": "playbook enrollment surfaces"
|
|
@@ -3864,6 +3949,10 @@
|
|
|
3864
3949
|
"GET /api/v1/settings": {
|
|
3865
3950
|
"status": "covered",
|
|
3866
3951
|
"tools": [
|
|
3952
|
+
"conduyt_get_stage_rules",
|
|
3953
|
+
"conduyt_get_deal_card_fields",
|
|
3954
|
+
"conduyt_get_settings_revision",
|
|
3955
|
+
"conduyt_get_pipeline_rot_days",
|
|
3867
3956
|
"conduyt_get_contact_table_defaults",
|
|
3868
3957
|
"conduyt_set_contact_table_defaults",
|
|
3869
3958
|
"conduyt_get_call_recording_settings",
|
|
@@ -4285,24 +4374,34 @@
|
|
|
4285
4374
|
"reason": "SAML/SSO configuration + protocol endpoints; interactive/IdP-facing"
|
|
4286
4375
|
},
|
|
4287
4376
|
"GET /api/v1/status-metadata": {
|
|
4288
|
-
"status": "
|
|
4289
|
-
"
|
|
4377
|
+
"status": "covered",
|
|
4378
|
+
"tools": [
|
|
4379
|
+
"conduyt_list_status_metadata"
|
|
4380
|
+
]
|
|
4290
4381
|
},
|
|
4291
4382
|
"POST /api/v1/status-metadata": {
|
|
4292
|
-
"status": "
|
|
4293
|
-
"
|
|
4383
|
+
"status": "covered",
|
|
4384
|
+
"tools": [
|
|
4385
|
+
"conduyt_create_status_metadata"
|
|
4386
|
+
]
|
|
4294
4387
|
},
|
|
4295
4388
|
"GET /api/v1/status-metadata/:id": {
|
|
4296
|
-
"status": "
|
|
4297
|
-
"
|
|
4389
|
+
"status": "covered",
|
|
4390
|
+
"tools": [
|
|
4391
|
+
"conduyt_get_status_metadata"
|
|
4392
|
+
]
|
|
4298
4393
|
},
|
|
4299
4394
|
"PATCH /api/v1/status-metadata/:id": {
|
|
4300
|
-
"status": "
|
|
4301
|
-
"
|
|
4395
|
+
"status": "covered",
|
|
4396
|
+
"tools": [
|
|
4397
|
+
"conduyt_update_status_metadata"
|
|
4398
|
+
]
|
|
4302
4399
|
},
|
|
4303
4400
|
"DELETE /api/v1/status-metadata/:id": {
|
|
4304
|
-
"status": "
|
|
4305
|
-
"
|
|
4401
|
+
"status": "covered",
|
|
4402
|
+
"tools": [
|
|
4403
|
+
"conduyt_delete_status_metadata"
|
|
4404
|
+
]
|
|
4306
4405
|
},
|
|
4307
4406
|
"GET /api/v1/status-metadata/buckets": {
|
|
4308
4407
|
"status": "excluded",
|
|
@@ -4321,8 +4420,10 @@
|
|
|
4321
4420
|
"reason": "UI enum/metadata hydration for dashboard pickers; tools embed their enums"
|
|
4322
4421
|
},
|
|
4323
4422
|
"POST /api/v1/status-metadata/import": {
|
|
4324
|
-
"status": "
|
|
4325
|
-
"
|
|
4423
|
+
"status": "covered",
|
|
4424
|
+
"tools": [
|
|
4425
|
+
"conduyt_import_status_metadata"
|
|
4426
|
+
]
|
|
4326
4427
|
},
|
|
4327
4428
|
"POST /api/v1/support/requests": {
|
|
4328
4429
|
"status": "excluded",
|
|
@@ -4599,6 +4700,14 @@
|
|
|
4599
4700
|
"status": "excluded",
|
|
4600
4701
|
"reason": "inbound provider callback receivers (Twilio/Resend/etc.), not client-callable"
|
|
4601
4702
|
},
|
|
4703
|
+
"GET /api/v1/webhooks/mailchimp/:connectionId": {
|
|
4704
|
+
"status": "excluded",
|
|
4705
|
+
"reason": "inbound provider callback receivers (Twilio/Resend/etc.), not client-callable"
|
|
4706
|
+
},
|
|
4707
|
+
"POST /api/v1/webhooks/mailchimp/:connectionId": {
|
|
4708
|
+
"status": "excluded",
|
|
4709
|
+
"reason": "inbound provider callback receivers (Twilio/Resend/etc.), not client-callable"
|
|
4710
|
+
},
|
|
4602
4711
|
"GET /api/v1/webhooks/manage": {
|
|
4603
4712
|
"status": "covered",
|
|
4604
4713
|
"tools": [
|