conduyt-mcp 4.104.0 → 4.105.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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
- ## 376 Tools
5
+ ## 378 Tools
6
6
 
7
7
  ### Discovery
8
8
  | Tool | Description |
@@ -73,6 +73,7 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
73
73
  | Tool | Description |
74
74
  |------|-------------|
75
75
  | `conduyt_list_activities` | List recent CRM activities |
76
+ | `conduyt_export_activities_csv` | Export the activities a filter matches (`type`, `search`, `contactId`, `dealId`, `fromDate`, `toDate`) to a CSV FILE at `out_path` (`GET /activity/export.csv`; absolute path; an existing file is refused unless `overwrite: true`). Writes the file on the machine running this server and returns only `{ path, rows, bytes, parts }`, never the CSV. The server streams the export in parts of up to 55 s; the tool follows each part's `continue` trailer by itself (same filters plus the cursor, no repeated header) and commits the file only on `complete`. All or nothing: 413 `export_too_large` (more than 100,000 rows; error, code and hint surfaced), a `too_large` trailer (50 MB), 400 `cursor_invalid`, or a part still cut after 3 attempts leaves no file; a 503 `export_count_timeout` is retried once after its Retry-After. For small reads use `conduyt_list_activities` |
76
77
  | `conduyt_create_activity` | Log a call, meeting, or note |
77
78
 
78
79
  ### AI
@@ -151,7 +152,8 @@ No tool carries call audio. Fetch it with `GET {API base}/api/v1/dialer/call/{id
151
152
  | Tool | Description |
152
153
  |------|-------------|
153
154
  | `conduyt_list_companies` | List and search companies (`count_mode: none` skips the total: `meta.total` null, `meta.has_more` exact) |
154
- | `conduyt_get_company` | Get company with contacts and deals |
155
+ | `conduyt_get_company` | Get company with the first page of its contacts (100), deals (50) and child accounts (25), each with its total and next cursor |
156
+ | `conduyt_list_company_related` | The next pages of a company's contacts, deals or child accounts (`GET /companies/{id}/related`; `company_id`, `kind` contacts / deals / children, `cursor`): answers `{ kind, rows, total, nextCursor }`; pass `nextCursor` back until it is null. A caller who may not read deals gets an empty deals list with total 0 |
155
157
  | `conduyt_create_company` | Create a new company record |
156
158
  | `conduyt_update_company` | Update company details |
157
159
 
@@ -402,7 +404,7 @@ A refused call (HTTP 4xx/5xx) fails the tool with this text, stable across tools
402
404
  Conduyt API <status>: <server message>[ [Retry-After: <N>s]][ <details>]
403
405
  ```
404
406
 
405
- `[Retry-After: Ns]` appears only when the server sent the header (shown, never acted on: a mutation is never retried; the one exception is the finalize POST of the upload convenience tools `conduyt_upload_local_file` and `conduyt_upload_chat_attachment`: on a 409 `upload_in_progress` only, at most 4 total attempts, the Retry-After is honoured (capped at 10 s, 5 s when absent or unreadable); finalize is idempotent on the server for the same upload id, so a repeat cannot double-finalize).
407
+ `[Retry-After: Ns]` appears only when the server sent the header (shown, never acted on: a mutation is never retried; the one exception is the finalize POST of the upload convenience tools `conduyt_upload_local_file` and `conduyt_upload_chat_attachment`: on a 409 `upload_in_progress` only, at most 4 total attempts, the Retry-After is honoured (capped at 10 s, 5 s when absent or unreadable); finalize is idempotent on the server for the same upload id, so a repeat cannot double-finalize; the other is `conduyt_export_activities_csv`, a read: on a 503 `export_count_timeout` it waits the Retry-After (capped at 30 s, 5 s when absent) and asks once more).
406
408
  `<details>` appears only when the body had more than `error`: it is ONE JSON object and the FINAL token. To extract it, try
407
409
  each `{` from the left until the rest of the text parses as JSON (a server message may itself contain a brace). The object
408
410
  carries scalars first (`code`, revision), then nested objects whole; `HealthFixRefusal.row` is always whole, any other nested
