conduyt-mcp 4.104.0 → 4.105.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 +5 -3
- package/dist/client.d.ts +9 -0
- package/dist/client.js +27 -0
- package/dist/tool-annotations.js +2 -0
- package/dist/tools/activities.js +43 -1
- package/dist/tools/activity-export.d.ts +62 -0
- package/dist/tools/activity-export.js +216 -0
- package/dist/tools/companies.js +13 -1
- package/package.json +1 -1
- package/scripts/coverage-manifest.json +0 -5
- package/scripts/coverage-matrix.json +11 -7
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
|
+
## 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
|
|
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 = {}) {
|
package/dist/tool-annotations.js
CHANGED
|
@@ -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"),
|
|
@@ -112,6 +113,7 @@ export const TOOL_ANNOTATIONS = {
|
|
|
112
113
|
conduyt_update_call_recording_settings: update("Update Call Recording Settings"),
|
|
113
114
|
conduyt_list_companies: ro("List Companies"),
|
|
114
115
|
conduyt_get_company: ro("Get Company"),
|
|
116
|
+
conduyt_list_company_related: ro("List Company Related Records"),
|
|
115
117
|
conduyt_create_company: create("Create Company"),
|
|
116
118
|
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
119
|
conduyt_list_tags: ro("List Tags"),
|
package/dist/tools/activities.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/tools/companies.js
CHANGED
|
@@ -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
|
|
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
|
@@ -480,11 +480,6 @@
|
|
|
480
480
|
"status": "planned",
|
|
481
481
|
"reason": "account readiness/onboarding surfaces"
|
|
482
482
|
},
|
|
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
483
|
{
|
|
489
484
|
"match": "/api/v1/activities",
|
|
490
485
|
"status": "planned",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"generatedAt": "2026-10-30T13:37:28.694Z",
|
|
3
|
-
"toolCount":
|
|
3
|
+
"toolCount": 378,
|
|
4
4
|
"totalEndpoints": 1134,
|
|
5
5
|
"counts": {
|
|
6
|
-
"covered":
|
|
6
|
+
"covered": 331,
|
|
7
7
|
"excluded": 350,
|
|
8
|
-
"planned":
|
|
8
|
+
"planned": 453,
|
|
9
9
|
"uncategorized": 0
|
|
10
10
|
},
|
|
11
11
|
"matrix": {
|
|
@@ -302,8 +302,10 @@
|
|
|
302
302
|
"reason": "activity detail reads beyond the log tools"
|
|
303
303
|
},
|
|
304
304
|
"GET /api/v1/activity/export.csv": {
|
|
305
|
-
"status": "
|
|
306
|
-
"
|
|
305
|
+
"status": "covered",
|
|
306
|
+
"tools": [
|
|
307
|
+
"conduyt_export_activities_csv"
|
|
308
|
+
]
|
|
307
309
|
},
|
|
308
310
|
"GET /api/v1/admin/accounts": {
|
|
309
311
|
"status": "excluded",
|
|
@@ -1494,8 +1496,10 @@
|
|
|
1494
1496
|
"reason": "company subroutes beyond core CRUD"
|
|
1495
1497
|
},
|
|
1496
1498
|
"GET /api/v1/companies/:id/related": {
|
|
1497
|
-
"status": "
|
|
1498
|
-
"
|
|
1499
|
+
"status": "covered",
|
|
1500
|
+
"tools": [
|
|
1501
|
+
"conduyt_list_company_related"
|
|
1502
|
+
]
|
|
1499
1503
|
},
|
|
1500
1504
|
"GET /api/v1/companies/:id/tags": {
|
|
1501
1505
|
"status": "planned",
|