conduyt-mcp 4.67.0 → 4.69.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.
@@ -0,0 +1,6 @@
1
+ /** the exact compile verdict of the app for one rule: true when advancedRuleToWhere would return a condition */
2
+ export declare function advancedRuleCompiles(rule: {
3
+ field: string;
4
+ operator: string;
5
+ value?: unknown;
6
+ }): boolean;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * (Codex mcp/cli r6 h1) DOES THIS RULE COMPILE? — the null-returning paths of advancedRuleToWhere / ruleToWhere /
3
+ * customFieldRuleToWhere / masterStatusRuleToWhere in lib/smart-view-filters.ts, as ONE pure predicate with no Prisma and no
4
+ * imports, so the MCP server and the CLI can carry it VERBATIM and refuse exactly what the app's validator refuses (the app's
5
+ * validator runs the real compiler; lib/advanced-filter-compilability.test.ts proves this predicate agrees with it over an
6
+ * adversarial value matrix for every field, operator and custom-field operator). Keep in sync with the compiler.
7
+ */
8
+ const CF_FIELD_PREFIX = "cf:";
9
+ const CF_OPERATORS = new Set(["equals", "not_equals", "contains", "is_any_of", "is", "is_not", "num_equals", "num_not_equals", "gt", "gte", "lt", "lte", "is_true", "is_false", "has_any_of", "has_all_of", "has_none_of"]);
10
+ const SCALAR_DATE_FIELDS = new Set(["lastContactedAt", "lastActionAt", "lastContactAttemptAt", "createdAt", "updatedAt"]);
11
+ const LIST_FIELDS = new Set(["tags", "pipeline", "pipelineStage", "dealStatus", "email", "phone", "source", "assignedTo"]);
12
+ const STRING_FIELDS = new Set(["city", "state", "jobTitle"]);
13
+ function cfKeyOf(field) {
14
+ if (!field.startsWith(CF_FIELD_PREFIX))
15
+ return null;
16
+ const key = field.slice(CF_FIELD_PREFIX.length).trim();
17
+ return key.length > 0 ? key : null;
18
+ }
19
+ function isValidIsoDate(value) {
20
+ const ts = Date.parse(value);
21
+ return !isNaN(ts) && new Date(ts).toISOString() !== "Invalid Date";
22
+ }
23
+ const has = (set, op) => set.includes(op);
24
+ /** the exact compile verdict of the app for one rule: true when advancedRuleToWhere would return a condition */
25
+ export function advancedRuleCompiles(rule) {
26
+ const { field, operator: rawOp, value } = rule;
27
+ const operator = rawOp === "is" ? "equals" : rawOp === "is_not" ? "not_equals" : rawOp;
28
+ const strValue = Array.isArray(value) ? value.join(",") : value;
29
+ // String(undefined) is "undefined" in the compiler's own coercions: an absent value there is a one-element list, verbatim
30
+ const listOf = (v) => (Array.isArray(v) ? v : String(v).split(",")).map((x) => String(x).trim()).filter(Boolean);
31
+ const cfKey = cfKeyOf(field);
32
+ if (cfKey) {
33
+ const op = CF_OPERATORS.has(rawOp) ? rawOp : operator;
34
+ switch (op) {
35
+ case "equals":
36
+ case "is":
37
+ case "not_equals":
38
+ case "is_not":
39
+ case "contains":
40
+ case "is_true":
41
+ case "is_false": return true;
42
+ case "is_any_of": return (Array.isArray(value) ? value : String(value).split(",").map((v) => v.trim()).filter(Boolean)).length > 0;
43
+ case "num_equals":
44
+ case "num_not_equals":
45
+ case "gt":
46
+ case "gte":
47
+ case "lt":
48
+ case "lte": {
49
+ const s = strValue ?? "";
50
+ return s.trim() !== "" && Number.isFinite(Number(s));
51
+ }
52
+ case "has_any_of":
53
+ case "has_all_of":
54
+ case "has_none_of": return listOf(value).length > 0;
55
+ default: return false;
56
+ }
57
+ }
58
+ if (field === "masterStatus") {
59
+ const list = (Array.isArray(value) ? value : String(value ?? "").split(",")).map((v) => String(v).trim().toLowerCase()).filter(Boolean);
60
+ if (list.length === 0)
61
+ return false;
62
+ if (rawOp === "is_any_of" || rawOp === "is_none_of")
63
+ return true;
64
+ return operator === "equals" || operator === "not_equals";
65
+ }
66
+ if (rawOp === "is_any_of") {
67
+ const values = Array.isArray(value) ? value : String(value ?? "").split(",").map((v) => v.trim()).filter(Boolean);
68
+ return values.length > 0 && LIST_FIELDS.has(field);
69
+ }
70
+ if (field === "company")
71
+ return has(["is_empty", "is_not_empty", "equals", "not_equals", "contains"], operator);
72
+ // (Codex pool r28 h1) a name-bearing tag rule needs at least one name (lib/tag-rule-names.ts: an array is one name per
73
+ // element, a scalar splits on commas; blanks dropped)
74
+ if (field === "tags" && has(["contains", "not_contains", "equals", "not_equals"], operator)) {
75
+ const names = Array.isArray(value) ? value.filter((x) => typeof x === "string").map((x) => x.trim()).filter(Boolean) : typeof value === "string" ? value.split(",").map((x) => x.trim()).filter(Boolean) : [];
76
+ return names.length > 0;
77
+ }
78
+ // ruleToWhere on the flattened value
79
+ const v = strValue ?? "";
80
+ // a duration is parsed with String.prototype.match: an ABSENT value throws inside the compiler (dropped), any string parses
81
+ const hasString = typeof strValue === "string";
82
+ if (SCALAR_DATE_FIELDS.has(field)) {
83
+ if (operator === "is_empty" || operator === "is_not_empty")
84
+ return true;
85
+ if (operator === "within" || operator === "not_within")
86
+ return hasString;
87
+ if (operator === "before" || operator === "after")
88
+ return isValidIsoDate(v);
89
+ return false;
90
+ }
91
+ if (field === "lastEmailAt" || field === "lastSmsAt") {
92
+ if (operator === "is_empty" || operator === "is_not_empty")
93
+ return true;
94
+ if (operator === "within" || operator === "not_within")
95
+ return hasString;
96
+ if (operator === "before" || operator === "after")
97
+ return isValidIsoDate(v);
98
+ return false;
99
+ }
100
+ if (field === "lastCallAt")
101
+ return operator === "is_empty" || operator === "is_not_empty" || (hasString && (operator === "within" || operator === "not_within"));
102
+ if (field === "callAttempts")
103
+ return true; // an unsupported boundary compiles to the impossible filter (the validator gates the value)
104
+ if (field === "nextAppointmentAt")
105
+ return operator === "is_empty" || operator === "is_not_empty" || (hasString && (operator === "within" || operator === "not_within"));
106
+ if (field === "appointmentStatus")
107
+ return has(["equals", "not_equals"], operator);
108
+ if (STRING_FIELDS.has(field))
109
+ return has(["is_empty", "is_not_empty", "equals", "not_equals", "contains"], operator);
110
+ if (field === "country")
111
+ return has(["equals", "not_equals"], operator);
112
+ if (field === "optedOutSms" || field === "optedOutEmail")
113
+ return operator === "equals";
114
+ if (field === "leadScore")
115
+ return v.trim() !== "" && Number.isFinite(Number(v.trim())) && has(["gt", "lt", "gte", "lte", "equals"], operator); // (Codex pool r28 m1) the whole value
116
+ if (field === "email" || field === "phone")
117
+ return has(["is_empty", "is_not_empty", "contains", "equals", "not_equals"], operator);
118
+ if (field === "source")
119
+ return has(["equals", "not_equals", "contains", "is_empty", "is_not_empty"], operator);
120
+ if (field === "assignedTo")
121
+ return has(["equals", "not_equals", "is_empty", "is_not_empty"], operator);
122
+ if (field === "tags")
123
+ return has(["is_empty", "is_not_empty"], operator);
124
+ if (field === "pipeline" || field === "pipelineStage" || field === "dealStatus")
125
+ return has(["equals", "not_equals", "is_empty", "is_not_empty"], operator);
126
+ return false;
127
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * PORTED VERBATIM from conduyt-app lib/smart-view-filters.ts @ slice B r28 (bfba9b0ef + the non-string array element refusal; the app's validator additionally compiles every rule — see validateAdvancedFiltersEffective below) — validateAdvancedFilters, its tables and its
3
+ * value rules — so a call this surface accepts is never a predictable 400 from the API (Codex mcp r2 m1 / cli r2 m1).
4
+ * Keep in sync with the app: when validateAdvancedFilters, VALID_FIELDS, VALID_OPERATORS, ADVANCED_OPERATORS,
5
+ * CF_OPERATORS, VALUELESS_OPERATORS, DATE_FILTER_FIELDS or isSupportedCallAttemptsFilter change there, re-port here.
6
+ */
7
+ export declare const CF_FIELD_PREFIX = "cf:";
8
+ export declare const MAX_FILTER_GROUPS = 10;
9
+ export declare const MAX_RULES_PER_GROUP = 20;
10
+ export declare const VALID_FIELDS: Set<string>;
11
+ export declare const ADVANCED_OPERATORS: Record<string, Set<string>>;
12
+ export declare const CF_OPERATORS: Set<string>;
13
+ export declare function customFieldKey(field: string): string | null;
14
+ export type FilterGroupPayload = {
15
+ conjunction: "AND" | "OR";
16
+ rules: Array<{
17
+ field: string;
18
+ operator: string;
19
+ value?: string | string[];
20
+ }>;
21
+ };
22
+ /** the API's validateAdvancedFilters, verbatim: the same verdict and the same message for the same payload */
23
+ export declare function validateAdvancedFilters(payload: unknown): {
24
+ valid: true;
25
+ groups: FilterGroupPayload[];
26
+ } | {
27
+ valid: false;
28
+ error: string;
29
+ };
30
+ /** validateAdvancedFilters (the port) PLUS the compile verdict per rule: the same answer the API gives a write */
31
+ export declare function validateAdvancedFiltersEffective(payload: unknown): {
32
+ valid: true;
33
+ groups: FilterGroupPayload[];
34
+ } | {
35
+ valid: false;
36
+ error: string;
37
+ };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * PORTED VERBATIM from conduyt-app lib/smart-view-filters.ts @ slice B r28 (bfba9b0ef + the non-string array element refusal; the app's validator additionally compiles every rule — see validateAdvancedFiltersEffective below) — validateAdvancedFilters, its tables and its
3
+ * value rules — so a call this surface accepts is never a predictable 400 from the API (Codex mcp r2 m1 / cli r2 m1).
4
+ * Keep in sync with the app: when validateAdvancedFilters, VALID_FIELDS, VALID_OPERATORS, ADVANCED_OPERATORS,
5
+ * CF_OPERATORS, VALUELESS_OPERATORS, DATE_FILTER_FIELDS or isSupportedCallAttemptsFilter change there, re-port here.
6
+ */
7
+ export const CF_FIELD_PREFIX = "cf:";
8
+ export const MAX_FILTER_GROUPS = 10;
9
+ export const MAX_RULES_PER_GROUP = 20;
10
+ export const VALID_FIELDS = new Set([
11
+ "lastContactedAt", "lastActionAt", "lastContactAttemptAt", "createdAt", "updatedAt", "lastEmailAt", "lastSmsAt",
12
+ "lastCallAt", "callAttempts", "nextAppointmentAt", "appointmentStatus",
13
+ "city", "state", "country", "jobTitle", "optedOutSms", "optedOutEmail",
14
+ "leadScore", "email", "phone", "source", "assignedTo", "tags",
15
+ "pipeline", "pipelineStage", "dealStatus", "company",
16
+ "masterStatus",
17
+ ]);
18
+ const VALID_OPERATORS = {
19
+ lastContactedAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
20
+ lastActionAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
21
+ lastContactAttemptAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
22
+ createdAt: new Set(["within", "not_within", "before", "after"]),
23
+ updatedAt: new Set(["within", "not_within", "before", "after"]),
24
+ lastEmailAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
25
+ lastSmsAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
26
+ lastCallAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
27
+ callAttempts: new Set(["gt", "lt", "gte", "lte", "equals"]),
28
+ nextAppointmentAt: new Set(["within", "not_within", "is_empty", "is_not_empty"]),
29
+ appointmentStatus: new Set(["equals", "not_equals"]),
30
+ leadScore: new Set(["gt", "lt", "gte", "lte", "equals"]),
31
+ email: new Set(["is_empty", "is_not_empty", "contains"]),
32
+ phone: new Set(["is_empty", "is_not_empty", "contains"]),
33
+ source: new Set(["equals", "not_equals", "contains"]),
34
+ assignedTo: new Set(["equals", "not_equals", "is_empty"]),
35
+ tags: new Set(["contains", "not_contains", "is_empty", "is_not_empty"]),
36
+ pipeline: new Set(["equals", "not_equals", "is_empty", "is_not_empty"]),
37
+ pipelineStage: new Set(["equals", "not_equals", "is_empty", "is_not_empty"]),
38
+ dealStatus: new Set(["equals", "not_equals", "is_empty", "is_not_empty"]),
39
+ masterStatus: new Set(["equals", "not_equals", "is_any_of", "is_none_of"]),
40
+ city: new Set(["equals", "not_equals", "contains", "is_empty", "is_not_empty"]),
41
+ state: new Set(["equals", "not_equals", "contains", "is_empty", "is_not_empty"]),
42
+ country: new Set(["equals", "not_equals"]),
43
+ jobTitle: new Set(["equals", "not_equals", "contains", "is_empty", "is_not_empty"]),
44
+ optedOutSms: new Set(["equals"]),
45
+ optedOutEmail: new Set(["equals"]),
46
+ company: new Set(["equals", "not_equals", "contains", "is_empty", "is_not_empty"]),
47
+ };
48
+ export const ADVANCED_OPERATORS = {
49
+ lastContactedAt: new Set([...VALID_OPERATORS.lastContactedAt, "before", "after"]),
50
+ lastActionAt: new Set([...VALID_OPERATORS.lastActionAt, "before", "after"]),
51
+ lastContactAttemptAt: new Set([...VALID_OPERATORS.lastContactAttemptAt, "before", "after"]),
52
+ createdAt: new Set([...VALID_OPERATORS.createdAt, "is_empty", "is_not_empty"]),
53
+ updatedAt: new Set([...VALID_OPERATORS.updatedAt, "is_empty", "is_not_empty"]),
54
+ lastEmailAt: new Set([...VALID_OPERATORS.lastEmailAt, "is_not_empty", "before", "after"]),
55
+ lastSmsAt: new Set([...VALID_OPERATORS.lastSmsAt, "is_not_empty", "before", "after"]),
56
+ lastCallAt: new Set([...VALID_OPERATORS.lastCallAt, "before", "after"]),
57
+ callAttempts: new Set([...VALID_OPERATORS.callAttempts]),
58
+ nextAppointmentAt: new Set([...VALID_OPERATORS.nextAppointmentAt]),
59
+ appointmentStatus: new Set([...VALID_OPERATORS.appointmentStatus, "is", "is_not", "is_any_of"]),
60
+ leadScore: new Set([...VALID_OPERATORS.leadScore]),
61
+ email: new Set([...VALID_OPERATORS.email, "is", "is_not", "is_any_of"]),
62
+ phone: new Set([...VALID_OPERATORS.phone, "is", "is_not", "is_any_of"]),
63
+ source: new Set([...VALID_OPERATORS.source, "is", "is_not", "is_empty", "is_not_empty", "is_any_of"]),
64
+ assignedTo: new Set([...VALID_OPERATORS.assignedTo, "is", "is_not", "is_not_empty"]),
65
+ tags: new Set([...VALID_OPERATORS.tags, "is", "is_not", "is_any_of"]),
66
+ pipeline: new Set([...VALID_OPERATORS.pipeline, "is", "is_not", "is_any_of"]),
67
+ pipelineStage: new Set([...VALID_OPERATORS.pipelineStage, "is", "is_not", "is_empty", "is_not_empty", "is_any_of"]),
68
+ dealStatus: new Set([...VALID_OPERATORS.dealStatus, "is", "is_not", "is_empty", "is_not_empty", "is_any_of"]),
69
+ masterStatus: new Set([...VALID_OPERATORS.masterStatus, "is", "is_not"]),
70
+ city: new Set([...VALID_OPERATORS.city, "is", "is_not", "is_any_of"]),
71
+ state: new Set([...VALID_OPERATORS.state, "is", "is_not", "is_any_of"]),
72
+ country: new Set([...VALID_OPERATORS.country, "is", "is_not", "is_any_of"]),
73
+ jobTitle: new Set([...VALID_OPERATORS.jobTitle, "is", "is_not", "is_any_of"]),
74
+ optedOutSms: new Set([...VALID_OPERATORS.optedOutSms]),
75
+ optedOutEmail: new Set([...VALID_OPERATORS.optedOutEmail]),
76
+ company: new Set(["is", "is_not", "contains", "is_empty", "is_not_empty"]),
77
+ };
78
+ export const CF_OPERATORS = new Set([
79
+ "equals", "not_equals", "contains", "is_any_of", "is", "is_not",
80
+ "num_equals", "num_not_equals", "gt", "gte", "lt", "lte", "is_true", "is_false",
81
+ "has_any_of", "has_all_of", "has_none_of",
82
+ ]);
83
+ const VALUELESS_OPERATORS = new Set(["is_empty", "is_not_empty", "is_true", "is_false"]);
84
+ const DATE_FILTER_FIELDS = new Set([
85
+ "createdAt", "updatedAt", "lastContactedAt", "lastActionAt", "lastContactAttemptAt", "lastEmailAt", "lastSmsAt", "lastCallAt", "nextAppointmentAt",
86
+ ]);
87
+ export function customFieldKey(field) {
88
+ if (!field.startsWith(CF_FIELD_PREFIX))
89
+ return null;
90
+ const key = field.slice(CF_FIELD_PREFIX.length).trim();
91
+ return key.length > 0 ? key : null;
92
+ }
93
+ function normalizeOperator(_field, operator) {
94
+ if (operator === "is")
95
+ return "equals";
96
+ if (operator === "is_not")
97
+ return "not_equals";
98
+ return operator;
99
+ }
100
+ function isSupportedCallAttemptsFilter(operator, value) {
101
+ const num = Number(value);
102
+ if (!Number.isInteger(num) || num < 0)
103
+ return false;
104
+ return ((operator === "equals" && num === 0) ||
105
+ (operator === "gt" && num === 0) ||
106
+ (operator === "gte" && num === 1) ||
107
+ (operator === "lt" && num === 1) ||
108
+ (operator === "lte" && num === 0));
109
+ }
110
+ function validateCommonRuleValue(field, operator, value, label) {
111
+ if (!VALUELESS_OPERATORS.has(operator)) {
112
+ if (typeof value !== "string" || value.trim() === "")
113
+ return `${label}: value is required for ${field} ${operator}`;
114
+ }
115
+ if (field === "callAttempts" && typeof value === "string") {
116
+ if (!isSupportedCallAttemptsFilter(operator, value))
117
+ return `${label}: callAttempts only supports exact zero-call or has-call boundary filters`;
118
+ }
119
+ if (DATE_FILTER_FIELDS.has(field) && typeof value === "string" && value.trim() !== "") {
120
+ if (operator === "before" || operator === "after") {
121
+ if (isNaN(Date.parse(value)))
122
+ return `${label}: invalid date value "${value}" for ${operator}`;
123
+ }
124
+ else if (operator === "within" || operator === "not_within") {
125
+ if (!/^\d+(h|d|m)$/.test(value))
126
+ return `${label}: invalid duration value "${value}" — expected format like "7d", "24h", or "3m"`;
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+ /** the API's validateAdvancedFilters, verbatim: the same verdict and the same message for the same payload */
132
+ export function validateAdvancedFilters(payload) {
133
+ if (!Array.isArray(payload))
134
+ return { valid: false, error: "Filter payload must be an array of groups" };
135
+ if (payload.length > MAX_FILTER_GROUPS)
136
+ return { valid: false, error: `Too many filter groups (max ${MAX_FILTER_GROUPS})` };
137
+ for (let gi = 0; gi < payload.length; gi++) {
138
+ const group = payload[gi];
139
+ if (!group || typeof group !== "object")
140
+ return { valid: false, error: `Group ${gi}: invalid object` };
141
+ const { conjunction, rules } = group;
142
+ if (conjunction !== "AND" && conjunction !== "OR")
143
+ return { valid: false, error: `Group ${gi}: conjunction must be "AND" or "OR"` };
144
+ if (!Array.isArray(rules))
145
+ return { valid: false, error: `Group ${gi}: rules must be an array` };
146
+ if (rules.length > MAX_RULES_PER_GROUP)
147
+ return { valid: false, error: `Group ${gi}: too many rules (max ${MAX_RULES_PER_GROUP})` };
148
+ for (let ri = 0; ri < rules.length; ri++) {
149
+ const rule = rules[ri];
150
+ if (!rule || typeof rule !== "object")
151
+ return { valid: false, error: `Group ${gi} rule ${ri}: invalid object` };
152
+ const { field, operator, value } = rule;
153
+ if (typeof field !== "string")
154
+ return { valid: false, error: `Group ${gi} rule ${ri}: field must be a string` };
155
+ const cfKey = customFieldKey(field);
156
+ if (cfKey) {
157
+ if (typeof operator !== "string")
158
+ return { valid: false, error: `Group ${gi} rule ${ri}: operator must be a string` };
159
+ if (!CF_OPERATORS.has(operator))
160
+ return { valid: false, error: `Group ${gi} rule ${ri}: invalid operator "${operator}" for custom field "${field}"` };
161
+ }
162
+ else {
163
+ if (!VALID_FIELDS.has(field))
164
+ return { valid: false, error: `Group ${gi} rule ${ri}: unknown field "${field}"` };
165
+ if (typeof operator !== "string")
166
+ return { valid: false, error: `Group ${gi} rule ${ri}: operator must be a string` };
167
+ const validOps = ADVANCED_OPERATORS[field];
168
+ if (!validOps?.has(operator))
169
+ return { valid: false, error: `Group ${gi} rule ${ri}: invalid operator "${operator}" for field "${field}"` };
170
+ }
171
+ // (Codex cli r3 m1) an array element that is not a string is refused here, as the app now does (the is_any_of branch trims each element)
172
+ if (value !== undefined && typeof value !== "string" && !(Array.isArray(value) && value.every((v) => typeof v === "string")))
173
+ return { valid: false, error: `Group ${gi} rule ${ri}: value must be a string or string[]` };
174
+ if (operator === "is_any_of") {
175
+ const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
176
+ if (values.map((v) => v.trim()).filter(Boolean).length === 0)
177
+ return { valid: false, error: `Group ${gi} rule ${ri}: at least one value is required` };
178
+ }
179
+ else {
180
+ const scalarValue = Array.isArray(value) ? value.join(",") : value;
181
+ const valueError = validateCommonRuleValue(field, normalizeOperator(field, operator), scalarValue, `Group ${gi} rule ${ri}`);
182
+ if (valueError)
183
+ return { valid: false, error: valueError };
184
+ }
185
+ }
186
+ }
187
+ return { valid: true, groups: payload };
188
+ }
189
+ // ─── (Codex mcp/cli r5 h1 → r6 h1) VALID MEANS COMPILES ────────────────────────────────────────────────────────────────────────
190
+ // The app's validateAdvancedFilters runs every rule through its strict compiler and refuses what the compiler would drop. This
191
+ // surface carries the compiler's EXACT null-returning paths as one pure predicate — ./advanced-filter-compilability.ts, ported
192
+ // verbatim from conduyt-app lib/advanced-filter-compilability.ts (slice B, on 0dab73baf), which the app proves equal to its real
193
+ // compiler over an adversarial value matrix for every field, operator and custom-field operator
194
+ // (lib/advanced-filter-compilability.test.ts) — so a rule accepted here is one the API accepts and the compiler keeps.
195
+ import { advancedRuleCompiles } from "./advanced-filter-compilability.js";
196
+ /** validateAdvancedFilters (the port) PLUS the compile verdict per rule: the same answer the API gives a write */
197
+ export function validateAdvancedFiltersEffective(payload) {
198
+ const v = validateAdvancedFilters(payload);
199
+ if (!v.valid)
200
+ return v;
201
+ for (const [gi, group] of v.groups.entries())
202
+ for (const [ri, rule] of group.rules.entries()) {
203
+ if (!advancedRuleCompiles(rule))
204
+ return { valid: false, error: `Group ${gi} rule ${ri}: ${rule.field} ${rule.operator} cannot be applied to this value` };
205
+ }
206
+ return v;
207
+ }
@@ -95,4 +95,18 @@ export function registerDripCampaignTools(server, client) {
95
95
  const result = await client.post("/api/v1/drip-enrollments/batch/stop", params);
96
96
  return formatResult(result);
97
97
  });
98
+ // (Smart Views slice A, Codex r70 m5) the whole enrollment's barrier at once, including a MARKER-ONLY barrier (the
99
+ // processor died between its marker and its log: no webhook log exists to cancel)
100
+ server.tool("conduyt_cancel_drip_enrollment_barrier", "Cancel a drip enrollment's whole send barrier (POST /api/v1/drip-enrollments/{id}/cancel-barrier; admin or owner, the drip-enrollments scope): every parked / queued / failed webhook log of the enrollment is cancelled, including the case where no webhook log exists at all (the processor died after writing its marker). Without a resolution the barrier STANDS (nothing is sent). resolution 'skip' assumes the current step was delivered: the processor advances without sending. resolution 'resend' makes the processor rebuild and send the step AGAIN (a possible duplicate SMS): refused unless acknowledgeDuplicateRisk is true. Answers 404 (no such enrollment), 409 drip_send_in_flight (a request is in flight: retry in a minute) or 409 step_already_sent (the step already carries sent evidence: a resend would only advance it, use 'skip'). The response carries resolution, resendScheduled, advanceScheduled, logsCancelled, markersResolved, handedBack and barrierStanding.", {
101
+ id: z.string().uuid().describe("Drip enrollment UUID"),
102
+ resolution: z.enum(["resend", "skip"]).optional().describe("Omit to cancel only (the barrier stands). 'skip' = assume delivered, advance. 'resend' = send the step again (duplicate risk: needs acknowledgeDuplicateRisk)"),
103
+ acknowledgeDuplicateRisk: z.boolean().optional().describe("Required true with resolution 'resend': you accept the step may be delivered twice"),
104
+ }, async ({ id, resolution, acknowledgeDuplicateRisk }) => {
105
+ if (resolution === "resend" && acknowledgeDuplicateRisk !== true) {
106
+ return formatResult({ error: "resolution 'resend' may deliver the step twice: pass acknowledgeDuplicateRisk: true, or use resolution 'skip' to advance without sending" });
107
+ }
108
+ // the body as ONE literal: the contract gate reads the request fields from it (resolution, confirm)
109
+ const result = await client.post(`/api/v1/drip-enrollments/${id}/cancel-barrier`, { ...(resolution ? { resolution } : {}), ...(resolution === "resend" ? { confirm: "RESEND_UNCONFIRMED_DRIP" } : {}) });
110
+ return formatResult(result);
111
+ });
98
112
  }
@@ -24,4 +24,18 @@ export type TagRef = {
24
24
  /** the same plain-English criteria the Up for Grabs page prints under its header (lib/pool-filter-summary.ts in the app):
25
25
  * a deleted tag (name null) stays visible because its id still filters every lead out; a failed lookup is said so */
26
26
  export declare function poolCriteriaPhrases(filter: PoolFilterShape, tags: TagRef[], tagLookupFailed?: boolean): string[];
27
+ /** (Paul's signed concept 2026-09-25 §2–§3: pool filter sets, slice D parity) the two pool pages */
28
+ export declare const POOL_PAGES: readonly ["up_for_grabs", "lead_pool"];
29
+ export type PoolPage = (typeof POOL_PAGES)[number];
30
+ /** the API's list row (lib/pool-page.ts toPoolFilterSetRow), validated in full: a stored payload that no longer compiles is answered
31
+ * as filters: [] with valid: false (it matches nothing until repaired), so `valid` is safety-critical and every field is checked */
32
+ export type PoolFilterSetRow = {
33
+ id: string;
34
+ name: string;
35
+ filters: unknown[];
36
+ isEnabled: boolean;
37
+ sortOrder: number;
38
+ valid: boolean;
39
+ };
40
+ export declare function isPoolFilterSetRow(r: unknown): r is PoolFilterSetRow;
27
41
  export declare function registerLeadPoolTools(server: McpServer, client: ConduytClient): void;
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { formatResult } from "../client.js";
3
3
  import { patchGeneralSettings } from "./lifecycle.js";
4
+ import { validateAdvancedFiltersEffective } from "./advanced-filter-rules.js";
4
5
  const EMPTY = { enabled: false, tagIds: [], sources: [], minScore: null, maxScore: null, createdWithinDays: null };
5
6
  function intOrNull(v) {
6
7
  return typeof v === "number" && Number.isInteger(v) ? v : null;
@@ -67,7 +68,90 @@ async function tagNamesFor(client, tagIds) {
67
68
  }
68
69
  }
69
70
  const score = z.number().int().min(0).max(1000);
71
+ /** (Paul's signed concept 2026-09-25 §2–§3: pool filter sets, slice D parity) the two pool pages */
72
+ export const POOL_PAGES = ["up_for_grabs", "lead_pool"];
73
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
74
+ export function isPoolFilterSetRow(r) {
75
+ if (!r || typeof r !== "object" || Array.isArray(r))
76
+ return false;
77
+ const x = r;
78
+ if (typeof x.id !== "string" || !UUID_RE.test(x.id))
79
+ return false;
80
+ if (typeof x.name !== "string" || typeof x.isEnabled !== "boolean" || typeof x.valid !== "boolean")
81
+ return false;
82
+ if (typeof x.sortOrder !== "number" || !Number.isInteger(x.sortOrder) || x.sortOrder < 0 || x.sortOrder > 10_000)
83
+ return false;
84
+ if (!Array.isArray(x.filters))
85
+ return false;
86
+ // (Codex mcp r7 m1) a row flagged valid must COMPILE by the same verdict the writes use: a version-skewed or malformed answer is never surfaced as a valid enabled set
87
+ return x.valid ? validateAdvancedFiltersEffective(x.filters).valid : x.filters.length === 0;
88
+ }
89
+ const poolPage = z.enum(POOL_PAGES).describe("Which page: up_for_grabs (claim offers) or lead_pool (pull pool)");
90
+ /** the API's validateAdvancedFilters, ported verbatim (./advanced-filter-rules.ts): the structural shape as a zod schema, then the
91
+ * field / operator / value rules as a refinement — a schema-valid call is never a predictable 400 */
92
+ const filtersSchema = z.array(z.object({
93
+ conjunction: z.enum(["AND", "OR"]),
94
+ rules: z.array(z.object({ field: z.string().min(1), operator: z.string().min(1), value: z.union([z.string(), z.array(z.string())]).optional() })).max(20),
95
+ })).max(10).superRefine((groups, ctx) => {
96
+ const verdict = validateAdvancedFiltersEffective(groups); // (Codex mcp r5 h1) valid means COMPILES, as the API now judges
97
+ if (!verdict.valid)
98
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: verdict.error });
99
+ }).describe("The Contacts Advanced Filters payload, validated exactly like a Smart View's: up to 10 groups, each { conjunction: AND|OR, rules: [...] } with up to 20 rules of { field, operator, value }; fields and operators are the API's (unknown fields, operators a field does not take, a missing value for a value-bearing operator, an empty is_any_of, a bad date or duration are refused here). Tag rules take an ARRAY of tag names (one name per element), e.g. [{\"conjunction\":\"AND\",\"rules\":[{\"field\":\"tags\",\"operator\":\"not_contains\",\"value\":[\"VIP\"]}]}]");
100
+ const setName = z.string().trim().min(1).max(80).describe("The set's name (1-80 characters, unique on its page)");
101
+ /** (Codex mcp r1 h1) the page version is the CALLER's: the one its conduyt_list_pool_filter_sets read answered, so the API's
102
+ * 409 page_version_stale protects the state the agent decided on — never a version this tool would read for it */
103
+ const pageVersion = z.string().min(1).describe("The pageVersion your conduyt_list_pool_filter_sets read answered (list first; a stale version is 409 page_version_stale: re-list, re-read the sets, retry)");
70
104
  export function registerLeadPoolTools(server, client) {
105
+ server.tool("conduyt_list_pool_filter_sets", "The named filter sets behind the gear on Up for Grabs or Lead Pool (admin only): each set's name, Advanced Filters payload, enabled flag and order, plus the page's current pageVersion — every write must name it, so LIST FIRST. The page shows the UNION of the enabled sets; a page with an enabled rule-less set — or no set once the legacy filter is retired — is OPEN to every unassigned lead.", { page: poolPage }, async ({ page }) => {
106
+ // the API's envelope: jsonResponse({ data: sets, pageVersion }) is { data: { data: sets, pageVersion } } on the wire
107
+ const result = (await client.get(`/api/v1/pool-filter-sets?page=${page}`));
108
+ // (Codex mcp r3 m1) FAIL CLOSED on a malformed answer: a missing or non-array set collection, or a set without its essential
109
+ // fields, is never presented as an empty page (an admin would create and enable a replacement against a valid version)
110
+ const rows = result?.data?.data;
111
+ const version = typeof result?.data?.pageVersion === "string" && result.data.pageVersion.trim() ? result.data.pageVersion : "";
112
+ if (!version)
113
+ throw new Error("GET /api/v1/pool-filter-sets answered no pageVersion — the API is older than this tool");
114
+ // (Codex mcp r4 m1) the WHOLE row contract: a row missing or mistyping any field (filters, sortOrder, the valid flag too) fails the call
115
+ if (!Array.isArray(rows) || !rows.every(isPoolFilterSetRow)) {
116
+ throw new Error("GET /api/v1/pool-filter-sets answered a malformed set collection — refusing to report an empty page (the API and this tool disagree on the response shape)");
117
+ }
118
+ return formatResult({ data: { sets: rows, pageVersion: version } });
119
+ });
120
+ server.tool("conduyt_create_pool_filter_set", "Add a named filter set to a pool page (admin only). A new set is a DRAFT (off) unless isEnabled is true. Enabling a set that would OPEN the whole page (no rule) is refused with 409 pool_opens unless acknowledgeOpensPool is true. Every tag name a rule carries must be a live tag (422 tag_missing); a custom field must exist (422 custom_field_missing). pageVersion is the one your list read answered (409 page_version_stale when the page moved since: re-list and retry).", {
121
+ page: poolPage,
122
+ name: setName,
123
+ filters: filtersSchema,
124
+ isEnabled: z.boolean().optional().describe("true to enable at once (default: a draft)"),
125
+ acknowledgeOpensPool: z.boolean().optional().describe("true to confirm a change that opens the whole page to every unassigned lead — for the page state your pageVersion names"),
126
+ pageVersion,
127
+ }, async ({ page, name, filters, isEnabled, acknowledgeOpensPool, pageVersion: version }) => formatResult(await client.post("/api/v1/pool-filter-sets", {
128
+ page, name, filters, pageVersion: version,
129
+ ...(isEnabled !== undefined ? { isEnabled } : {}),
130
+ ...(acknowledgeOpensPool !== undefined ? { acknowledgeOpensPool } : {}),
131
+ })));
132
+ server.tool("conduyt_update_pool_filter_set", "Rename, refilter, enable / disable or reorder a pool filter set (admin only). Pass only what changes. Switching a set on or changing its rules reconciles the open offers the page no longer shows; a change that OPENS the whole page needs acknowledgeOpensPool. pageVersion is the one your list read answered for the set's page (409 page_version_stale when the page moved since: re-list and retry).", {
133
+ id: z.string().uuid().describe("The set's UUID"),
134
+ name: setName.optional(),
135
+ filters: filtersSchema.optional(),
136
+ isEnabled: z.boolean().optional(),
137
+ sortOrder: z.number().int().min(0).max(10_000).optional().describe("The set's position on its page (0-10000)"),
138
+ acknowledgeOpensPool: z.boolean().optional(),
139
+ pageVersion,
140
+ }, async ({ id, pageVersion: version, ...updates }) => {
141
+ if (!Object.entries(updates).some(([k, v]) => k !== "acknowledgeOpensPool" && v !== undefined))
142
+ throw new Error("nothing to update: pass name, filters, isEnabled, sortOrder (and acknowledgeOpensPool when the change opens the page)");
143
+ return formatResult(await client.patch(`/api/v1/pool-filter-sets/${id}`, { pageVersion: version, ...updates }));
144
+ });
145
+ server.tool("conduyt_delete_pool_filter_set", "Remove a pool filter set (admin only). Removing the LAST enabled set once the legacy filter is retired OPENS the page to every unassigned lead: refused with 409 pool_opens unless acknowledgeOpensPool is true. pageVersion is the one your list read answered for the set's page.", {
146
+ id: z.string().uuid().describe("The set's UUID"),
147
+ acknowledgeOpensPool: z.boolean().optional(),
148
+ pageVersion,
149
+ }, async ({ id, acknowledgeOpensPool, pageVersion: version }) => {
150
+ const params = new URLSearchParams({ pageVersion: version });
151
+ if (acknowledgeOpensPool)
152
+ params.set("acknowledgeOpensPool", "true");
153
+ return formatResult(await client.del(`/api/v1/pool-filter-sets/${id}?${params}`));
154
+ });
71
155
  server.tool("conduyt_get_lead_pool_settings", "Read the admin-curated lead pool behind Up for Grabs / Lead Pool (settings.poolFilter + poolClaimDailyLimit). When enabled, an unassigned lead is offered only if it matches EVERY configured criterion: tagIds (any of), sources (any of), minScore (lead score >=), maxScore (lead score <=; min = max keeps one exact score), createdWithinDays (added to the CRM in the past N days: the contact's creation time — import, webhook, form, API or by hand; a re-import into an existing contact does not make it new). An empty criterion is skipped; enabled with nothing configured reads as disabled. Claims are gated by the same rules. tags carries the resolved tag names; criteria is the plain-English line the Up for Grabs page shows. Requires the settings scope.", {}, async () => {
72
156
  const result = (await client.get("/api/v1/settings"));
73
157
  const raw = result?.data ?? {};
@@ -38,4 +38,17 @@ export function registerWebhookTools(server, client) {
38
38
  const result = await client.del(`/api/v1/webhooks/manage/${id}`);
39
39
  return formatResult(result);
40
40
  });
41
+ // (Smart Views slice A, Codex r70 m5) the drip send barrier's operator recovery, first-class for agents
42
+ server.tool("conduyt_cancel_webhook_log", "Cancel ONE parked / queued / failed webhook log of a drip send whose outcome was unconfirmed (POST /api/v1/webhook-logs/{id}/cancel; admin or owner, the webhooks scope). Without a resolution the log is cancelled and the barrier STANDS (nothing is sent, the enrollment stays parked). resolution 'skip' assumes the step was delivered: the processor advances without sending. resolution 'resend' makes the processor rebuild and send the step AGAIN (a possible duplicate SMS): it is refused unless acknowledgeDuplicateRisk is true. Answers 404 (no such log), 409 webhook_log_in_flight (a request is in flight right now: retry in a minute), 409 webhook_log_not_cancellable (already cancelled / succeeded) or 409 step_already_sent (the step already carries sent evidence: a resend would only advance it, use 'skip'). The response carries resolution, resendScheduled, advanceScheduled, handedBack, markersResolved and barrierStanding.", {
43
+ id: z.string().uuid().describe("Webhook log UUID (from the replay answer's replayBlockedIds / replayUnarmableIds, or the merge's 409 drip_send_unconfirmed)"),
44
+ resolution: z.enum(["resend", "skip"]).optional().describe("Omit to cancel only (the barrier stands). 'skip' = assume delivered, advance. 'resend' = send the step again (duplicate risk: needs acknowledgeDuplicateRisk)"),
45
+ acknowledgeDuplicateRisk: z.boolean().optional().describe("Required true with resolution 'resend': you accept the step may be delivered twice"),
46
+ }, async ({ id, resolution, acknowledgeDuplicateRisk }) => {
47
+ if (resolution === "resend" && acknowledgeDuplicateRisk !== true) {
48
+ return formatResult({ error: "resolution 'resend' may deliver the step twice: pass acknowledgeDuplicateRisk: true, or use resolution 'skip' to advance without sending" });
49
+ }
50
+ // the body as ONE literal: the contract gate reads the request fields from it (resolution, confirm)
51
+ const result = await client.post(`/api/v1/webhook-logs/${id}/cancel`, { ...(resolution ? { resolution } : {}), ...(resolution === "resend" ? { confirm: "RESEND_UNCONFIRMED_DRIP" } : {}) });
52
+ return formatResult(result);
53
+ });
41
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt-mcp",
3
- "version": "4.67.0",
3
+ "version": "4.69.0",
4
4
  "description": "MCP server for Conduyt CRM — expose CRM operations as AI-accessible tools",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,11 +1,11 @@
1
1
  {
2
- "generatedAt": "2026-09-25T04:02:01.551Z",
3
- "toolCount": 284,
4
- "totalEndpoints": 1003,
2
+ "generatedAt": "2026-09-27T00:07:38.757Z",
3
+ "toolCount": 290,
4
+ "totalEndpoints": 1011,
5
5
  "counts": {
6
- "covered": 260,
6
+ "covered": 266,
7
7
  "excluded": 295,
8
- "planned": 448,
8
+ "planned": 450,
9
9
  "uncategorized": 0
10
10
  },
11
11
  "matrix": {
@@ -2499,6 +2499,12 @@
2499
2499
  "conduyt_list_drip_enrollments"
2500
2500
  ]
2501
2501
  },
2502
+ "POST /api/v1/drip-enrollments/:id/cancel-barrier": {
2503
+ "status": "covered",
2504
+ "tools": [
2505
+ "conduyt_cancel_drip_enrollment_barrier"
2506
+ ]
2507
+ },
2502
2508
  "GET /api/v1/drip-enrollments/:id/timeline": {
2503
2509
  "status": "planned",
2504
2510
  "reason": "drip enrollment timeline inspection"
@@ -3071,6 +3077,10 @@
3071
3077
  "status": "planned",
3072
3078
  "reason": "notification surfaces"
3073
3079
  },
3080
+ "GET /api/v1/ops/capacity": {
3081
+ "status": "planned",
3082
+ "reason": "operator capacity dashboard (#51, shipped 2026-09) — read-only queue/park telemetry; a typed conduyt_capacity_snapshot tool is agent-relevant for throughput questions"
3083
+ },
3074
3084
  "GET /api/v1/phone-numbers": {
3075
3085
  "status": "planned",
3076
3086
  "reason": "phone number inventory"
@@ -3165,6 +3175,30 @@
3165
3175
  "status": "planned",
3166
3176
  "reason": "playbook management"
3167
3177
  },
3178
+ "GET /api/v1/pool-filter-sets": {
3179
+ "status": "covered",
3180
+ "tools": [
3181
+ "conduyt_list_pool_filter_sets"
3182
+ ]
3183
+ },
3184
+ "POST /api/v1/pool-filter-sets": {
3185
+ "status": "covered",
3186
+ "tools": [
3187
+ "conduyt_create_pool_filter_set"
3188
+ ]
3189
+ },
3190
+ "PATCH /api/v1/pool-filter-sets/:id": {
3191
+ "status": "covered",
3192
+ "tools": [
3193
+ "conduyt_update_pool_filter_set"
3194
+ ]
3195
+ },
3196
+ "DELETE /api/v1/pool-filter-sets/:id": {
3197
+ "status": "covered",
3198
+ "tools": [
3199
+ "conduyt_delete_pool_filter_set"
3200
+ ]
3201
+ },
3168
3202
  "GET /api/v1/products": {
3169
3203
  "status": "covered",
3170
3204
  "tools": [
@@ -4033,6 +4067,10 @@
4033
4067
  "conduyt_move_smart_view"
4034
4068
  ]
4035
4069
  },
4070
+ "GET /api/v1/smart-views/version": {
4071
+ "status": "planned",
4072
+ "reason": "smart view management beyond summaries"
4073
+ },
4036
4074
  "GET /api/v1/sms-providers": {
4037
4075
  "status": "planned",
4038
4076
  "reason": "SMS surfaces beyond send tools"
@@ -4366,6 +4404,12 @@
4366
4404
  "status": "planned",
4367
4405
  "reason": "outbound webhook delivery logs"
4368
4406
  },
4407
+ "POST /api/v1/webhook-logs/:id/cancel": {
4408
+ "status": "covered",
4409
+ "tools": [
4410
+ "conduyt_cancel_webhook_log"
4411
+ ]
4412
+ },
4369
4413
  "GET /api/v1/webhooks": {
4370
4414
  "status": "planned",
4371
4415
  "reason": "outbound webhook subscription management (distinct from the inbound /webhooks/ receivers)"