package/dist/client.d.ts CHANGED
@@ -76,6 +76,15 @@ export declare class ConduytClient {
76
76
  request(method: string, path: string, body?: unknown, opts?: RequestOptions): Promise<unknown>;
77
77
  requestText(method: string, path: string, body?: unknown, opts?: RequestOptions): Promise<string>;
78
78
  get(path: string, opts?: RequestOptions): Promise<unknown>;
79
+ /**
80
+ * (#624) A GET whose 2xx body is handed back UNREAD as a stream (the activity CSV export, text/csv): sent once, never retried, never
81
+ * buffered and never parsed as JSON. The deadline covers the body as well. A non-2xx is read and thrown through the shared error
82
+ * grammar (status, code, Retry-After and the details object intact).
83
+ */
84
+ getStream(path: string, opts?: {
85
+ timeoutMs?: number;
86
+ accept?: string;
87
+ }): Promise<Response>;
79
88
  /** Multipart upload (call-flow greeting audio): same auth, timeouts and
80
89
  * response bounds as JSON calls; the body is a FormData the caller built. */
81
90
  postMultipart(path: string, form: FormData, opts?: RequestOptions): Promise<unknown>;
package/dist/client.js CHANGED
@@ -391,6 +391,33 @@ export class ConduytClient {
391
391
  get(path, opts) {
392
392
  return this.request("GET", path, undefined, opts);
393
393
  }
394
+ /**
395
+ * (#624) A GET whose 2xx body is handed back UNREAD as a stream (the activity CSV export, text/csv): sent once, never retried, never
396
+ * buffered and never parsed as JSON. The deadline covers the body as well. A non-2xx is read and thrown through the shared error
397
+ * grammar (status, code, Retry-After and the details object intact).
398
+ */
399
+ async getStream(path, opts = {}) {
400
+ const timeoutMs = opts.timeoutMs ?? this.timeoutMs;
401
+ let res;
402
+ try {
403
+ res = await fetch(`${this.baseUrl}${path}`, {
404
+ method: "GET",
405
+ headers: { Authorization: `Bearer ${this.apiKey}`, Accept: opts.accept ?? "text/csv" },
406
+ signal: AbortSignal.timeout(timeoutMs),
407
+ });
408
+ }
409
+ catch (err) {
410
+ throw new ConduytApiError({
411
+ message: isAbort(err) ? `Conduyt API timeout: no response within ${timeoutMs}ms for GET ${path}` : `Conduyt API network error for GET ${path}: ${err?.message ?? String(err)}`,
412
+ code: isAbort(err) ? "timeout" : "network",
413
+ retryable: true,
414
+ cause: err,
415
+ });
416
+ }
417
+ if (!res.ok)
418
+ throw this.httpError(res, (await res.text().catch(() => "")).slice(0, this.maxResponseBytes));
419
+ return res;
420
+ }
394
421
  /** Multipart upload (call-flow greeting audio): same auth, timeouts and
395
422
  * response bounds as JSON calls; the body is a FormData the caller built. */
396
423
  async postMultipart(path, form, opts = {}) {
@@ -10,6 +10,7 @@ import { registerAutomationTools } from "./tools/automations.js";
10
10
  import { registerSequenceTools } from "./tools/sequences.js";
11
11
  import { registerMessagingTools } from "./tools/messaging.js";
12
12
  import { registerMessageCheckSettingsTools } from "./tools/message-check-settings.js";
13
+ import { registerAiLocalFilesTools } from "./tools/ai-local-files.js";
13
14
  import { registerCallTools } from "./tools/calls.js";
14
15
  import { registerDialerSettingsTools, registerCallingHoursTools } from "./tools/dialer-settings.js";
15
16
  import { registerCompanyTools } from "./tools/companies.js";
@@ -71,6 +72,7 @@ export function registerAllTools(server, client) {
71
72
  registerSequenceTools(server, client);
72
73
  registerMessagingTools(server, client);
73
74
  registerMessageCheckSettingsTools(server, client);
75
+ registerAiLocalFilesTools(server, client);
74
76
  registerCallTools(server, client);
75
77
  registerDialerSettingsTools(server, client);
76
78
  registerCallingHoursTools(server, client);
@@ -55,6 +55,7 @@ export const TOOL_ANNOTATIONS = {
55
55
  conduyt_reorder_stages: update("Reorder Stages"),
56
56
  conduyt_delete_stage: destroy("Delete Stage"),
57
57
  conduyt_list_activities: ro("List Activities"),
58
+ conduyt_export_activities_csv: row("Export Activities to CSV File", false, true, false), // writes a LOCAL file, so not read-only; destructive because overwrite: true replaces an existing regular file (MCP: destructiveHint false is additive-only); a repeat writes a new export, so not idempotent
58
59
  conduyt_create_activity: create("Create Activity"),
59
60
  conduyt_ai_insights: create("Query AI Insights"),
60
61
  conduyt_list_automations: ro("List Automations"),
@@ -95,6 +96,8 @@ export const TOOL_ANNOTATIONS = {
95
96
  conduyt_appointment_report: ro("Get Appointment Report"),
96
97
  conduyt_funnel_report: ro("Get Funnel Report"),
97
98
  conduyt_get_message_check_minimum_grade: ro("Get Message Check Minimum Grade"),
99
+ conduyt_get_ai_local_files: ro("Get AI Local Files Settings"),
100
+ conduyt_set_ai_local_files: update("Set AI Local Files Settings"),
98
101
  conduyt_update_message_check_minimum_grade: update("Update Message Check Minimum Grade"),
99
102
  conduyt_list_calls: ro("List Calls"),
100
103
  conduyt_search_dialer_history: ro("Search Dialer History"),
@@ -112,6 +115,7 @@ export const TOOL_ANNOTATIONS = {
112
115
  conduyt_update_call_recording_settings: update("Update Call Recording Settings"),
113
116
  conduyt_list_companies: ro("List Companies"),
114
117
  conduyt_get_company: ro("Get Company"),
118
+ conduyt_list_company_related: ro("List Company Related Records"),
115
119
  conduyt_create_company: create("Create Company"),
116
120
  conduyt_update_company: row("Update Company", false, false, false), // every identical PATCH creates a new company_updated Activity and re-dispatches the update fan-out (companies/[id]/route.ts:362-375)
117
121
  conduyt_list_tags: ro("List Tags"),
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { formatResult } from "../client.js";
2
+ import { formatResult, formatToolError } from "../client.js";
3
+ import { EXPORT_PART_TIMEOUT_MS, runActivityExport } from "./activity-export.js";
3
4
  export function registerActivityTools(server, client) {
4
5
  server.tool("conduyt_list_activities", "List recent activities across the CRM — calls, emails, notes, meetings, deal changes, and system events.", {
5
6
  type: z.string().optional().describe("Filter by activity type (e.g. 'call', 'email', 'note', 'meeting', 'deal_stage_changed')"),
@@ -25,6 +26,47 @@ export function registerActivityTools(server, client) {
25
26
  const result = await client.get(`/api/v1/activities?${params}`);
26
27
  return formatResult(result);
27
28
  });
29
+ server.tool("conduyt_export_activities_csv", "Export the activities a filter matches to a CSV FILE on this machine (GET /activity/export.csv, permission activities.view): the same rows and columns as the Activity page's Export (id, type, description, user, contact, deal, timestamp), newest first. Writes `out_path` (an absolute path; an existing file is refused unless `overwrite: true`) and returns only { path, rows, bytes, parts }: the CSV content is NEVER returned to you (it can be 50 MB). For a small read use conduyt_list_activities instead. The server streams the export in parts of up to 55 s; this tool follows each part's continue trailer by itself (same filters plus the cursor) and commits the file only when the last part says complete: a refusal, a too_large trailer or a part that stays cut leaves no file. Limits: 100,000 rows and 50 MB (413 export_too_large before any row, with maxRows, maxBytes, matchedAtLeast and a hint to narrow the date range or filters). A 503 export_count_timeout is retried once after its Retry-After.", {
30
+ out_path: z.string().describe("Absolute path of the CSV file to write on the machine running this MCP server (a relative path is refused)"),
31
+ overwrite: z.boolean().optional().describe("Replace an existing file at out_path (default false: an existing file is refused before any request)"),
32
+ type: z.string().optional().describe("Filter by activity type, as conduyt_list_activities"),
33
+ search: z.string().optional().describe("Search activity titles/descriptions"),
34
+ contactId: z.string().uuid().optional().describe("Only this contact's activities (UUID)"),
35
+ dealId: z.string().uuid().optional().describe("Only this deal's activities (UUID)"),
36
+ fromDate: z.string().optional().describe("From this day, YYYY-MM-DD in the account's time zone"),
37
+ toDate: z.string().optional().describe("Up to this day, YYYY-MM-DD in the account's time zone"),
38
+ }, async ({ out_path, overwrite, type, search, contactId, dealId, fromDate, toDate }) => {
39
+ const params = new URLSearchParams();
40
+ if (type)
41
+ params.set("type", type);
42
+ if (search)
43
+ params.set("search", search);
44
+ if (contactId)
45
+ params.set("contact_id", contactId);
46
+ if (dealId)
47
+ params.set("deal_id", dealId);
48
+ if (fromDate)
49
+ params.set("fromDate", fromDate);
50
+ if (toDate)
51
+ params.set("toDate", toDate);
52
+ try {
53
+ const result = await runActivityExport({
54
+ outPath: out_path,
55
+ overwrite: overwrite === true,
56
+ // every part carries the SAME filters; a resumed part adds the cursor of the previous part's continue trailer
57
+ fetchPart: (cursor) => {
58
+ const qs = new URLSearchParams(params);
59
+ if (cursor !== null)
60
+ qs.set("cursor", cursor);
61
+ return client.getStream(`/api/v1/activity/export.csv?${qs}`, { timeoutMs: EXPORT_PART_TIMEOUT_MS, accept: "text/csv" });
62
+ },
63
+ });
64
+ return formatResult(result);
65
+ }
66
+ catch (err) {
67
+ return formatToolError(err instanceof Error ? err.message : String(err));
68
+ }
69
+ });
28
70
  server.tool("conduyt_create_activity", "Log a new activity — a call, meeting, note, or custom event. Can be linked to a contact.", {
29
71
  type: z.string().describe("Activity type: 'call', 'email', 'note', 'meeting', or custom"),
30
72
  title: z.string().describe("Activity title/summary"),
@@ -0,0 +1,62 @@
1
+ import { type FileHandle } from "node:fs/promises";
2
+ /**
3
+ * (#624) The part loop of GET /api/v1/activity/export.csv, the same protocol as the app's browser download (lib/activity-export.ts
4
+ * downloadActivityExport): request a part, stream it, read its LAST CSV record as the trailer, and on `continue` request the next part with the
5
+ * SAME filters plus the cursor (a resumed part has no header). All or nothing: the rows go to a temporary file next to out_path, which is
6
+ * committed only on `complete`; any refusal, a too_large trailer, a part that is still cut after 3 attempts, or more bytes than the cap
7
+ * leaves no file. The CSV is never returned and never held whole in memory: only the part's final CSV RECORD is held back until the part ends
8
+ * (it can span physical lines inside quotes; it is bounded by the remaining 50 MB budget plus the trailer room).
9
+ */
10
+ export declare const EXPORT_TRAILER: RegExp;
11
+ /** the app's ceiling (lib/activity-export.ts ACTIVITY_EXPORT_MAX_BYTES; 100,000 rows are counted by the server) */
12
+ export declare const ACTIVITY_EXPORT_MAX_BYTES: number;
13
+ /** the app's part bound (downloadActivityExport maxParts) */
14
+ export declare const EXPORT_MAX_PARTS = 200;
15
+ /** one part runs at most 55 s on the server (maxDuration 60); the client allows 120 s for the whole part, body included */
16
+ export declare const EXPORT_PART_TIMEOUT_MS = 120000;
17
+ /** a 200 part without a trailer (a cut stream) is requested again from the same cursor: this many attempts in all */
18
+ export declare const EXPORT_CUT_ATTEMPTS = 3;
19
+ /** the one 503 export_count_timeout retry waits its Retry-After (5 s from the route), capped here; `sleep` is module state for tests */
20
+ export declare const exportRetry: {
21
+ maxWaitMs: number;
22
+ defaultWaitMs: number;
23
+ sleep: (ms: number) => Promise<unknown>;
24
+ };
25
+ export type ActivityExportResult = {
26
+ path: string;
27
+ rows: number;
28
+ bytes: number;
29
+ parts: number;
30
+ };
31
+ type PartEnd = {
32
+ kind: "complete" | "continue" | "too_large";
33
+ value: string;
34
+ end: number;
35
+ } | {
36
+ cut: true;
37
+ } | {
38
+ over: true;
39
+ };
40
+ /** (r4) a write that made no progress: the export fails (never retried as a cut part, never published) */
41
+ export declare class ExportWriteError extends Error {
42
+ }
43
+ /**
44
+ * Write all `length` bytes of `buf` from `offset` at file position `position`: FileHandle.write may write fewer bytes than asked (a short
45
+ * write), so loop with offset and position advanced by each bytesWritten; an iteration that writes 0 bytes throws ExportWriteError naming
46
+ * the part and the position. Answers the bytes written (always `length` when it returns).
47
+ */
48
+ export declare function writeAll(fh: Pick<FileHandle, "write">, buf: Buffer, offset: number, length: number, position: number, part: number): Promise<number>;
49
+ /**
50
+ * Stream one part's body into the file from `start`; the trailer is never written. RFC 4180 quote state is tracked across chunks (a double
51
+ * quote toggles in-quote, so the escaped "" inside a quoted field toggles twice), and only a newline OUTSIDE quotes ends a record. The
52
+ * trailer must be the whole LAST record, starting at a record boundary and ending the body outside quotes: a marker line inside a quoted
53
+ * multi-line description is field text, never a trailer, and a body that ends inside quotes was cut.
54
+ */
55
+ export declare function readPart(res: Response, fh: Pick<FileHandle, "write">, start: number, room: number, part?: number): Promise<PartEnd>;
56
+ export declare function runActivityExport(o: {
57
+ outPath: string;
58
+ overwrite: boolean;
59
+ fetchPart: (cursor: string | null) => Promise<Response>;
60
+ maxBytes?: number;
61
+ }): Promise<ActivityExportResult>;
62
+ export {};
@@ -0,0 +1,216 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { linkSync, lstatSync, renameSync, unlinkSync } from "node:fs";
3
+ import { open } from "node:fs/promises";
4
+ import { basename, dirname, isAbsolute, join } from "node:path";
5
+ import { ConduytApiError } from "../client.js";
6
+ /**
7
+ * (#624) The part loop of GET /api/v1/activity/export.csv, the same protocol as the app's browser download (lib/activity-export.ts
8
+ * downloadActivityExport): request a part, stream it, read its LAST CSV record as the trailer, and on `continue` request the next part with the
9
+ * SAME filters plus the cursor (a resumed part has no header). All or nothing: the rows go to a temporary file next to out_path, which is
10
+ * committed only on `complete`; any refusal, a too_large trailer, a part that is still cut after 3 attempts, or more bytes than the cap
11
+ * leaves no file. The CSV is never returned and never held whole in memory: only the part's final CSV RECORD is held back until the part ends
12
+ * (it can span physical lines inside quotes; it is bounded by the remaining 50 MB budget plus the trailer room).
13
+ */
14
+ export const EXPORT_TRAILER = /^#conduyt-export,(complete|continue|too_large),([^,"\r\n]*)$/;
15
+ /** the app's ceiling (lib/activity-export.ts ACTIVITY_EXPORT_MAX_BYTES; 100,000 rows are counted by the server) */
16
+ export const ACTIVITY_EXPORT_MAX_BYTES = 50 * 1024 * 1024;
17
+ /** a trailer line's bytes at most (the app's TRAILER_ROOM; a signed cursor is ~200 bytes) */
18
+ const TRAILER_ROOM = 1024;
19
+ /** the app's part bound (downloadActivityExport maxParts) */
20
+ export const EXPORT_MAX_PARTS = 200;
21
+ /** one part runs at most 55 s on the server (maxDuration 60); the client allows 120 s for the whole part, body included */
22
+ export const EXPORT_PART_TIMEOUT_MS = 120_000;
23
+ /** a 200 part without a trailer (a cut stream) is requested again from the same cursor: this many attempts in all */
24
+ export const EXPORT_CUT_ATTEMPTS = 3;
25
+ /** the one 503 export_count_timeout retry waits its Retry-After (5 s from the route), capped here; `sleep` is module state for tests */
26
+ export const exportRetry = { maxWaitMs: 30_000, defaultWaitMs: 5_000, sleep: (ms) => new Promise((r) => setTimeout(r, ms)) };
27
+ const LIMIT_COPY = "An export holds up to 100,000 activities and 50 MB: narrow the date range or filters. No file was made.";
28
+ /** (r4) a write that made no progress: the export fails (never retried as a cut part, never published) */
29
+ export class ExportWriteError extends Error {
30
+ }
31
+ /**
32
+ * Write all `length` bytes of `buf` from `offset` at file position `position`: FileHandle.write may write fewer bytes than asked (a short
33
+ * write), so loop with offset and position advanced by each bytesWritten; an iteration that writes 0 bytes throws ExportWriteError naming
34
+ * the part and the position. Answers the bytes written (always `length` when it returns).
35
+ */
36
+ export async function writeAll(fh, buf, offset, length, position, part) {
37
+ let done = 0;
38
+ while (done < length) {
39
+ const { bytesWritten } = await fh.write(buf, offset + done, length - done, position + done);
40
+ if (!bytesWritten)
41
+ throw new ExportWriteError(`Writing part ${part} of the export made no progress at byte ${position + done} of the temporary file (${length - done} bytes left). No file was made.`);
42
+ done += bytesWritten;
43
+ }
44
+ return done;
45
+ }
46
+ /**
47
+ * Stream one part's body into the file from `start`; the trailer is never written. RFC 4180 quote state is tracked across chunks (a double
48
+ * quote toggles in-quote, so the escaped "" inside a quoted field toggles twice), and only a newline OUTSIDE quotes ends a record. The
49
+ * trailer must be the whole LAST record, starting at a record boundary and ending the body outside quotes: a marker line inside a quoted
50
+ * multi-line description is field text, never a trailer, and a body that ends inside quotes was cut.
51
+ */
52
+ export async function readPart(res, fh, start, room, part = 1) {
53
+ let pos = start;
54
+ let read = 0;
55
+ let pending = Buffer.alloc(0);
56
+ let inQuote = false;
57
+ // record starts inside `pending`: lastB = after the latest unquoted newline, prevB = the one before (0 = pending starts a record)
58
+ let lastB = 0, prevB = 0;
59
+ const take = async (chunk) => {
60
+ const from = pending.length;
61
+ pending = from ? Buffer.concat([pending, chunk]) : chunk;
62
+ for (let i = from; i < pending.length; i++) {
63
+ const c = pending[i];
64
+ if (c === 0x22)
65
+ inQuote = !inQuote;
66
+ else if (c === 0x0a && !inQuote) {
67
+ prevB = lastB;
68
+ lastB = i + 1;
69
+ }
70
+ }
71
+ // hold back the last record: the complete one when the bytes end on a boundary, else the unfinished tail
72
+ const k = lastB === pending.length ? prevB : lastB;
73
+ if (k > 0) {
74
+ pos += await writeAll(fh, pending, 0, k, pos, part);
75
+ pending = pending.subarray(k);
76
+ lastB -= k;
77
+ prevB = Math.max(0, prevB - k);
78
+ }
79
+ };
80
+ const reader = res.body?.getReader();
81
+ try {
82
+ if (!reader) {
83
+ const all = Buffer.from(await res.text(), "utf8");
84
+ if (all.length > room)
85
+ return { over: true };
86
+ await take(all);
87
+ }
88
+ else {
89
+ for (;;) {
90
+ const { done, value } = await reader.read();
91
+ if (done)
92
+ break;
93
+ read += value.byteLength;
94
+ if (read > room) {
95
+ await reader.cancel().catch(() => { });
96
+ return { over: true };
97
+ }
98
+ await take(Buffer.from(value));
99
+ }
100
+ }
101
+ }
102
+ catch (err) {
103
+ if (err instanceof ExportWriteError)
104
+ throw err;
105
+ return { cut: true };
106
+ }
107
+ if (inQuote)
108
+ return { cut: true };
109
+ // `pending` is now exactly the last record (one final newline at most): it is the trailer or the part was cut
110
+ const text = pending.toString("utf8");
111
+ const m = EXPORT_TRAILER.exec(text.endsWith("\n") ? text.slice(0, -1) : text);
112
+ if (!m)
113
+ return { cut: true };
114
+ return { kind: m[1], value: m[2], end: pos };
115
+ }
116
+ const isCountTimeout = (err) => err instanceof ConduytApiError && err.status === 503 && /export_count_timeout/.test(err.message);
117
+ export async function runActivityExport(o) {
118
+ const maxBytes = o.maxBytes ?? ACTIVITY_EXPORT_MAX_BYTES;
119
+ if (!isAbsolute(o.outPath))
120
+ throw new Error(`out_path must be an absolute path (got "${o.outPath}"). Nothing was requested.`);
121
+ // lstat (never follows a link): a symlink (dangling or not) or a directory is refused whatever overwrite says; a regular file needs overwrite
122
+ let existing = null;
123
+ try {
124
+ existing = lstatSync(o.outPath);
125
+ }
126
+ catch (err) {
127
+ if (err.code !== "ENOENT")
128
+ throw err;
129
+ }
130
+ if (existing?.isSymbolicLink())
131
+ throw new Error(`${o.outPath} is a symbolic link; out_path must name a regular file. Nothing was requested.`);
132
+ if (existing?.isDirectory())
133
+ throw new Error(`${o.outPath} is a directory; out_path must name a file. Nothing was requested.`);
134
+ if (existing && !existing.isFile())
135
+ throw new Error(`${o.outPath} is not a regular file. Nothing was requested.`);
136
+ if (existing && !o.overwrite)
137
+ throw new Error(`${o.outPath} already exists; pass overwrite: true to replace it, or another out_path. Nothing was requested.`);
138
+ const tmp = join(dirname(o.outPath), `.${basename(o.outPath)}.${process.pid}.${randomBytes(6).toString("hex")}.part`);
139
+ let fh = await open(tmp, "wx", 0o600);
140
+ const drop = async () => {
141
+ if (fh) {
142
+ await fh.close().catch(() => { });
143
+ fh = null;
144
+ }
145
+ try {
146
+ unlinkSync(tmp);
147
+ }
148
+ catch { /* already gone */ }
149
+ };
150
+ try {
151
+ let cursor = null;
152
+ // every cursor this export has requested: any repeat (A, A or A, B, A) would append pages already written
153
+ const seenCursors = new Set();
154
+ let committed = 0;
155
+ let countRetried = false;
156
+ for (let part = 1; part <= EXPORT_MAX_PARTS; part++) {
157
+ let end = null;
158
+ for (let attempt = 1;;) {
159
+ let res;
160
+ try {
161
+ res = await o.fetchPart(cursor);
162
+ }
163
+ catch (err) {
164
+ // 503 export_count_timeout: wait its Retry-After and ask ONCE more; every other refusal ends the export as the server said it
165
+ if (isCountTimeout(err) && !countRetried) {
166
+ countRetried = true;
167
+ const ra = err.retryAfterSeconds;
168
+ await exportRetry.sleep(Math.min(ra !== null ? ra * 1000 : exportRetry.defaultWaitMs, exportRetry.maxWaitMs));
169
+ continue;
170
+ }
171
+ throw err;
172
+ }
173
+ end = await readPart(res, fh, committed, maxBytes - committed + TRAILER_ROOM, part);
174
+ if (!("cut" in end))
175
+ break;
176
+ // a cut part: drop what it wrote and ask for THAT part again from the same cursor
177
+ await fh.truncate(committed);
178
+ if (++attempt > EXPORT_CUT_ATTEMPTS)
179
+ throw new Error(`Part ${part} of the export was cut before its end ${EXPORT_CUT_ATTEMPTS} times (no trailer). No file was made.`);
180
+ }
181
+ if ("over" in end || ("kind" in end && end.end > maxBytes))
182
+ throw new Error(`The export passed 50 MB. ${LIMIT_COPY}`);
183
+ if (end.kind === "too_large")
184
+ throw new Error(`The export reached its ${end.value === "bytes" ? "50 MB" : "100,000 row"} limit while streaming (too_large,${end.value}). ${LIMIT_COPY}`);
185
+ if (end.kind === "complete") {
186
+ await fh.sync();
187
+ await fh.close();
188
+ fh = null;
189
+ if (o.overwrite)
190
+ renameSync(tmp, o.outPath);
191
+ else {
192
+ try {
193
+ linkSync(tmp, o.outPath);
194
+ }
195
+ catch (err) {
196
+ if (err.code === "EEXIST")
197
+ throw new Error(`${o.outPath} appeared while the export ran; it was not replaced. No file was made.`);
198
+ throw err;
199
+ }
200
+ unlinkSync(tmp);
201
+ }
202
+ return { path: o.outPath, rows: Number(end.value), bytes: end.end, parts: part };
203
+ }
204
+ if (!end.value || seenCursors.has(end.value))
205
+ throw new Error("The export did not advance (the continue trailer repeated a cursor already used). No file was made.");
206
+ seenCursors.add(end.value);
207
+ cursor = end.value;
208
+ committed = end.end;
209
+ }
210
+ throw new Error(`The export did not complete within ${EXPORT_MAX_PARTS} parts. No file was made.`);
211
+ }
212
+ catch (err) {
213
+ await drop();
214
+ throw err;
215
+ }
216
+ }
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ConduytClient } from "../client.js";
3
+ export declare function registerAiLocalFilesTools(server: McpServer, client: ConduytClient): void;
@@ -0,0 +1,29 @@
1
+ import { z } from "zod";
2
+ import { formatResult } from "../client.js";
3
+ import { strictInput } from "./strict.js";
4
+ /**
5
+ * (#655 phase 2) Settings › AI › Local files (GET / PATCH /api/v1/settings/ai-local-files): may AI teammates use their OWNER's local
6
+ * files (read on the owner's computer by the owner's own AI, in folders the owner allows there; Conduyt never reads a disk), and the
7
+ * sensitive-number screen on those posts. Both default on. Changing either is owners and admins only, never an AI teammate's key;
8
+ * switching the screen off needs acknowledgeUnscreened: true and is audited. Refusals are surfaced verbatim, never retried.
9
+ */
10
+ const STATE = "{ data: { enabled, sensitiveScreen, aiTeammatesEnabled, canManage } }";
11
+ export function registerAiLocalFilesTools(server, client) {
12
+ strictInput(server.tool("conduyt_get_ai_local_files", `Read Settings › AI › Local files (GET /api/v1/settings/ai-local-files): ${STATE}. enabled: AI teammates may use their owner's local files (read on the owner's computer, in folders the owner allows there, only when the owner's own message asks; default true). sensitiveScreen: a post from local files is refused 422 sensitive_numbers_in_local_post when it contains a full card, Social Security or bank routing + account number (default true). aiTeammatesEnabled: the workspace's AI teammates switch. canManage: this key may change it. Requires the settings scope and settings.view.`, {}, async () => formatResult(await client.get("/api/v1/settings/ai-local-files"))));
13
+ strictInput(server.tool("conduyt_set_ai_local_files", `Change Settings › AI › Local files (PATCH /api/v1/settings/ai-local-files): pass enabled and/or sensitiveScreen; answers ${STATE}. Owners and admins only (403 otherwise; an AI teammate's own key is always 403 agent_key_refused). Switching sensitiveScreen off needs acknowledgeUnscreened: true (400 local_files_screen_acknowledgement_required otherwise): the team accepts that posts from local files are no longer checked for full card, Social Security or bank account numbers; the acknowledgement is not stored, the change is audited (ai_local_files.changed, who, when, old -> new). Switching local files off needs no acknowledgement. Refusals are surfaced verbatim, never retried.`, {
14
+ enabled: z.boolean().optional().describe("AI teammates may use their owner's local files (true) or not (false)"),
15
+ sensitiveScreen: z.boolean().optional().describe("the sensitive-number screen on posts from local files; false needs acknowledgeUnscreened: true"),
16
+ acknowledgeUnscreened: z.boolean().optional().describe("required true with sensitiveScreen: false"),
17
+ }, async ({ enabled, sensitiveScreen, acknowledgeUnscreened }) => {
18
+ if (enabled === undefined && sensitiveScreen === undefined)
19
+ throw new Error("nothing to change: pass enabled and/or sensitiveScreen");
20
+ const body = {};
21
+ if (enabled !== undefined)
22
+ body.enabled = enabled;
23
+ if (sensitiveScreen !== undefined)
24
+ body.sensitiveScreen = sensitiveScreen;
25
+ if (acknowledgeUnscreened !== undefined)
26
+ body.acknowledgeUnscreened = acknowledgeUnscreened;
27
+ return formatResult(await client.patch("/api/v1/settings/ai-local-files", body));
28
+ }));
29
+ }
@@ -31,12 +31,24 @@ export function registerCompanyTools(server, client) {
31
31
  const result = await client.get(`/api/v1/companies?${params}`);
32
32
  return formatResult(result);
33
33
  });
34
- server.tool("conduyt_get_company", "Get a single company with full details — contacts (up to 100), deals (up to 50), and aggregated deal value.", {
34
+ server.tool("conduyt_get_company", "Get a single company with full details and aggregated deal value. Carries the FIRST page of each related list: contacts (100, total contactCount, contactsNextCursor), deals (50, dealsNextCursor) and child accounts (childCompanies, 25, total childCount, childNextCursor). A non-null next cursor means more rows: read them with conduyt_list_company_related.", {
35
35
  id: z.string().describe("Company UUID"),
36
36
  }, async ({ id }) => {
37
37
  const result = await client.get(`/api/v1/companies/${id}`);
38
38
  return formatResult(result);
39
39
  });
40
+ server.tool("conduyt_list_company_related", "List the NEXT pages of a company's related records (GET /companies/{id}/related, permission companies.view). `conduyt_get_company` already carries the FIRST page of each kind (contacts, deals, children) with its total and next cursor (contactsNextCursor, dealsNextCursor, childNextCursor): pass that cursor here as `cursor` with the same `kind`, then pass each answer's `nextCursor` back until it is null (keyset: strictly after the last row shown, none repeated or skipped). Answers { kind, rows, total, nextCursor }. kind contacts = the company's own and associated contacts, newest first; deals = newest first; children = child accounts oldest first (by creation time, then id). A caller who may not read deals (or a key without the deals scope) gets an empty deals list with total 0. 400 for an unknown kind or a cursor this list did not issue; 404 for an unknown company.", {
41
+ company_id: z.string().uuid().describe("Company UUID"),
42
+ kind: z.enum(["contacts", "deals", "children"]).describe("Which related list: contacts, deals or children (child accounts)"),
43
+ cursor: z.string().optional().describe("The nextCursor of the previous page (from conduyt_get_company or this tool), passed back unchanged; omit it to read the first page"),
44
+ }, async ({ company_id, kind, cursor }) => {
45
+ const params = new URLSearchParams();
46
+ params.set("kind", kind);
47
+ if (cursor !== undefined)
48
+ params.set("cursor", cursor);
49
+ const result = await client.get(`/api/v1/companies/${encodeURIComponent(company_id)}/related?${params}`);
50
+ return formatResult(result);
51
+ });
40
52
  server.tool("conduyt_create_company", "Create a new company record with business details.", {
41
53
  name: z.string().describe("Company name (required)"),
42
54
  domain: z.string().optional().describe("Company domain (e.g. 'acme.com')"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt-mcp",
3
- "version": "4.104.0",
3
+ "version": "4.105.1",
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",
@@ -25,6 +25,11 @@
25
25
  "status": "excluded",
26
26
  "reason": "CRON_SECRET-gated infrastructure jobs; never key-callable"
27
27
  },
28
+ {
29
+ "match": "POST /api/v1/account-exports",
30
+ "status": "planned",
31
+ "reason": "(#618) starts a background Activity export run (owners and admins; the CSV past 100,000 rows); a typed tool is wanted but not built (the #655 phase 2 parity release only refreshed the snapshot to app f3455749d)"
32
+ },
28
33
  {
29
34
  "match": "GET /api/v1/account-exports/:id/files/:entity/download",
30
35
  "status": "excluded",
@@ -480,11 +485,6 @@
480
485
  "status": "planned",
481
486
  "reason": "account readiness/onboarding surfaces"
482
487
  },
483
- {
484
- "match": "GET /api/v1/activity/export.csv",
485
- "status": "planned",
486
- "reason": "activity-log CSV export (small586, landed on app main after the 4.103.0 pin); a CSV download, tool deferred; classified here so the 4.104.0 snapshot refresh passes the coverage gate"
487
- },
488
488
  {
489
489
  "match": "/api/v1/activities",
490
490
  "status": "planned",
@@ -1,11 +1,11 @@
1
1
  {
2
- "generatedAt": "2026-10-30T13:37:28.694Z",
3
- "toolCount": 376,
4
- "totalEndpoints": 1134,
2
+ "generatedAt": "2026-11-05T07:30:55.402Z",
3
+ "toolCount": 380,
4
+ "totalEndpoints": 1143,
5
5
  "counts": {
6
- "covered": 329,
7
- "excluded": 350,
8
- "planned": 455,
6
+ "covered": 333,
7
+ "excluded": 351,
8
+ "planned": 459,
9
9
  "uncategorized": 0
10
10
  },
11
11
  "matrix": {
@@ -265,6 +265,10 @@
265
265
  "conduyt_account_exports_list"
266
266
  ]
267
267
  },
268
+ "POST /api/v1/account-exports": {
269
+ "status": "planned",
270
+ "reason": "(#618) starts a background Activity export run (owners and admins; the CSV past 100,000 rows); a typed tool is wanted but not built (the #655 phase 2 parity release only refreshed the snapshot to app f3455749d)"
271
+ },
268
272
  "GET /api/v1/account-exports/:id/files/:entity/download": {
269
273
  "status": "excluded",
270
274
  "reason": "raw CSV bytes (up to 50k rows) do not belong in a tool result; conduyt_account_exports_list returns the authed downloadPath for API/CLI retrieval"
@@ -302,8 +306,10 @@
302
306
  "reason": "activity detail reads beyond the log tools"
303
307
  },
304
308
  "GET /api/v1/activity/export.csv": {
305
- "status": "planned",
306
- "reason": "activity-log CSV export (small586, landed on app main after the 4.103.0 pin); a CSV download, tool deferred; classified here so the 4.104.0 snapshot refresh passes the coverage gate"
309
+ "status": "covered",
310
+ "tools": [
311
+ "conduyt_export_activities_csv"
312
+ ]
307
313
  },
308
314
  "GET /api/v1/admin/accounts": {
309
315
  "status": "excluded",
@@ -1335,6 +1341,10 @@
1335
1341
  "status": "excluded",
1336
1342
  "reason": "(#599) session-only (assertSessionOnly): a channel's agents-respond mode is an admin setting; every API key is refused"
1337
1343
  },
1344
+ "GET /api/v1/chat/channels/:id/linked-record": {
1345
+ "status": "planned",
1346
+ "reason": "team chat read/post for agents"
1347
+ },
1338
1348
  "POST /api/v1/chat/channels/:id/members": {
1339
1349
  "status": "planned",
1340
1350
  "reason": "team chat read/post for agents"
@@ -1494,8 +1504,10 @@
1494
1504
  "reason": "company subroutes beyond core CRUD"
1495
1505
  },
1496
1506
  "GET /api/v1/companies/:id/related": {
1497
- "status": "planned",
1498
- "reason": "company subroutes beyond core CRUD"
1507
+ "status": "covered",
1508
+ "tools": [
1509
+ "conduyt_list_company_related"
1510
+ ]
1499
1511
  },
1500
1512
  "GET /api/v1/companies/:id/tags": {
1501
1513
  "status": "planned",
@@ -1636,6 +1648,10 @@
1636
1648
  "conduyt_forget_contact"
1637
1649
  ]
1638
1650
  },
1651
+ "GET /api/v1/contacts/:id/phone-reachability": {
1652
+ "status": "planned",
1653
+ "reason": "contact subroutes beyond the core CRUD/search tools"
1654
+ },
1639
1655
  "GET /api/v1/contacts/:id/score": {
1640
1656
  "status": "planned",
1641
1657
  "reason": "contact subroutes beyond the core CRUD/search tools"
@@ -2681,6 +2697,10 @@
2681
2697
  "status": "excluded",
2682
2698
  "reason": "realtime browser dialer (WebRTC tokens, live call control) — needs a live audio session"
2683
2699
  },
2700
+ "GET /api/v1/dialer/status-log/timeline": {
2701
+ "status": "excluded",
2702
+ "reason": "realtime browser dialer (WebRTC tokens, live call control) — needs a live audio session"
2703
+ },
2684
2704
  "POST /api/v1/dialer/token": {
2685
2705
  "status": "excluded",
2686
2706
  "reason": "realtime browser dialer (WebRTC tokens, live call control) — needs a live audio session"
@@ -2881,6 +2901,10 @@
2881
2901
  "conduyt_add_email_opt_out"
2882
2902
  ]
2883
2903
  },
2904
+ "POST /api/v1/email/opt-out-list/check": {
2905
+ "status": "planned",
2906
+ "reason": "bulk email send surfaces"
2907
+ },
2884
2908
  "POST /api/v1/email/opt-out-list/resubscribe": {
2885
2909
  "status": "covered",
2886
2910
  "tools": [
@@ -3165,6 +3189,14 @@
3165
3189
  "status": "planned",
3166
3190
  "reason": "import job monitoring/resubmission (ai-import tool covers creation)"
3167
3191
  },
3192
+ "GET /api/v1/imports/:id/restore": {
3193
+ "status": "planned",
3194
+ "reason": "import job monitoring/resubmission (ai-import tool covers creation)"
3195
+ },
3196
+ "POST /api/v1/imports/:id/restore": {
3197
+ "status": "planned",
3198
+ "reason": "import job monitoring/resubmission (ai-import tool covers creation)"
3199
+ },
3168
3200
  "POST /api/v1/imports/:id/resubmit": {
3169
3201
  "status": "planned",
3170
3202
  "reason": "import job monitoring/resubmission (ai-import tool covers creation)"
@@ -4301,6 +4333,18 @@
4301
4333
  "status": "planned",
4302
4334
  "reason": "account settings read/update surfaces"
4303
4335
  },
4336
+ "GET /api/v1/settings/ai-local-files": {
4337
+ "status": "covered",
4338
+ "tools": [
4339
+ "conduyt_get_ai_local_files"
4340
+ ]
4341
+ },
4342
+ "PATCH /api/v1/settings/ai-local-files": {
4343
+ "status": "covered",
4344
+ "tools": [
4345
+ "conduyt_set_ai_local_files"
4346
+ ]
4347
+ },
4304
4348
  "GET /api/v1/settings/ai-teammates": {
4305
4349
  "status": "excluded",
4306
4350
  "reason": "(#614) session-only (assertSessionOnly): an admin turns AI teammates on or off in Settings > AI (Professional and above, the trial, comp); every API key is refused"