conduyt-mcp 4.100.0 → 4.102.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 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
- ## 299 Tools
5
+ ## 372 Tools
6
6
 
7
7
  ### Discovery
8
8
  | Tool | Description |
@@ -14,7 +14,7 @@ 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 |
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` counts the filtered set) |
18
18
  | `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
19
  | `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
20
  | `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) |
@@ -37,6 +37,7 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
37
37
  | `conduyt_get_calling_hours` / `conduyt_update_calling_hours` | Read the window the dialer enforces (`effectiveCallingWindow`: `source`, `enabled`, `start`/`end` HH:MM, `timezone` or null = contact zone, then number area code, then account) / set `settings.callingWindow` (`enabled`, `start`, `end` always sent, `end` after `start`, `timezone` trimmed and recognised by the API (omit keeps the stored zone, null clears it); `enabled: false` needs `acknowledgeAnyHour: true`, else 400 `calling_hours_acknowledgement_required`) |
38
38
  | `conduyt_list_email_opt_outs` / `conduyt_add_email_opt_out` / `conduyt_resubscribe_email_opt_out` | The email opt-out list (owner / admin key; hash-only rows `{ id, source, reason, createdAt }`): list (`page`, `perPage` 1-100), add an address by hand (`reason` 1-200 required; 201 new row, 200 when an unsubscribe-link or Mailchimp row is promoted to manual, 409 with the existing row when already listed by hand) and take an address off (`{ removed }`) |
39
39
  | `conduyt_get_my_profile` | Read the caller's own identity (`GET /users/me`, same as `/auth/me`): `data.user` incl. `ringtone` (the incoming-ring slug, null = Conduyt Default), `data.account`, role, permissions, `apiKey`, `accounts`. Editing the profile is a signed-in person's action and is not exposed to API keys |
