conduyt-mcp 4.99.0 → 4.101.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
@@ -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) |
@@ -36,6 +36,7 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
36
36
  | `conduyt_get_stage_rules` / `conduyt_set_stage_rules` | Read / set one stage's rule (`requiredFields` incl. `custom:<fieldKey>`, `requiredDocuments` 0-20, lost-reason label and options, `lostReasonAllowOther`, `entryByAutomationOnly`: only an automation can move or create a deal into the stage, every other entry is refused 422 `stage_automation_only`; `conduyt_list_pipelines` shows it on each stage; `closesOtherOpenDeals` (+ `closesOtherOpenDealsReason`, 1-200 characters): a deal entering the stage marks the contact's other open deals lost; `assignsContactOwner`: the entering deal's owner becomes the contact owner, the previous contact owner stays a collaborator); other stages untouched; revision-aware, retried once on a 409 |
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
+ | `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 |
39
40
  | `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) |
40
41
  | `conduyt_get_deal_card_fields` / `conduyt_set_deal_card_fields` | Read / replace the kanban card fields (1-8, built-ins or `custom:<fieldKey>`) |
41
42
  | `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` |
@@ -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 { registerMyProfileTools } from "./tools/my-profile.js";
36
37
  import { registerSavedFilterTools } from "./tools/saved-filters.js";
37
38
  import { registerDripCampaignTools } from "./tools/drip-campaigns.js";
38
39
  import { registerScoringTools } from "./tools/scoring.js";
@@ -93,6 +94,7 @@ export function registerAllTools(server, client) {
93
94
  registerDncTools(server, client);
94
95
  registerEmailOptOutTools(server, client);
95
96
  registerShortLinkTools(server, client);
97
+ registerMyProfileTools(server, client);
96
98
  registerSavedFilterTools(server, client);
97
99
  registerDripCampaignTools(server, client);
98
100
  registerScoringTools(server, client);
@@ -221,6 +221,7 @@ export const TOOL_ANNOTATIONS = {
221
221
  conduyt_list_email_opt_outs: ro("List Email Opt-Outs"),
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
+ conduyt_get_my_profile: ro("Get My Profile"),
224
225
  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
225
226
  conduyt_list_saved_filters: ro("List Saved Filters"),
226
227
  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,8 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { ConduytClient } from "../client.js";
3
+ /**
4
+ * The caller's own profile read (app route GET /api/v1/users/me, the same handler as GET /auth/me). data.user carries the
5
+ * incoming-ring slug (#385) as `ringtone`. PATCH /users/me is deliberately not a tool: it is a signed-in person's action
6
+ * (profile, ringtone, password) and the API refuses an API key, so a tool for it would only ever return 403.
7
+ */
8
+ export declare function registerMyProfileTools(server: McpServer, client: ConduytClient): void;
@@ -0,0 +1,10 @@
1
+ import { formatResult } from "../client.js";
2
+ import { strictInput } from "./strict.js";
3
+ /**
4
+ * The caller's own profile read (app route GET /api/v1/users/me, the same handler as GET /auth/me). data.user carries the
5
+ * incoming-ring slug (#385) as `ringtone`. PATCH /users/me is deliberately not a tool: it is a signed-in person's action
6
+ * (profile, ringtone, password) and the API refuses an API key, so a tool for it would only ever return 403.
7
+ */
8
+ export function registerMyProfileTools(server, client) {
9
+ strictInput(server.tool("conduyt_get_my_profile", "Read the caller's own identity (GET /api/v1/users/me, identical to GET /auth/me): data.user (id, email, firstName, lastName, avatarUrl, phone, birthdayMonth, birthdayDay, tourSeenAt, and ringtone: the slug of the incoming ring this person hears, or null = Conduyt Default; the slug is one of classic, soft-bell, marimba, digital-pulse, rising-chime, office-trill, double-ring, pocket-melody, retro-cell, kalimba, uplift), data.account (the current account, name at data.account.name), role, permissions, leadVisibilityMode, apiKey (the key's own metadata when called with an API key, else null) and accounts. Any authenticated caller, including a scoped key, may read it. Editing the profile (name, phone, birthday, ringtone, password) is a signed-in person's action in the app and is not available to an API key.", {}, async () => formatResult(await client.get("/api/v1/users/me"))));
10
+ }
@@ -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.99.0",
3
+ "version": "4.101.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",
@@ -200,6 +200,11 @@
200
200
  "status": "planned",
201
201
  "reason": "company subroutes beyond core CRUD"
202
202
  },
203
+ {
204
+ "match": "PATCH /api/v1/users/me",
205
+ "status": "excluded",
206
+ "reason": "a signed-in person's own profile edit (name, phone, birthday, ringtone, password): session only (assertSessionOnly), never an API key's; GET /users/me is covered by conduyt_get_my_profile"
207
+ },
203
208
  {
204
209
  "match": "/api/v1/users",
205
210
  "status": "planned",
@@ -1,11 +1,11 @@
1
1
  {
2
- "generatedAt": "2026-10-25T02:48:35.925Z",
3
- "toolCount": 365,
4
- "totalEndpoints": 1107,
2
+ "generatedAt": "2026-10-27T01:28:17.262Z",
3
+ "toolCount": 366,
4
+ "totalEndpoints": 1108,
5
5
  "counts": {
6
- "covered": 321,
7
- "excluded": 330,
8
- "planned": 456,
6
+ "covered": 322,
7
+ "excluded": 332,
8
+ "planned": 454,
9
9
  "uncategorized": 0
10
10
  },
11
11
  "matrix": {
@@ -593,6 +593,10 @@
593
593
  "status": "excluded",
594
594
  "reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
595
595
  },
596
+ "POST /api/v1/auth/signup-events": {
597
+ "status": "excluded",
598
+ "reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
599
+ },
596
600
  "POST /api/v1/auth/switch-account": {
597
601
  "status": "excluded",
598
602
  "reason": "interactive session/password/SSO flows; API keys are the MCP auth model"
@@ -4854,12 +4858,14 @@
4854
4858
  ]
4855
4859
  },
4856
4860
  "GET /api/v1/users/me": {
4857
- "status": "planned",
4858
- "reason": "team management subroutes beyond list/invite"
4861
+ "status": "covered",
4862
+ "tools": [
4863
+ "conduyt_get_my_profile"
4864
+ ]
4859
4865
  },
4860
4866
  "PATCH /api/v1/users/me": {
4861
- "status": "planned",
4862
- "reason": "team management subroutes beyond list/invite"
4867
+ "status": "excluded",
4868
+ "reason": "a signed-in person's own profile edit (name, phone, birthday, ringtone, password): session only (assertSessionOnly), never an API key's; GET /users/me is covered by conduyt_get_my_profile"
4863
4869
  },
4864
4870
  "GET /api/v1/users/me/tour": {
4865
4871
  "status": "planned",