conduyt-mcp 4.102.0 → 4.103.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 +4 -3
- package/dist/tool-annotations.js +1 -0
- package/dist/tools/batch-operations.js +3 -3
- package/dist/tools/bulk.js +3 -3
- package/dist/tools/companies.js +5 -2
- package/dist/tools/contacts.d.ts +3 -1
- package/dist/tools/contacts.js +57 -5
- package/package.json +2 -2
- package/scripts/coverage-matrix.json +19 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-accessible tools. Covers the core CRM surface (contacts, deals, pipelines, custom fields, automations, messaging, and more); use `conduyt_api_catalog` to discover the full set of 600+ REST endpoints.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 373 Tools
|
|
6
6
|
|
|
7
7
|
### Discovery
|
|
8
8
|
| Tool | Description |
|
|
@@ -14,7 +14,8 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
|
|
|
14
14
|
### Contacts
|
|
15
15
|
| Tool | Description |
|
|
16
16
|
|------|-------------|
|
|
17
|
-
| `conduyt_list_contacts` | List and search contacts with filters (search, exact `email`, `tag` names, `tagId` uuids: ANY-of, repeatable up to 20
|
|
17
|
+
| `conduyt_list_contacts` | List and search contacts with filters (search, exact `email`, `tag` names, `tagId` uuids: ANY-of, repeatable up to 20). `meta.total` can be null with `meta.total_deferred` true; `meta.has_more` is always exact |
|
|
18
|
+
| `conduyt_count_contacts` | The exact total of a contacts list filter (`GET /contacts/count`, the filters shared with the list, not the list-only deal-context filters: with those, page with `meta.has_more` instead; read scope): `{ total, age_ms?, total_capped?, pending?, timed_out? }`; `pending` = ask again in a few seconds, `timed_out` = narrow the filter |
|
|
18
19
|
| `conduyt_get_contact` | Get full contact details with timeline; `hasOutboundSms` = the workspace has ever provably texted the contact; `hasOutboundEmail` = the same for email; `duplicateProposals` and `heldIdentifiers` = the open possible-duplicate lines and held identifiers |
|
|
19
20
|
| `conduyt_create_contact` | Create a new contact (a 409 with `existingId`, `rule`, `ruleVersion`, `matchedOn`, `reason` means the identity policy identified the incoming person with that record: confirm the identity (name, company) before updating it, a shared phone or email is not proof, and the server creates (with a passive duplicate proposal) a different person on a shared identifier; 409 `identifier_changed` / `owner_busy` and 503 `IDENTITY_INDEX_NOT_READY` are retryable, nothing written; with `callId`: the lead for a contact-less inbound call, linked in the same transaction; the server derives source "Inbound Call" and the owner) |
|
|
20
21
|
| `conduyt_update_contact` | Update contact fields (incl. `assignedTo`; `keepPreviousOwnerAsCollaborator: true` with a reassignment keeps the previous owner as a read-only collaborator instead of releasing them, 400 without `assignedTo`); optional `expectedRevision` (0..2147483647) refuses a stale save with 409 `contact_changed` (code + current revision in the error) |
|
|
@@ -149,7 +150,7 @@ No tool carries call audio. Fetch it with `GET {API base}/api/v1/dialer/call/{id
|
|
|
149
150
|
### Companies
|
|
150
151
|
| Tool | Description |
|
|
151
152
|
|------|-------------|
|
|
152
|
-
| `conduyt_list_companies` | List and search companies |
|
|
153
|
+
| `conduyt_list_companies` | List and search companies (`count_mode: none` skips the total: `meta.total` null, `meta.has_more` exact) |
|
|
153
154
|
| `conduyt_get_company` | Get company with contacts and deals |
|
|
154
155
|
| `conduyt_create_company` | Create a new company record |
|
|
155
156
|
| `conduyt_update_company` | Update company details |
|
package/dist/tool-annotations.js
CHANGED
|
@@ -11,6 +11,7 @@ export const TOOL_ANNOTATIONS = {
|
|
|
11
11
|
conduyt_search: ro("Search CRM Records"),
|
|
12
12
|
conduyt_mcp_coverage: ro("Get MCP Coverage Matrix"),
|
|
13
13
|
conduyt_list_contacts: ro("List Contacts"),
|
|
14
|
+
conduyt_count_contacts: ro("Count Contacts"),
|
|
14
15
|
conduyt_get_contact: ro("Get Contact"),
|
|
15
16
|
conduyt_create_contact: create("Create Contact"),
|
|
16
17
|
conduyt_update_contact: update("Update Contact"),
|
|
@@ -5,7 +5,7 @@ import { formatResult } from "../client.js";
|
|
|
5
5
|
* matching → Run Batch" action and the Run Batch page. Admins and owners only.
|
|
6
6
|
*/
|
|
7
7
|
export function registerBatchOperationTools(server, client) {
|
|
8
|
-
server.tool("conduyt_create_batch_operation", "Queue a Run Batch: ONE task applied to many contacts, logged with progress and cancel (no undo). Tasks: enroll (automationId of a published workflow), delete (the #73 engine: cleanup runs, contacts with an upcoming appointment are kept), add_tag / remove_tag (params.tagId), assign (params.userId
|
|
8
|
+
server.tool("conduyt_create_batch_operation", "Queue a Run Batch: ONE task applied to many contacts, logged with progress and cancel (no undo). Tasks: enroll (automationId of a published workflow), delete (the #73 engine: cleanup runs, contacts with an upcoming appointment are kept), add_tag / remove_tag (params.tagId), assign (params.userId - one rep) or a round-robin pool (params.userIds, 2–50 reps rotated in order; a rep who is no longer an active member is skipped), set_source (params.source - OVERWRITES the lead source), set_status (params.status - one of the account's lead statuses; a lead with a won deal keeps its status). Pick the contacts ONE way: contactIds (up to 50,000), filterPayload (universal filters), or filter + expectedTotal - the same filter you would give conduyt_list_contacts plus the meta.total it returned (when the list answers meta.total null with meta.total_deferred true and has_more, use the total from conduyt_count_contacts for the same SHARED filter; with a deal-context filter see below); the server re-counts and refuses with 409 expected_total_mismatch when the set moved, then freezes the matching ids (max 50,000). Deal-context filters (deal_context_owners, deal_context_stages, deal_context_statuses) exist only on the list, not on conduyt_count_contacts: for a list that uses one of them do not take a total from the count tool (it would count a broader set); page the list with has_more and tally the rows instead. The run is processed in the background; poll conduyt_get_batch_operation. Requires the admin or owner role and an active subscription.", {
|
|
9
9
|
name: z.string().min(1).max(256).describe("A name for the run (shown on the Run Batch page)"),
|
|
10
10
|
operation: z.enum(["enroll", "delete", "add_tag", "remove_tag", "assign", "set_source", "set_status"]).describe("The task"),
|
|
11
11
|
automationId: z.string().uuid().optional().describe("enroll only: the published workflow"),
|
|
@@ -13,8 +13,8 @@ export function registerBatchOperationTools(server, client) {
|
|
|
13
13
|
comment: z.string().max(2000).optional().describe("Why this run (stored with it)"),
|
|
14
14
|
contactIds: z.array(z.string().uuid()).min(1).max(50_000).optional().describe("Explicit contacts"),
|
|
15
15
|
filterPayload: z.array(z.record(z.string(), z.unknown())).min(1).optional().describe("Universal filter rules (the Smart View filter shape)"),
|
|
16
|
-
filter: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional().describe("The contact list filter parameters
|
|
17
|
-
expectedTotal: z.number().int().positive().optional().describe("With filter: the total the list showed for it (meta.total)"),
|
|
16
|
+
filter: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional().describe("The contact list filter parameters as for conduyt_list_contacts (the filters shared with conduyt_count_contacts; the list-only deal_context_owners / deal_context_stages / deal_context_statuses cannot be counted there: page by has_more and tally rows); at least one non-empty value"),
|
|
17
|
+
expectedTotal: z.number().int().positive().optional().describe("With filter: the total the list showed for it (meta.total), or the total from conduyt_count_contacts when meta.total was null (total_deferred); not for a deal-context filter (list-only, the count tool cannot express it): tally rows by has_more"),
|
|
18
18
|
}, async (params) => {
|
|
19
19
|
const body = { name: params.name, operation: params.operation };
|
|
20
20
|
if (params.automationId)
|
package/dist/tools/bulk.js
CHANGED
|
@@ -118,9 +118,9 @@ export function registerBulkTools(server, client) {
|
|
|
118
118
|
const result = await client.post("/api/v1/bulk/contacts/delete", params);
|
|
119
119
|
return formatResult(result);
|
|
120
120
|
});
|
|
121
|
-
server.tool("conduyt_bulk_delete_contacts_matching", "Soft-delete EVERY contact a list filter matches, across pages (#73, 2026-09-15)
|
|
122
|
-
filter: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional().describe("First call: the contact list filter parameters
|
|
123
|
-
expectedTotal: z.number().int().positive().optional().describe("First call: the total the list showed for this filter (meta.total)"),
|
|
121
|
+
server.tool("conduyt_bulk_delete_contacts_matching", "Soft-delete EVERY contact a list filter matches, across pages (#73, 2026-09-15) - the contacts page's \"Select all N matching\" action. FIRST CALL: pass the same filter you would give conduyt_list_contacts (search, tag, tags, source, reachability, company, company_id, assigned_to, created_from/to, updated_from/to, min_score, import_id, smart_view, smartListId, filters JSON, master_status, exclude_master_status, cf[key]) and expectedTotal = the meta.total that filter returned (when the list answers meta.total null with meta.total_deferred true and has_more, get the exact total from conduyt_count_contacts with the same SHARED filter; with a deal-context filter see below). Deal-context filters (deal_context_owners, deal_context_stages, deal_context_statuses) exist only on the list, not on conduyt_count_contacts: for a list that uses one of them do not take a total from the count tool (it would count a broader set); page the list with has_more and tally the rows instead. The server re-counts and refuses when the set moved (409 expected_total_mismatch), refuses when some matching contacts are shared with you but not yours to delete (409 not_all_editable), then FREEZES the matching ids (max 50,000; 422 above) and deletes only those. Contacts with an upcoming appointment are kept (keptWithAppointments). If done=false the response carries remainingIds AND a signed continuation token: CALL AGAIN with ids = remainingIds and continuation = that token, verbatim (no filter, no expectedTotal) until done=true; busy=true means wait a moment first. The token is bound to you, this account and exactly those ids and expires after 2 hours (403 continuation_*); a continuation without it is refused. Empty filters are refused. Requires contacts:delete and an active subscription.", {
|
|
122
|
+
filter: z.record(z.string(), z.union([z.string(), z.array(z.string())])).optional().describe("First call: the contact list filter parameters as for conduyt_list_contacts (the filters shared with conduyt_count_contacts; the list-only deal_context_owners / deal_context_stages / deal_context_statuses cannot be counted there: page by has_more and tally rows); at least one non-empty value"),
|
|
123
|
+
expectedTotal: z.number().int().positive().optional().describe("First call: the total the list showed for this filter (meta.total), or the total from conduyt_count_contacts when meta.total was null (total_deferred); not for a deal-context filter (list-only, the count tool cannot express it): tally rows by has_more"),
|
|
124
124
|
ids: z.array(z.string().uuid()).min(1).max(50_000).optional().describe("Continuation: the remainingIds from the previous response, in the same order (send INSTEAD of filter/expectedTotal)"),
|
|
125
125
|
continuation: z.string().max(2_000).optional().describe("Continuation: the `continuation` token from the previous response, verbatim (required with ids)"),
|
|
126
126
|
}, async (params) => {
|
package/dist/tools/companies.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatResult } from "../client.js";
|
|
3
3
|
export function registerCompanyTools(server, client) {
|
|
4
|
-
server.tool("conduyt_list_companies", "List and search companies. Returns name, domain, industry, contact count, deal count, and total deal value.", {
|
|
4
|
+
server.tool("conduyt_list_companies", "List and search companies. Returns name, domain, industry, contact count, deal count, and total deal value. `count_mode: \"none\"` skips the total count (a picker that only lists names): meta.total is then null and meta.has_more says whether another page exists.", {
|
|
5
5
|
search: z.string().optional().describe("Search by company name, domain, or phone"),
|
|
6
6
|
industry: z.string().optional().describe("Filter by industry"),
|
|
7
7
|
size: z.enum(["1-10", "11-50", "51-200", "201-500", "501-1000", "1001+"]).optional().describe("Filter by company size"),
|
|
@@ -9,7 +9,8 @@ export function registerCompanyTools(server, client) {
|
|
|
9
9
|
order: z.enum(["asc", "desc"]).optional().describe("Sort order (default: desc)"),
|
|
10
10
|
page: z.number().optional().describe("Page number (default 1)"),
|
|
11
11
|
per_page: z.number().optional().describe("Results per page (default 50, max 200)"),
|
|
12
|
-
|
|
12
|
+
count_mode: z.enum(["exact", "none"]).optional().describe("exact (default) counts every matching company into meta.total. none skips that count: meta.total is then null and meta.has_more says whether another page exists."),
|
|
13
|
+
}, async ({ search, industry, size, sort, order, page, per_page, count_mode }) => {
|
|
13
14
|
const params = new URLSearchParams();
|
|
14
15
|
if (search)
|
|
15
16
|
params.set("search", search);
|
|
@@ -25,6 +26,8 @@ export function registerCompanyTools(server, client) {
|
|
|
25
26
|
params.set("page", String(page));
|
|
26
27
|
if (per_page)
|
|
27
28
|
params.set("per_page", String(per_page));
|
|
29
|
+
if (count_mode)
|
|
30
|
+
params.set("count_mode", count_mode);
|
|
28
31
|
const result = await client.get(`/api/v1/companies?${params}`);
|
|
29
32
|
return formatResult(result);
|
|
30
33
|
});
|
package/dist/tools/contacts.d.ts
CHANGED
|
@@ -21,5 +21,7 @@ export declare const MERGE_PREVIEW_MAX = 50;
|
|
|
21
21
|
export declare const MERGE_PREVIEW_TTL_MS: number;
|
|
22
22
|
export declare const EMAIL_LOOKUP_NOTE = "Exact email lookup: one address, or up to 20 distinct addresses in an array. Each is trimmed and lower-cased and matches the contact's primary email OR any secondary email slot (email_2 and up); it is an exact match, not a substring search (use `search` for partial text). Combines with every other filter (AND) inside your account and visibility. An empty or invalid address, or more than 20 distinct addresses, is a 400. On a workspace whose identifier index is still being built, the lookup answers 422 when the stored spellings of the requested addresses exceed its stored-spelling bind budget (a count of spellings, not of contacts; other filters do not help); ask support to complete the identifier backfill. Send addresses once; duplicates collapse.";
|
|
23
23
|
export declare const TAG_FILTER_NOTE = "Filter by tag NAME: one name, or up to 20 distinct names in an array (the contact carries ANY of them). Names match case-insensitively within your account. Cannot be combined with tagId (400). To filter by tag id use tagId.";
|
|
24
|
-
export declare const TAG_ID_FILTER_NOTE = "Filter by tag ID (uuid): one id, or up to 20 distinct ids in an array (the contact carries ANY of them). A malformed id or more than 20 ids is a 400; an id that is not a tag of your account matches nothing; a merged-away tag id follows its alias to the live tag. Cannot be combined with tag (400).
|
|
24
|
+
export declare const TAG_ID_FILTER_NOTE = "Filter by tag ID (uuid): one id, or up to 20 distinct ids in an array (the contact carries ANY of them). A malformed id or more than 20 ids is a 400; an id that is not a tag of your account matches nothing; a merged-away tag id follows its alias to the live tag. Cannot be combined with tag (400). meta.total counts the filtered set when present, but it can be null with total_deferred; per_page 1 is only an existence probe. Use conduyt_count_contacts with tagId for the exact total.";
|
|
25
|
+
/** (slow566b, app main 9aef66389) the contacts list may not count: how a caller gets the exact total */
|
|
26
|
+
export declare const LIST_TOTAL_NOTE = "`meta.total` can be null: then `meta.total_deferred` is true and the page was not counted (the unfiltered list and a Smart View answer a cached total, `meta.total_age_ms`, null until first counted; a filter without an index of its own is never counted here). `meta.has_more` is always exact, so page on by `has_more`, never by `total`. For the exact total call conduyt_count_contacts (GET /contacts/count), which takes the SHARED filters only: with dealContextOwners, dealContextStages or dealContextStatuses (list-only) the count route cannot answer (dropping them counts a broader set), so page with has_more instead. A search stops counting at 10,000 (`meta.total_capped`).";
|
|
25
27
|
export declare function registerContactTools(server: McpServer, client: ConduytClient): void;
|
package/dist/tools/contacts.js
CHANGED
|
@@ -73,10 +73,13 @@ export const EMAIL_LOOKUP_NOTE = "Exact email lookup: one address, or up to 20 d
|
|
|
73
73
|
/** (API ?tag= / ?tagId=, #506) both are repeatable and capped at 20 distinct values; the API reads every occurrence (ANY-of) and refuses tagId together with tag */
|
|
74
74
|
const TAG_FILTER_MAX = 20;
|
|
75
75
|
export const TAG_FILTER_NOTE = "Filter by tag NAME: one name, or up to 20 distinct names in an array (the contact carries ANY of them). Names match case-insensitively within your account. Cannot be combined with tagId (400). To filter by tag id use tagId.";
|
|
76
|
-
export const TAG_ID_FILTER_NOTE = "Filter by tag ID (uuid): one id, or up to 20 distinct ids in an array (the contact carries ANY of them). A malformed id or more than 20 ids is a 400; an id that is not a tag of your account matches nothing; a merged-away tag id follows its alias to the live tag. Cannot be combined with tag (400).
|
|
76
|
+
export const TAG_ID_FILTER_NOTE = "Filter by tag ID (uuid): one id, or up to 20 distinct ids in an array (the contact carries ANY of them). A malformed id or more than 20 ids is a 400; an id that is not a tag of your account matches nothing; a merged-away tag id follows its alias to the live tag. Cannot be combined with tag (400). meta.total counts the filtered set when present, but it can be null with total_deferred; per_page 1 is only an existence probe. Use conduyt_count_contacts with tagId for the exact total.";
|
|
77
|
+
/** (slow566b, app main 9aef66389) the contacts list may not count: how a caller gets the exact total */
|
|
78
|
+
export const LIST_TOTAL_NOTE = "`meta.total` can be null: then `meta.total_deferred` is true and the page was not counted (the unfiltered list and a Smart View answer a cached total, `meta.total_age_ms`, null until first counted; a filter without an index of its own is never counted here). `meta.has_more` is always exact, so page on by `has_more`, never by `total`. For the exact total call conduyt_count_contacts (GET /contacts/count), which takes the SHARED filters only: with dealContextOwners, dealContextStages or dealContextStatuses (list-only) the count route cannot answer (dropping them counts a broader set), so page with has_more instead. A search stops counting at 10,000 (`meta.total_capped`).";
|
|
77
79
|
export function registerContactTools(server, client) {
|
|
78
80
|
const mergePreviews = createMergePreviewStore();
|
|
79
|
-
|
|
81
|
+
/** the filter parameters conduyt_list_contacts and conduyt_count_contacts share (GET /contacts and GET /contacts/count read the same ones) */
|
|
82
|
+
const contactFilterShape = {
|
|
80
83
|
search: z.string().optional().describe("Search by name, email, or phone number"),
|
|
81
84
|
email: z.union([EMAIL_LOOKUP_ONE, z.array(EMAIL_LOOKUP_ONE).min(1).max(20)]).optional().describe(EMAIL_LOOKUP_NOTE),
|
|
82
85
|
tag: z.union([z.string().trim().min(1), z.array(z.string().trim().min(1)).min(1).max(TAG_FILTER_MAX)]).optional().describe(TAG_FILTER_NOTE),
|
|
@@ -85,9 +88,25 @@ export function registerContactTools(server, client) {
|
|
|
85
88
|
assignedTo: z.string().optional().describe("Filter by assigned user ID"),
|
|
86
89
|
masterStatus: z.array(z.string().trim().min(1).max(40)).max(20).optional().describe("Only contacts whose LEAD (master) status is one of these: 'open', 'won', 'lost', 'abandoned', 'disqualified' or an account custom status. Unknown values are a 400."),
|
|
87
90
|
excludeMasterStatus: z.array(z.string().trim().min(1).max(40)).max(20).optional().describe("Hide contacts whose lead status is one of these — e.g. ['won','lost','abandoned','disqualified'] is the 'Active' view. Naming a status in both lists is a 400."),
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
// the rest of the filters GET /contacts and GET /contacts/count both read (app main 9aef66389); each maps to the query param of the same meaning
|
|
92
|
+
company: z.string().optional().describe("Filter by company name (query param `company`)"),
|
|
93
|
+
companyId: z.string().optional().describe("Filter by company id (query param `company_id`)"),
|
|
94
|
+
createdFrom: z.string().optional().describe("Created on or after this date/time (query param `created_from`)"),
|
|
95
|
+
createdTo: z.string().optional().describe("Created on or before this date/time (query param `created_to`)"),
|
|
96
|
+
updatedFrom: z.string().optional().describe("Updated on or after this date/time (query param `updated_from`)"),
|
|
97
|
+
updatedTo: z.string().optional().describe("Updated on or before this date/time (query param `updated_to`)"),
|
|
98
|
+
importId: z.string().optional().describe("Only contacts from this import job (query param `import_id`)"),
|
|
99
|
+
minScore: z.number().int().optional().describe("Minimum lead score (query param `min_score`)"),
|
|
100
|
+
smartView: z.string().optional().describe("Smart View: a Smart View id, or a built-in view such as needs_follow_up, hot_leads, new_this_week, recently_won, untagged, missing_email, missing_phone (query param `smart_view`)"),
|
|
101
|
+
smartListId: z.string().optional().describe("Smart List id (uuid); a non-uuid is a 400 invalid_smart_list_id, an unknown list a 404 smart_list_not_found (query param `smartListId`)"),
|
|
102
|
+
tags: z.string().optional().describe("Comma-separated tag names (query param `tags`; the same set the Run Batch / bulk delete filters use). Prefer `tag` / `tagId` for new calls"),
|
|
103
|
+
filters: z.string().optional().describe("The filter builder's JSON, as a string (query param `filters`)"),
|
|
104
|
+
reachability: z.string().optional().describe("Reachability filter (query param `reachability`)"),
|
|
105
|
+
cf: z.string().optional().describe("Compact custom-field filters: fieldKey=value pairs (comma-separated) or a JSON object; a value can be exact, __notempty__ or __empty__ (query param `cf`)"),
|
|
106
|
+
customFieldFilters: z.record(z.string(), z.string()).optional().describe("Filter by contact custom fields: { fieldKey: exact value | '__notempty__' | '__empty__' }; each is sent as `cf[fieldKey]`. The key must be a contact custom-field of the account"),
|
|
107
|
+
};
|
|
108
|
+
const contactFilterParams = (f) => {
|
|
109
|
+
const { search, email, tag, tagId, source, assignedTo, masterStatus, excludeMasterStatus } = f;
|
|
91
110
|
const params = new URLSearchParams();
|
|
92
111
|
if (search)
|
|
93
112
|
params.set("search", search);
|
|
@@ -107,6 +126,34 @@ export function registerContactTools(server, client) {
|
|
|
107
126
|
params.set("master_status", masterStatus.join(","));
|
|
108
127
|
if (excludeMasterStatus?.length)
|
|
109
128
|
params.set("exclude_master_status", excludeMasterStatus.join(","));
|
|
129
|
+
const plain = [
|
|
130
|
+
["company", f.company], ["company_id", f.companyId], ["created_from", f.createdFrom], ["created_to", f.createdTo],
|
|
131
|
+
["updated_from", f.updatedFrom], ["updated_to", f.updatedTo], ["import_id", f.importId], ["min_score", f.minScore],
|
|
132
|
+
["smart_view", f.smartView], ["smartListId", f.smartListId], ["tags", f.tags], ["filters", f.filters], ["reachability", f.reachability], ["cf", f.cf],
|
|
133
|
+
];
|
|
134
|
+
for (const [name, value] of plain)
|
|
135
|
+
if (value !== undefined && value !== "")
|
|
136
|
+
params.set(name, String(value));
|
|
137
|
+
for (const [key, value] of Object.entries(f.customFieldFilters ?? {}))
|
|
138
|
+
params.set(`cf[${key}]`, value);
|
|
139
|
+
return params;
|
|
140
|
+
};
|
|
141
|
+
server.tool("conduyt_list_contacts", "List and search contacts with filtering. To find the contact(s) holding a known email address, pass `email` (exact match on the primary or any secondary email); `search` is a substring search. To list the contacts carrying a tag (or any of several tags), pass `tagId` (uuid) or `tag` (name). Returns paginated results with name, email, phone, tags, source, and assignee. Each row carries `revision` (integer save revision; pass it back as expectedRevision on conduyt_update_contact or the collaborator tools). " + LIST_TOTAL_NOTE, {
|
|
142
|
+
...contactFilterShape,
|
|
143
|
+
// list-only filters (GET /contacts reads them; GET /contacts/count does not)
|
|
144
|
+
dealContextOwners: z.string().optional().describe("Deal-context filter: comma-separated owner ids (query param `deal_context_owners`; list only, the count route does not read it)"),
|
|
145
|
+
dealContextStages: z.string().optional().describe("Deal-context filter: comma-separated stage ids (query param `deal_context_stages`; list only)"),
|
|
146
|
+
dealContextStatuses: z.string().optional().describe("Deal-context filter: comma-separated deal statuses (query param `deal_context_statuses`; list only)"),
|
|
147
|
+
page: z.number().optional().describe("Page number (default 1)"),
|
|
148
|
+
per_page: z.number().optional().describe("Results per page (default 25, max 100)"),
|
|
149
|
+
}, async ({ page, per_page, dealContextOwners, dealContextStages, dealContextStatuses, ...filter }) => {
|
|
150
|
+
const params = contactFilterParams(filter);
|
|
151
|
+
if (dealContextOwners)
|
|
152
|
+
params.set("deal_context_owners", dealContextOwners);
|
|
153
|
+
if (dealContextStages)
|
|
154
|
+
params.set("deal_context_stages", dealContextStages);
|
|
155
|
+
if (dealContextStatuses)
|
|
156
|
+
params.set("deal_context_statuses", dealContextStatuses);
|
|
110
157
|
if (page)
|
|
111
158
|
params.set("page", String(page));
|
|
112
159
|
if (per_page)
|
|
@@ -114,6 +161,11 @@ export function registerContactTools(server, client) {
|
|
|
114
161
|
const result = await client.get(`/api/v1/contacts?${params}`);
|
|
115
162
|
return formatResult(result);
|
|
116
163
|
});
|
|
164
|
+
server.tool("conduyt_count_contacts", "Count the contacts a list filter matches (GET /contacts/count): the exact total when conduyt_list_contacts answers `meta.total: null` with `meta.total_deferred: true`. Takes the SHARED subset of conduyt_list_contacts filters (no page, per_page or sort, and NOT dealContextOwners / dealContextStages / dealContextStatuses, which exist only on the list): with a deal-context filter the exact total is not available here (dropping it counts a broader set), so page the list with has_more instead. Answers `{ total, age_ms?, total_capped?, pending?, timed_out? }`: a cached list (the unfiltered list, or a Smart View alone) answers its cached exact count while under a minute old (`age_ms`), else recounts; `total: null` with `pending: true` = another request is recounting and nothing is cached yet, ask again in a few seconds; `total: null` with `timed_out: true` = the count could not finish within 30 s (narrow the filter); a `search` counts up to 10,000 (`total_capped: true` = more match). Use the total as `expectedTotal` for conduyt_bulk_delete_contacts_matching or conduyt_create_batch_operation.", contactFilterShape, async (filter) => {
|
|
165
|
+
const params = contactFilterParams(filter);
|
|
166
|
+
const result = await client.get(`/api/v1/contacts/count?${params}`);
|
|
167
|
+
return formatResult(result);
|
|
168
|
+
});
|
|
117
169
|
server.tool("conduyt_get_contact", "Get a single contact with full details — profile, tags, custom fields, timeline, deals, and activity history. Includes `revision` (integer save revision): pass it as expectedRevision on conduyt_update_contact or the collaborator tools so a newer change is never overwritten. Includes `hasOutboundSms` (boolean): true when this workspace has ever PROVABLY texted the contact (a proven send only: not a draft, a queued row or a pre-send failure); use it to pick the `context` for conduyt_check_message (false = 'first', true = 'conversation'). Also includes `hasOutboundEmail` (boolean): the same for email (true when at least one outbound email to the contact was proven sent, so a genuine \"Re:\" reply is a conversation and not flagged); use it to pick the `context` for an email check. Also includes `duplicateProposals` (the open and not-sure possible-duplicate lines on this record, each naming the other record as far as your key may see it) and `heldIdentifiers` (emails or phones held from this record because another record owns them, value-free), each the FIRST page only, with duplicateProposalsTotal, duplicateProposalsNextCursor, duplicateProposalsMembershipRevision, heldIdentifiersTotal, heldIdentifiersNextCursor and heldIdentifiersMembershipRevision (page on with conduyt_list_duplicate_proposals, passing the next cursors as cursor / held_cursor); list or decide them with conduyt_list_duplicate_proposals / conduyt_decide_duplicate_proposal.", {
|
|
118
170
|
id: z.string().describe("Contact UUID"),
|
|
119
171
|
}, async ({ id }) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "conduyt-mcp",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.103.0",
|
|
4
4
|
"mcpName": "com.conduyt/mcp",
|
|
5
5
|
"description": "MCP server for Conduyt CRM — expose CRM operations as AI-accessible tools",
|
|
6
6
|
"type": "module",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"claude",
|
|
40
40
|
"llm-tools"
|
|
41
41
|
],
|
|
42
|
-
"homepage": "https://conduyt.
|
|
42
|
+
"homepage": "https://conduyt.com",
|
|
43
43
|
"repository": {
|
|
44
44
|
"type": "git",
|
|
45
45
|
"url": "git+https://github.com/ptaramona/conduyt-mcp-server.git"
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-10-
|
|
3
|
-
"toolCount":
|
|
4
|
-
"totalEndpoints":
|
|
2
|
+
"generatedAt": "2026-10-29T23:02:33.248Z",
|
|
3
|
+
"toolCount": 373,
|
|
4
|
+
"totalEndpoints": 1118,
|
|
5
5
|
"counts": {
|
|
6
|
-
"covered":
|
|
7
|
-
"excluded":
|
|
6
|
+
"covered": 327,
|
|
7
|
+
"excluded": 336,
|
|
8
8
|
"planned": 455,
|
|
9
9
|
"uncategorized": 0
|
|
10
10
|
},
|
|
@@ -609,6 +609,14 @@
|
|
|
609
609
|
"status": "excluded",
|
|
610
610
|
"reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
|
|
611
611
|
},
|
|
612
|
+
"GET /api/v1/auth/sso/confirm": {
|
|
613
|
+
"status": "excluded",
|
|
614
|
+
"reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
|
|
615
|
+
},
|
|
616
|
+
"POST /api/v1/auth/sso/confirm": {
|
|
617
|
+
"status": "excluded",
|
|
618
|
+
"reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
|
|
619
|
+
},
|
|
612
620
|
"POST /api/v1/auth/switch-account": {
|
|
613
621
|
"status": "excluded",
|
|
614
622
|
"reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
|
|
@@ -1603,6 +1611,12 @@
|
|
|
1603
1611
|
"conduyt_ai_import_contacts"
|
|
1604
1612
|
]
|
|
1605
1613
|
},
|
|
1614
|
+
"GET /api/v1/contacts/count": {
|
|
1615
|
+
"status": "covered",
|
|
1616
|
+
"tools": [
|
|
1617
|
+
"conduyt_count_contacts"
|
|
1618
|
+
]
|
|
1619
|
+
},
|
|
1606
1620
|
"GET /api/v1/contacts/dnc-status": {
|
|
1607
1621
|
"status": "planned",
|
|
1608
1622
|
"reason": "contact subroutes beyond the core CRUD/search tools"
|