40
+ | `conduyt_create_file_upload_intent` / `conduyt_finalize_file_upload` / `conduyt_create_chat_upload_intent` / `conduyt_finalize_chat_upload` / `conduyt_upload_local_file` / `conduyt_upload_chat_attachment` | Direct-to-storage uploads (#565): intent (`POST /files/upload/intent`, `POST /chat/channels/{id}/upload/intent`; `fileName`, `size`, optional `contentType`) returns a signed PUT URL, the bytes go straight to storage, finalize (`.../finalize`, `{ uploadId }`) settles it. CRM files up to 50 MB (key scope `files`), Team Chat attachments up to 10 MB (key scope `chat`). `conduyt_upload_local_file` / `conduyt_upload_chat_attachment` do all three steps for a local path and refuse an over-cap or empty file before any call (413 wording `File is too large (12 MB). Maximum allowed: 10 MB.`). Finalize refusals, verbatim: 400 `upload_id_required`, 404 `upload_not_found`, 409 `upload_incomplete` (bytes never PUT), 409 `upload_in_progress`, 413 `file_too_large`, 422 `upload_size_mismatch`, 400/415 `file_rejected`, 429, 503 `uploads_unavailable`; finalize allows 120 s. Only a finalize 409 `upload_in_progress` is repeated (at most 4 total attempts) |
40
41
  | `conduyt_create_short_link` | Make (or reuse) the workspace's Conduyt short link for a public link-shortener address the Message check flagged (`POST /short-links`, body `{ url }`; key needs the `messages` scope, member needs communications.create). Not shortenable: our own host, merge fields, `mailto:`/`tel:`/`sms:`, or any address the Message check does not flag as a link shortener (400 `not_shortenable`). Other errors verbatim, never retried: 400 `invalid_body`, 403 `forbidden`, 413 `body_too_large`, 429 `rate_limited` (with Retry-After), 503 `short_link_unavailable` (nothing changed) |
41
42
  | `conduyt_get_deal_card_fields` / `conduyt_set_deal_card_fields` | Read / replace the kanban card fields (1-8, built-ins or `custom:<fieldKey>`) |
42
43
  | `conduyt_get_settings_revision` | The account `settingsRevision` (plus stored stage rules and rot days): pass it as `expectedSettingsRevision` to `conduyt_archive_pipeline` / `conduyt_delete_stage` so a stale one is refused 409 `stale_settings_revision` |
@@ -397,7 +398,7 @@ A refused call (HTTP 4xx/5xx) fails the tool with this text, stable across tools
397
398
  Conduyt API <status>: <server message>[ [Retry-After: <N>s]][ <details>]
398
399
  ```
399
400
 
400
- `[Retry-After: Ns]` appears only when the server sent the header (shown, never acted on: a mutation is never retried).
401
+ `[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).
401
402
  `<details>` appears only when the body had more than `error`: it is ONE JSON object and the FINAL token. To extract it, try
402
403
  each `{` from the left until the rest of the text parses as JSON (a server message may itself contain a brace). The object
403
404
  carries scalars first (`code`, revision), then nested objects whole; `HealthFixRefusal.row` is always whole, any other nested
package/dist/client.d.ts CHANGED
@@ -12,7 +12,7 @@ export declare class ConduytApiError extends Error {
12
12
  readonly code: "http" | "timeout" | "response_too_large" | "network";
13
13
  readonly retryable: boolean;
14
14
  /** The response's Retry-After, in seconds, when it sent one (a busy 503 / a 429): surfaced to the agent, never used
15
- * to replay a mutation. */
15
+ * to replay a mutation. Exception (finalize of the convenience upload tools/commands only): on a 409 upload_in_progress the finalize POST is repeated, at most 4 total attempts, the Retry-After honoured and capped at 10 s, 5 s when absent or unreadable. */
16
16
  readonly retryAfterSeconds: number | null;
17
17
  constructor(opts: {
18
18
  message: string;
package/dist/client.js CHANGED
@@ -11,7 +11,7 @@ export class ConduytApiError extends Error {
11
11
  code;
12
12
  retryable;
13
13
  /** The response's Retry-After, in seconds, when it sent one (a busy 503 / a 429): surfaced to the agent, never used
14
- * to replay a mutation. */
14
+ * to replay a mutation. Exception (finalize of the convenience upload tools/commands only): on a 409 upload_in_progress the finalize POST is repeated, at most 4 total attempts, the Retry-After honoured and capped at 10 s, 5 s when absent or unreadable. */
15
15
  retryAfterSeconds;
16
16
  constructor(opts) {
17
17
  super(opts.message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
@@ -33,6 +33,7 @@ import { registerBatchOperationTools } from "./tools/batch-operations.js";
33
33
  import { registerDncTools } from "./tools/dnc.js";
34
34
  import { registerEmailOptOutTools } from "./tools/email-opt-out.js";
35
35
  import { registerShortLinkTools } from "./tools/short-links.js";
36
+ import { registerDirectUploadTools } from "./tools/direct-uploads.js";
36
37
  import { registerMyProfileTools } from "./tools/my-profile.js";
37
38
  import { registerSavedFilterTools } from "./tools/saved-filters.js";
38
39
  import { registerDripCampaignTools } from "./tools/drip-campaigns.js";
@@ -94,6 +95,7 @@ export function registerAllTools(server, client) {
94
95
  registerDncTools(server, client);
95
96
  registerEmailOptOutTools(server, client);
96
97
  registerShortLinkTools(server, client);
98
+ registerDirectUploadTools(server, client);
97
99
  registerMyProfileTools(server, client);
98
100
  registerSavedFilterTools(server, client);
99
101
  registerDripCampaignTools(server, client);
@@ -222,6 +222,12 @@ export const TOOL_ANNOTATIONS = {
222
222
  conduyt_add_email_opt_out: update("Add Email Opt-Out"), // a repeat is refused 409 (already listed by hand) before any write, so it lands on the same state; adding a block is protective
223
223
  conduyt_resubscribe_email_opt_out: destroy("Resubscribe Email Address"), // consent change: removing the row re-enables every customer-facing email to the address
224
224
  conduyt_get_my_profile: ro("Get My Profile"),
225
+ conduyt_create_file_upload_intent: create("Create File Upload Intent"), // mints a pending upload record and a signed PUT target; each call is a new upload id
226
+ conduyt_finalize_file_upload: update("Finalize File Upload"), // finalizing the same upload again answers the same body
227
+ conduyt_create_chat_upload_intent: create("Create Chat Upload Intent"),
228
+ conduyt_finalize_chat_upload: update("Finalize Chat Upload"), // finalizing again answers the same body
229
+ conduyt_upload_local_file: create("Upload Local File"), // reads a local file and PUTs it to the signed storage URL; each call stores a new file
230
+ conduyt_upload_chat_attachment: create("Upload Chat Attachment"),
225
231
  conduyt_create_short_link: update("Create Short Link"), // one link per account and target: a repeat returns the same link (idempotent), nothing existing is changed
226
232
  conduyt_list_saved_filters: ro("List Saved Filters"),
227
233
  conduyt_create_saved_filter: create("Create Saved Filter"),
@@ -20,4 +20,6 @@ export declare const LOSER_ORDER_NOTE = "losers are merged in the order given: f
20
20
  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
+ 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). The returned total (meta.total) counts the filtered set, so tagId with per_page=1 is a cheap count of the contacts holding a tag.";
23
25
  export declare function registerContactTools(server: McpServer, client: ConduytClient): void;
@@ -70,27 +70,35 @@ function createMergePreviewStore() {
70
70
  /** (API ?email=, app main 318adf38d) exact lookup of one address; the API trims and lower-cases, so a stray space is harmless */
71
71
  const EMAIL_LOOKUP_ONE = z.string().trim().min(1).max(254);
72
72
  export 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.";
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
+ const TAG_FILTER_MAX = 20;
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). The returned total (meta.total) counts the filtered set, so tagId with per_page=1 is a cheap count of the contacts holding a tag.";
73
77
  export function registerContactTools(server, client) {
74
78
  const mergePreviews = createMergePreviewStore();
75
- 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. 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).", {
79
+ 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); meta.total is the count of that filtered set. 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).", {
76
80
  search: z.string().optional().describe("Search by name, email, or phone number"),
77
81
  email: z.union([EMAIL_LOOKUP_ONE, z.array(EMAIL_LOOKUP_ONE).min(1).max(20)]).optional().describe(EMAIL_LOOKUP_NOTE),
78
- tag: z.string().optional().describe("Filter by tag name"),
82
+ 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),
83
+ tagId: z.union([z.string().trim().uuid(), z.array(z.string().trim().uuid()).min(1).max(TAG_FILTER_MAX)]).optional().describe(TAG_ID_FILTER_NOTE),
79
84
  source: z.string().optional().describe("Filter by source (e.g. 'signup', 'import', 'manual')"),
80
85
  assignedTo: z.string().optional().describe("Filter by assigned user ID"),
81
86
  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."),
82
87
  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."),
83
88
  page: z.number().optional().describe("Page number (default 1)"),
84
89
  per_page: z.number().optional().describe("Results per page (default 25, max 100)"),
85
- }, async ({ search, email, tag, source, assignedTo, masterStatus, excludeMasterStatus, page, per_page }) => {
90
+ }, async ({ search, email, tag, tagId, source, assignedTo, masterStatus, excludeMasterStatus, page, per_page }) => {
86
91
  const params = new URLSearchParams();
87
92
  if (search)
88
93
  params.set("search", search);
89
94
  // repeated `email` params, one per address (the API reads every occurrence); an omitted or empty array sends none
90
95
  for (const address of email === undefined ? [] : Array.isArray(email) ? email : [email])
91
96
  params.append("email", address);
92
- if (tag)
93
- params.set("tag", tag);
97
+ // repeated `tag` / `tagId` params, one per value (the API reads every occurrence and refuses the two together)
98
+ for (const name of tag === undefined ? [] : Array.isArray(tag) ? tag : [tag])
99
+ params.append("tag", name);
100
+ for (const id of tagId === undefined ? [] : Array.isArray(tagId) ? tagId : [tagId])
101
+ params.append("tagId", id);
94
102
  if (source)
95
103
  params.set("source", source);
96
104
  if (assignedTo)
@@ -0,0 +1,41 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ConduytClient } from "../client.js";
3
+ /**
4
+ * Direct-to-storage uploads (#565; app routes POST /api/v1/files/upload/intent + /finalize and
5
+ * POST /api/v1/chat/channels/{id}/upload/intent + /finalize). The bytes go straight to the signed storage URL; the API never
6
+ * carries the file. The four routes are exposed one to one, plus ONE convenience tool per surface that runs the whole flow
7
+ * (intent, PUT the bytes of a local file, finalize). Every API call is sent once: a refusal reaches the agent verbatim.
8
+ * The only repeat is finalize on 409 upload_in_progress, which the API documents as retryable (finalizing again answers the
9
+ * same body), and only inside the convenience tools.
10
+ */
11
+ /** the app's caps (lib/file-upload-policy.ts CRM_FILE_MAX_BYTES, lib/chat-upload-policy.ts CHAT_UPLOAD_MAX_BYTES) */
12
+ export declare const FILE_UPLOAD_MAX_BYTES: number;
13
+ export declare const CHAT_UPLOAD_MAX_BYTES: number;
14
+ export declare const tooLargeMessage: (size: number, max: number) => string;
15
+ /** both app finalize routes set maxDuration = 120 (finalize reads the staged object back and rewrites it), above the client's 30 s default */
16
+ export declare const FINALIZE_TIMEOUT_MS = 120000;
17
+ /** finalize retry on 409 upload_in_progress: exactly `attempts` calls; between them the refusal's Retry-After (capped at maxDelayMs)
18
+ * or, when it sent none or an unreadable one, delayMs. `sleep` is module state so tests can spy on the scheduled delay. */
19
+ export declare const finalizeRetry: {
20
+ attempts: number;
21
+ delayMs: number;
22
+ maxDelayMs: number;
23
+ sleep: (ms: number) => Promise<unknown>;
24
+ };
25
+ /** the milliseconds to wait before finalizing again; pure */
26
+ export declare function finalizeWaitMs(err: unknown): number;
27
+ /** Everything the end-to-end tools do after the argument checks; exported for the tests. */
28
+ export declare function runDirectUpload(client: ConduytClient, o: {
29
+ intentPath: string;
30
+ finalizePath: string;
31
+ maxBytes: number;
32
+ filePath: string;
33
+ fileName?: string;
34
+ contentType?: string;
35
+ }): Promise<{
36
+ content: Array<{
37
+ type: "text";
38
+ text: string;
39
+ }>;
40
+ }>;
41
+ export declare function registerDirectUploadTools(server: McpServer, client: ConduytClient): void;
@@ -0,0 +1,113 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import { basename, resolve } from "node:path";
4
+ import { Readable } from "node:stream";
5
+ import { z } from "zod";
6
+ import { formatResult, formatToolError } from "../client.js";
7
+ import { strictInput } from "./strict.js";
8
+ /**
9
+ * Direct-to-storage uploads (#565; app routes POST /api/v1/files/upload/intent + /finalize and
10
+ * POST /api/v1/chat/channels/{id}/upload/intent + /finalize). The bytes go straight to the signed storage URL; the API never
11
+ * carries the file. The four routes are exposed one to one, plus ONE convenience tool per surface that runs the whole flow
12
+ * (intent, PUT the bytes of a local file, finalize). Every API call is sent once: a refusal reaches the agent verbatim.
13
+ * The only repeat is finalize on 409 upload_in_progress, which the API documents as retryable (finalizing again answers the
14
+ * same body), and only inside the convenience tools.
15
+ */
16
+ /** the app's caps (lib/file-upload-policy.ts CRM_FILE_MAX_BYTES, lib/chat-upload-policy.ts CHAT_UPLOAD_MAX_BYTES) */
17
+ export const FILE_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
18
+ export const CHAT_UPLOAD_MAX_BYTES = 10 * 1024 * 1024;
19
+ /** the app's own wording (lib/direct-upload.ts mb()) so a client-side refusal reads the same as the server's 413 */
20
+ const mb = (bytes) => `${Math.round((bytes / 1024 / 1024) * 10) / 10} MB`;
21
+ export const tooLargeMessage = (size, max) => `File is too large (${mb(size)}). Maximum allowed: ${mb(max)}.`;
22
+ /** both app finalize routes set maxDuration = 120 (finalize reads the staged object back and rewrites it), above the client's 30 s default */
23
+ export const FINALIZE_TIMEOUT_MS = 120_000;
24
+ /** finalize retry on 409 upload_in_progress: exactly `attempts` calls; between them the refusal's Retry-After (capped at maxDelayMs)
25
+ * or, when it sent none or an unreadable one, delayMs. `sleep` is module state so tests can spy on the scheduled delay. */
26
+ export const finalizeRetry = {
27
+ attempts: 4,
28
+ delayMs: 5000,
29
+ maxDelayMs: 10_000,
30
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
31
+ };
32
+ /** the milliseconds to wait before finalizing again; pure */
33
+ export function finalizeWaitMs(err) {
34
+ const ra = err?.retryAfterSeconds;
35
+ return typeof ra === "number" && Number.isFinite(ra) && ra >= 0 ? Math.min(ra * 1000, finalizeRetry.maxDelayMs) : finalizeRetry.delayMs;
36
+ }
37
+ const unwrap = (r) => (r && typeof r === "object" && "data" in r ? r.data : r);
38
+ const FILE_SCOPE = "an API key needs the 'files' scope and the member files.create";
39
+ const CHAT_SCOPE = "an API key needs the 'chat' scope and the member communications.create and membership of the channel";
40
+ const INTENT_ERRORS = "Errors, surfaced verbatim and never retried: 400 file_name_required / file_type_not_allowed / file_size_required / file_empty; 413 file_too_large; 429 (rate limited, Retry-After in the error); 503 uploads_unavailable (nothing changed).";
41
+ const FINALIZE_ERRORS = "Errors, surfaced verbatim: 400 upload_id_required; 404 upload_not_found (malformed, unknown, expired or someone else's upload: upload again); 409 upload_incomplete (the bytes were never PUT, or not all of them: upload again; not retried); 409 upload_in_progress (still being saved: finalize again, same body); 413 file_too_large (the stored object is over the cap); 422 upload_size_mismatch (the stored size is not the declared one: upload again); 400/415 file_rejected (the content check refused the bytes; the status and message are the check's); 429 (rate limited, Retry-After in the error); 503 uploads_unavailable (try again shortly). Finalize allows 120 seconds.";
42
+ /** Everything the end-to-end tools do after the argument checks; exported for the tests. */
43
+ export async function runDirectUpload(client, o) {
44
+ const abs = resolve(o.filePath);
45
+ let st;
46
+ try {
47
+ st = await stat(abs);
48
+ }
49
+ catch {
50
+ return formatToolError(`Cannot read '${o.filePath}': no such file. Nothing was uploaded.`);
51
+ }
52
+ if (!st.isFile())
53
+ return formatToolError(`'${o.filePath}' is not a regular file. Nothing was uploaded.`);
54
+ // the size cap is checked BEFORE the intent call, with the server's wording
55
+ if (st.size === 0)
56
+ return formatToolError("The file is empty.");
57
+ if (st.size > o.maxBytes)
58
+ return formatToolError(tooLargeMessage(st.size, o.maxBytes));
59
+ const body = { fileName: o.fileName ?? basename(abs), size: st.size };
60
+ if (o.contentType !== undefined)
61
+ body.contentType = o.contentType;
62
+ const intent = unwrap(await client.post(o.intentPath, body));
63
+ // the bytes go to the signed URL as-is: NO Authorization header (the URL is the credential), exactly the declared length and type
64
+ let put;
65
+ try {
66
+ put = await fetch(intent.uploadUrl, {
67
+ method: intent.method || "PUT",
68
+ headers: { "Content-Type": intent.contentType, "Content-Length": String(st.size) },
69
+ body: Readable.toWeb(createReadStream(abs)),
70
+ duplex: "half",
71
+ signal: AbortSignal.timeout(Math.max(1, intent.expiresIn) * 1000),
72
+ });
73
+ }
74
+ catch (err) {
75
+ return formatToolError(`Storage upload failed before finalize (${err?.message ?? String(err)}). Nothing was saved; call this tool again (the upload id ${intent.uploadId} is abandoned).`);
76
+ }
77
+ if (!put.ok) {
78
+ return formatToolError(`Storage refused the upload (HTTP ${put.status}). Nothing was saved; call this tool again (the upload id ${intent.uploadId} is abandoned).`);
79
+ }
80
+ for (let attempt = 1;; attempt++) {
81
+ try {
82
+ return formatResult(await client.post(o.finalizePath, { uploadId: intent.uploadId }, { timeoutMs: FINALIZE_TIMEOUT_MS }));
83
+ }
84
+ catch (err) {
85
+ const inProgress = err?.status === 409 && /upload_in_progress/.test(err.message);
86
+ if (!inProgress || attempt >= finalizeRetry.attempts)
87
+ throw err;
88
+ await finalizeRetry.sleep(finalizeWaitMs(err));
89
+ }
90
+ }
91
+ }
92
+ export function registerDirectUploadTools(server, client) {
93
+ const fileNameField = z.string().min(1).describe("File name with its extension, e.g. contract.pdf (the extension decides the allowed type)");
94
+ const contentTypeField = z.string().optional().describe("MIME type; omit to let the API infer it from the file name");
95
+ const sizeField = z.number().int().positive().describe("Exact size of the file in bytes");
96
+ const uploadIdField = z.string().uuid().describe("uploadId from the intent call, after its bytes were PUT");
97
+ const channelField = z.string().uuid().describe("Team Chat channel UUID (you must be a member)");
98
+ strictInput(server.tool("conduyt_create_file_upload_intent", `Step 1 of a direct-to-storage CRM file upload, up to 50 MB (POST /api/v1/files/upload/intent; ${FILE_SCOPE}). Answers 201 { uploadId, uploadUrl, method: "PUT", contentType, size, expiresIn, maxBytes }: PUT exactly 'size' bytes to uploadUrl with Content-Type: contentType within expiresIn seconds (no Authorization header), then call conduyt_finalize_file_upload. This tool does not move bytes; to upload a local file in one step use conduyt_upload_local_file. ${INTENT_ERRORS}`, { fileName: fileNameField, contentType: contentTypeField, size: sizeField }, async ({ fileName, contentType, size }) => formatResult(await client.post("/api/v1/files/upload/intent", { fileName, contentType, size }))));
99
+ strictInput(server.tool("conduyt_finalize_file_upload", `Step 3 of a direct-to-storage CRM file upload (POST /api/v1/files/upload/finalize; ${FILE_SCOPE}). The stored object must be present and exactly the declared size; its bytes are judged by content, then settled. Answers 201 { uploadId, filename, size, contentType}, the same body as the multipart upload; the uploadId is then attached with POST /api/v1/files { entityType, entityId, uploadId }. Finalizing the same upload again answers the same body. ${FINALIZE_ERRORS}`, { uploadId: uploadIdField }, async ({ uploadId }) => formatResult(await client.post("/api/v1/files/upload/finalize", { uploadId }, { timeoutMs: FINALIZE_TIMEOUT_MS }))));
100
+ strictInput(server.tool("conduyt_create_chat_upload_intent", `Step 1 of a direct-to-storage Team Chat attachment, up to 10 MB (POST /api/v1/chat/channels/{id}/upload/intent; ${CHAT_SCOPE}). Answers 201 { uploadId, uploadUrl, method: "PUT", contentType, size, expiresIn, maxBytes }: PUT exactly 'size' bytes to uploadUrl with Content-Type: contentType within expiresIn seconds (no Authorization header), then call conduyt_finalize_chat_upload. This tool does not move bytes; to upload a local file in one step use conduyt_upload_chat_attachment. A deal channel is 404 for a key without the deals scope; a non-member gets 403. ${INTENT_ERRORS}`, { channelId: channelField, fileName: fileNameField, contentType: contentTypeField, size: sizeField }, async ({ channelId, fileName, contentType, size }) => formatResult(await client.post(`/api/v1/chat/channels/${channelId}/upload/intent`, { fileName, contentType, size }))));
101
+ strictInput(server.tool("conduyt_finalize_chat_upload", `Step 3 of a direct-to-storage Team Chat attachment (POST /api/v1/chat/channels/{id}/upload/finalize; ${CHAT_SCOPE}). Answers 201 { url, name, size, type }, the same body as the multipart upload: send it in a message's metadata.attachments. A message that claims an attachment that was never finalized is refused 409 attachment_unavailable. Finalizing again answers the same body. ${FINALIZE_ERRORS}`, { channelId: channelField, uploadId: uploadIdField }, async ({ channelId, uploadId }) => formatResult(await client.post(`/api/v1/chat/channels/${channelId}/upload/finalize`, { uploadId }, { timeoutMs: FINALIZE_TIMEOUT_MS }))));
102
+ strictInput(server.tool("conduyt_upload_local_file", `Upload a file from the machine this server runs on, end to end, as a CRM file (up to 50 MB): checks the size here first, then intent (POST /api/v1/files/upload/intent), PUT of the bytes to the signed URL, finalize (POST /api/v1/files/upload/finalize). ${FILE_SCOPE}. Answers { uploadId, filename, size, contentType }; attach it with POST /api/v1/files { entityType, entityId, uploadId }. A file over the cap is refused here with the API's wording ("File is too large (60 MB). Maximum allowed: 50 MB.") before any call is made; an empty file likewise ("The file is empty."). API refusals are surfaced verbatim and not retried; a storage failure leaves nothing saved, call the tool again. Only a 409 upload_in_progress on finalize is repeated (at most 4 total attempts, waiting the Retry-After, capped at 10 seconds, else 5 seconds). Finalize refusals: ${FINALIZE_ERRORS}`, {
103
+ filePath: z.string().min(1).describe("Path of the local file to upload"),
104
+ fileName: z.string().min(1).optional().describe("Name to store it under; default: the file's own name"),
105
+ contentType: contentTypeField,
106
+ }, async ({ filePath, fileName, contentType }) => runDirectUpload(client, { intentPath: "/api/v1/files/upload/intent", finalizePath: "/api/v1/files/upload/finalize", maxBytes: FILE_UPLOAD_MAX_BYTES, filePath, fileName, contentType })));
107
+ strictInput(server.tool("conduyt_upload_chat_attachment", `Upload a file from the machine this server runs on, end to end, as a Team Chat attachment (up to 10 MB): checks the size here first, then intent (POST /api/v1/chat/channels/{id}/upload/intent), PUT of the bytes, finalize (.../upload/finalize). ${CHAT_SCOPE}. Answers { url, name, size, type }: send it in a message's metadata.attachments. A file over the cap is refused here with the API's wording ("File is too large (12 MB). Maximum allowed: 10 MB.") before any call is made; an empty file likewise ("The file is empty."). API refusals are surfaced verbatim and not retried; a storage failure leaves nothing saved, call the tool again. Only a 409 upload_in_progress on finalize is repeated (at most 4 total attempts, waiting the Retry-After, capped at 10 seconds, else 5 seconds). Finalize refusals: ${FINALIZE_ERRORS}`, {
108
+ channelId: channelField,
109
+ filePath: z.string().min(1).describe("Path of the local file to upload"),
110
+ fileName: z.string().min(1).optional().describe("Name to store it under; default: the file's own name"),
111
+ contentType: contentTypeField,
112
+ }, async ({ channelId, filePath, fileName, contentType }) => runDirectUpload(client, { intentPath: `/api/v1/chat/channels/${channelId}/upload/intent`, finalizePath: `/api/v1/chat/channels/${channelId}/upload/finalize`, maxBytes: CHAT_UPLOAD_MAX_BYTES, filePath, fileName, contentType })));
113
+ }
@@ -8,7 +8,7 @@ import { strictInput } from "./strict.js";
8
8
  * Retry-After) reaches the agent as the first answer and is never replayed here.
9
9
  */
10
10
  export function registerShortLinkTools(server, client) {
11
- strictInput(server.tool("conduyt_create_short_link", "Make (or reuse) this workspace's Conduyt short link for a public link-shortener address that the Message check flagged (POST /api/v1/short-links; an API key needs the 'messages' scope, an sms key is accepted, and the member needs communications.create): put the returned short link in the message in place of the flagged address. The short link redirects to the address exactly as given; there is one link per workspace and address, so a repeat returns the same link. Shortenable: a web link the link check would fetch and the Message check flagged as a public link shortener. NOT shortenable (400 not_shortenable, before any work): an address on our own host (unsubscribe, one-time booking and file links, click tracking), an address with a merge field, mailto:, tel: or sms: addresses, one of our own short links, or any address the Message check does not flag as a link shortener. Answers 200 { data: { url: the short link, target: the address given } }. Errors, surfaced verbatim and never retried: 400 invalid_body (url missing, not a string or an unknown key) / not_shortenable; 403 forbidden; 413 body_too_large; 429 rate_limited (30 per minute per workspace and member; the Retry-After seconds are in the error); 503 short_link_unavailable (the link could not be made; nothing changed, try again later).", {
12
- url: z.string().min(1).describe("The flagged public link-shortener address to replace, e.g. https://bit.ly/abc123 (sent exactly as given; the redirect goes to this address)"),
11
+ strictInput(server.tool("conduyt_create_short_link", "Make (or reuse) this workspace's Conduyt short link for a public link-shortener address that the Message check flagged (POST /api/v1/short-links; an API key needs the 'messages' scope, an sms key is accepted, and the member needs communications.create): put the returned short link in the message in place of the flagged address. The short link redirects to the address exactly as given; there is one link per workspace and address, so a repeat returns the same link. Shortenable: a web link of at most 2,048 characters that the link check would fetch and the Message check flagged as a public link shortener. NOT shortenable (400 not_shortenable, before any work): an address on our own host (unsubscribe, one-time booking and file links, click tracking), an address with a merge field, mailto:, tel: or sms: addresses, one of our own short links, or any address the Message check does not flag as a link shortener. Answers 200 { data: { url: the short link, target: the address given } }. Errors, surfaced verbatim and never retried: 400 invalid_body (url missing, not a string or an unknown key) / not_shortenable; 403 forbidden; 413 body_too_large; 429 rate_limited (30 per minute per workspace and member; the Retry-After seconds are in the error); 503 short_link_unavailable (the link could not be made; nothing changed, try again later).", {
12
+ url: z.string().describe("The flagged public link-shortener address to replace, e.g. https://bit.ly/abc123 (at most 2,048 characters; sent exactly as given, an empty or over-long value reaches the route and is its 400; the redirect goes to this address)"),
13
13
  }, async ({ url }) => formatResult(await client.post("/api/v1/short-links", { url }))));
14
14
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt-mcp",
3
- "version": "4.100.0",
3
+ "version": "4.102.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",
@@ -123,7 +123,7 @@
123
123
  {
124
124
  "match": "/api/v1/files",
125
125
  "status": "planned",
126
- "reason": "file listing/metadata is agent-useful; upload stays browser-side"
126
+ "reason": "file listing/metadata is agent-useful; the direct-upload routes (POST /files/upload/intent and /finalize) are covered, the multipart upload stays browser-side"
127
127
  },
128
128
  {
129
129
  "match": "GET /api/v1/chat/messages/",
@@ -1,11 +1,11 @@
1
1
  {
2
- "generatedAt": "2026-10-25T09:09:54.777Z",
3
- "toolCount": 366,
4
- "totalEndpoints": 1107,
2
+ "generatedAt": "2026-10-28T11:47:07.866Z",
3
+ "toolCount": 372,
4
+ "totalEndpoints": 1115,
5
5
  "counts": {
6
- "covered": 322,
7
- "excluded": 331,
8
- "planned": 454,
6
+ "covered": 326,
7
+ "excluded": 334,
8
+ "planned": 455,
9
9
  "uncategorized": 0
10
10
  },
11
11
  "matrix": {
@@ -269,6 +269,10 @@
269
269
  "status": "excluded",
270
270
  "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"
271
271
  },
272
+ "POST /api/v1/account/company-name": {
273
+ "status": "planned",
274
+ "reason": "account readiness/onboarding surfaces"
275
+ },
272
276
  "GET /api/v1/account/readiness": {
273
277
  "status": "planned",
274
278
  "reason": "account readiness/onboarding surfaces"
@@ -501,6 +505,10 @@
501
505
  "status": "excluded",
502
506
  "reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
503
507
  },
508
+ "POST /api/v1/auth/google": {
509
+ "status": "excluded",
510
+ "reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
511
+ },
504
512
  "GET /api/v1/auth/google/callback": {
505
513
  "status": "excluded",
506
514
  "reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
@@ -545,6 +553,10 @@
545
553
  "status": "excluded",
546
554
  "reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
547
555
  },
556
+ "POST /api/v1/auth/microsoft": {
557
+ "status": "excluded",
558
+ "reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
559
+ },
548
560
  "GET /api/v1/auth/microsoft/callback": {
549
561
  "status": "excluded",
550
562
  "reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
@@ -593,6 +605,10 @@
593
605
  "status": "excluded",
594
606
  "reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
595
607
  },
608
+ "POST /api/v1/auth/signup-events": {
609
+ "status": "excluded",
610
+ "reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
611
+ },
596
612
  "POST /api/v1/auth/switch-account": {
597
613
  "status": "excluded",
598
614
  "reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
@@ -1346,6 +1362,18 @@
1346
1362
  "status": "planned",
1347
1363
  "reason": "team chat read/post for agents"
1348
1364
  },
1365
+ "POST /api/v1/chat/channels/:id/upload/finalize": {
1366
+ "status": "covered",
1367
+ "tools": [
1368
+ "conduyt_finalize_chat_upload"
1369
+ ]
1370
+ },
1371
+ "POST /api/v1/chat/channels/:id/upload/intent": {
1372
+ "status": "covered",
1373
+ "tools": [
1374
+ "conduyt_create_chat_upload_intent"
1375
+ ]
1376
+ },
1349
1377
  "GET /api/v1/chat/floor": {
1350
1378
  "status": "planned",
1351
1379
  "reason": "team chat read/post for agents"
@@ -2914,23 +2942,35 @@
2914
2942
  },
2915
2943
  "GET /api/v1/files": {
2916
2944
  "status": "planned",
2917
- "reason": "file listing/metadata is agent-useful; upload stays browser-side"
2945
+ "reason": "file listing/metadata is agent-useful; the direct-upload routes (POST /files/upload/intent and /finalize) are covered, the multipart upload stays browser-side"
2918
2946
  },
2919
2947
  "POST /api/v1/files": {
2920
2948
  "status": "planned",
2921
- "reason": "file listing/metadata is agent-useful; upload stays browser-side"
2949
+ "reason": "file listing/metadata is agent-useful; the direct-upload routes (POST /files/upload/intent and /finalize) are covered, the multipart upload stays browser-side"
2922
2950
  },
2923
2951
  "DELETE /api/v1/files": {
2924
2952
  "status": "planned",
2925
- "reason": "file listing/metadata is agent-useful; upload stays browser-side"
2953
+ "reason": "file listing/metadata is agent-useful; the direct-upload routes (POST /files/upload/intent and /finalize) are covered, the multipart upload stays browser-side"
2926
2954
  },
2927
2955
  "GET /api/v1/files/:id/download": {
2928
2956
  "status": "planned",
2929
- "reason": "file listing/metadata is agent-useful; upload stays browser-side"
2957
+ "reason": "file listing/metadata is agent-useful; the direct-upload routes (POST /files/upload/intent and /finalize) are covered, the multipart upload stays browser-side"
2930
2958
  },
2931
2959
  "POST /api/v1/files/upload": {
2932
2960
  "status": "planned",
2933
- "reason": "file listing/metadata is agent-useful; upload stays browser-side"
2961
+ "reason": "file listing/metadata is agent-useful; the direct-upload routes (POST /files/upload/intent and /finalize) are covered, the multipart upload stays browser-side"
2962
+ },
2963
+ "POST /api/v1/files/upload/finalize": {
2964
+ "status": "covered",
2965
+ "tools": [
2966
+ "conduyt_finalize_file_upload"
2967
+ ]
2968
+ },
2969
+ "POST /api/v1/files/upload/intent": {
2970
+ "status": "covered",
2971
+ "tools": [
2972
+ "conduyt_create_file_upload_intent"
2973
+ ]
2934
2974
  },
2935
2975
  "GET /api/v1/generated-documents": {
2936
2976
  "status": "planned",