conduyt-mcp 4.77.0 → 4.79.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 +12 -2
- package/dist/index.js +2 -0
- package/dist/tools/chat.d.ts +6 -0
- package/dist/tools/chat.js +55 -0
- package/dist/tools/notifications.js +1 -1
- package/dist/tools/pipelines.js +4 -4
- package/package.json +1 -1
- package/scripts/coverage-manifest.json +5 -0
- package/scripts/coverage-matrix.json +25 -10
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
|
|
|
55
55
|
| `conduyt_delete_pipeline` | Delete a pipeline (move/remove its deals first) |
|
|
56
56
|
| `conduyt_create_stage` | Append a stage to a pipeline (e.g. 'On Hold', 'Declined/Dead') |
|
|
57
57
|
| `conduyt_update_stage` | Rename, recolor, reorder, or change won/lost flags on a stage |
|
|
58
|
-
| `conduyt_delete_stage` |
|
|
58
|
+
| `conduyt_delete_stage` | Soft-delete a stage: an EMPTY stage can always be deleted (history kept, hidden from every list, GET / PATCH then 404, no restore in v1, name reusable); `moveTo` re-homes its live deals first, `expectedSettingsRevision` refuses the delete when settings changed (409 `stale_settings_revision`; the answer then carries the new `settingsRevision`). Refusals are 409s whose message is surfaced verbatim: deals in the stage, `used by N active automation(s) (names)`, DPP status map, `pipeline_archived`, `intake_target`. A run parked before the delete fails at that step with a clear reason |
|
|
59
59
|
|
|
60
60
|
### Activities
|
|
61
61
|
| Tool | Description |
|
|
@@ -197,9 +197,19 @@ The Mailchimp OAuth connect and callback are browser-only: connect Mailchimp in
|
|
|
197
197
|
### Notifications
|
|
198
198
|
| Tool | Description |
|
|
199
199
|
|------|-------------|
|
|
200
|
-
| `conduyt_list_notifications` | List notifications for current user |
|
|
200
|
+
| `conduyt_list_notifications` | List notifications for current user; `meta.latestByType` is `{type: {latest, count}}` over the last 24 h (newest createdAt + row count per type, read state ignored) next to `unreadCount` / `unreadByType` |
|
|
201
201
|
| `conduyt_create_notification` | Send in-app + push notification to a team member |
|
|
202
202
|
|
|
203
|
+
### Team Chat
|
|
204
|
+
| Tool | Description |
|
|
205
|
+
|------|-------------|
|
|
206
|
+
| `conduyt_list_chat_channels` | `GET /chat/channels`: your channels; also CREATES your memberships in the auto channels the first time |
|
|
207
|
+
| `conduyt_get_chat_unread` | `GET /chat/unread`: per channel `{ channelId, unread, soundUnread, newestSeq, latestSeq }`; may list an auto channel you have not opened yet (no membership row) |
|
|
208
|
+
| `conduyt_list_chat_messages` | List a channel's messages, newest first; every message carries `seq` (commit-ordered position in the channel); does not move the read marker; on a 403 "Not a member of this channel" it calls `GET /chat/channels` once and retries once |
|
|
209
|
+
| `conduyt_mark_chat_read` | Acknowledge read up to one message (`readUpTo` = message UUID; the marker only moves forward, an older or foreign message is a no-op; same one-retry membership bootstrap) |
|
|
210
|
+
|
|
211
|
+
The rest of team chat (sending, reactions, pins, presence, uploads) is not wrapped yet; `GET /chat/messages/{messageId}/location` is a UI deep-link helper and deliberately not wrapped.
|
|
212
|
+
|
|
203
213
|
### Custom Fields
|
|
204
214
|
| Tool | Description |
|
|
205
215
|
|------|-------------|
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import { registerBoardSettingsTools } from "./tools/board-settings.js";
|
|
|
26
26
|
import { registerMailchimpTools } from "./tools/mailchimp.js";
|
|
27
27
|
import { registerStatusMetadataTools } from "./tools/status-metadata.js";
|
|
28
28
|
import { registerNotificationTools } from "./tools/notifications.js";
|
|
29
|
+
import { registerChatTools } from "./tools/chat.js";
|
|
29
30
|
import { registerCustomFieldTools } from "./tools/custom-fields.js";
|
|
30
31
|
import { registerSmartListTools } from "./tools/smart-lists.js";
|
|
31
32
|
import { registerUserTools } from "./tools/users.js";
|
|
@@ -102,6 +103,7 @@ registerBoardSettingsTools(server, client);
|
|
|
102
103
|
registerMailchimpTools(server, client);
|
|
103
104
|
registerStatusMetadataTools(server, client);
|
|
104
105
|
registerNotificationTools(server, client);
|
|
106
|
+
registerChatTools(server, client);
|
|
105
107
|
registerCustomFieldTools(server, client);
|
|
106
108
|
registerSmartListTools(server, client);
|
|
107
109
|
registerUserTools(server, client);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { ConduytClient } from "../client.js";
|
|
3
|
+
/** The messages route's membership refusal (403 "Not a member of this channel"); any other 403 (a permission) is NOT this. */
|
|
4
|
+
export declare function isMembershipRefusal(err: unknown): boolean;
|
|
5
|
+
/** Team chat read side (app 10-09, live sha 3869c79ba): the unread vector, messages carrying `seq`, and the read acknowledgement. */
|
|
6
|
+
export declare function registerChatTools(server: McpServer, client: ConduytClient): void;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ConduytApiError, formatResult } from "../client.js";
|
|
3
|
+
import { strictInput } from "./strict.js";
|
|
4
|
+
/** The messages route's membership refusal (403 "Not a member of this channel"); any other 403 (a permission) is NOT this. */
|
|
5
|
+
export function isMembershipRefusal(err) {
|
|
6
|
+
return err instanceof Error && /\b403\b/.test(err.message) && /Not a member of this channel/.test(err.message)
|
|
7
|
+
&& (!(err instanceof ConduytApiError) || err.status === 403);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* GET /chat/unread lists the AUTO channels even when the caller has no membership row yet, while the messages route answers
|
|
11
|
+
* 403 without one; GET /chat/channels is what lazily creates it. So on that one refusal: call GET /chat/channels ONCE, retry
|
|
12
|
+
* the read ONCE. Bounded: a second refusal, or a 403 for any other reason, reaches the caller untouched.
|
|
13
|
+
*/
|
|
14
|
+
async function withMembershipBootstrap(client, read) {
|
|
15
|
+
try {
|
|
16
|
+
return await read();
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
if (!isMembershipRefusal(err))
|
|
20
|
+
throw err;
|
|
21
|
+
await client.get("/api/v1/chat/channels");
|
|
22
|
+
return read();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Team chat read side (app 10-09, live sha 3869c79ba): the unread vector, messages carrying `seq`, and the read acknowledgement. */
|
|
26
|
+
export function registerChatTools(server, client) {
|
|
27
|
+
strictInput(server.tool("conduyt_list_chat_channels", "List the team chat channels the API key's user belongs to (GET /api/v1/chat/channels): id, name, kind, members, last message, counts. SIDE EFFECT: it also creates the caller's memberships in the account's AUTO channels (needs-a-hand, new-leads, wins) the first time, so a user who never opened Team Chat can read them. conduyt_list_chat_messages and conduyt_mark_chat_read already do this once for you when a channel listed by conduyt_get_chat_unread answers 403 'Not a member of this channel'; call this tool directly to discover channel ids.", {}, async () => {
|
|
28
|
+
const result = await client.get("/api/v1/chat/channels");
|
|
29
|
+
return formatResult(result);
|
|
30
|
+
}));
|
|
31
|
+
strictInput(server.tool("conduyt_get_chat_unread", "Team chat unread vector for the API key's user (GET /api/v1/chat/unread): one row per channel the user is a member of, { channelId, unread, soundUnread, newestSeq, latestSeq }. unread is the badge count (auto channels are 0 by design); soundUnread counts unread messages in every channel, auto ones included; newestSeq is the seq of the channel's newest message not written by the user (null = none, panic cards left out); latestSeq is the same including panic cards. Read-only: no writes, never creates a membership, so a row can be an AUTO channel the caller has not opened yet (no membership row: reading it first answers 403, which conduyt_list_chat_messages / conduyt_mark_chat_read recover from by calling conduyt_list_chat_channels once and retrying once). Use it to decide which channel to read next, then conduyt_list_chat_messages.", {}, async () => {
|
|
32
|
+
const result = await client.get("/api/v1/chat/unread");
|
|
33
|
+
return formatResult(result);
|
|
34
|
+
}));
|
|
35
|
+
strictInput(server.tool("conduyt_list_chat_messages", "List messages in a team chat channel (GET /api/v1/chat/channels/{id}/messages), newest first, paged by cursor. The caller must be a channel member (else 403). Every message carries seq, its commit-ordered position inside the channel (a total order: the list and the cursor follow it). Read-only: this tool does not move the read marker. On a 403 'Not a member of this channel' it calls GET /chat/channels once (which creates the caller's auto-channel memberships) and retries ONCE; a second refusal, or a 403 for any other reason, is returned as is; acknowledge with conduyt_mark_chat_read.", {
|
|
36
|
+
channelId: z.string().uuid().describe("Channel UUID"),
|
|
37
|
+
limit: z.number().int().min(1).max(100).optional().describe("Messages per page (1-100, default 50)"),
|
|
38
|
+
cursor: z.string().uuid().optional().describe("nextCursor from the previous page (a message UUID)"),
|
|
39
|
+
}, async ({ channelId, limit, cursor }) => {
|
|
40
|
+
const params = new URLSearchParams();
|
|
41
|
+
if (limit !== undefined)
|
|
42
|
+
params.set("limit", String(limit));
|
|
43
|
+
if (cursor !== undefined)
|
|
44
|
+
params.set("cursor", cursor);
|
|
45
|
+
const result = await withMembershipBootstrap(client, () => client.get(`/api/v1/chat/channels/${channelId}/messages?${params}`));
|
|
46
|
+
return formatResult(result);
|
|
47
|
+
}));
|
|
48
|
+
strictInput(server.tool("conduyt_mark_chat_read", "Acknowledge team chat as read up to one message (GET /api/v1/chat/channels/{id}/messages?readUpTo=<messageId>&limit=1; the API takes the acknowledgement as a query on the message list). readUpTo is the UUID of a message you were served (its seq becomes your read marker); take it from conduyt_list_chat_messages. The marker only moves FORWARD, never to 'now': an older (stale) message is a no-op, and a message that is not in this channel moves nothing (the call still succeeds, so re-check conduyt_get_chat_unread). A malformed id is a 400. On a 403 'Not a member of this channel' it calls GET /chat/channels once (creating the caller's auto-channel memberships) and retries ONCE; a second refusal, or a 403 for any other reason, is returned as is. The response is the newest message of the channel (limit 1), not a confirmation of the move.", {
|
|
49
|
+
channelId: z.string().uuid().describe("Channel UUID"),
|
|
50
|
+
readUpTo: z.string().uuid().describe("UUID of the newest message you have seen in this channel"),
|
|
51
|
+
}, async ({ channelId, readUpTo }) => {
|
|
52
|
+
const result = await withMembershipBootstrap(client, () => client.get(`/api/v1/chat/channels/${channelId}/messages?limit=1&readUpTo=${readUpTo}`));
|
|
53
|
+
return formatResult(result);
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatResult } from "../client.js";
|
|
3
3
|
export function registerNotificationTools(server, client) {
|
|
4
|
-
server.tool("conduyt_list_notifications", "List notifications for the current user (determined by API key's associated user).", {
|
|
4
|
+
server.tool("conduyt_list_notifications", "List notifications for the current user (determined by API key's associated user). The response meta carries page, per_page, total, unreadCount, unreadByType ({type: count}) and latestByType ({type: {latest, count}}: per type over the last 24 hours, the newest createdAt as an ISO string and the row count, read state ignored; a poller sees a type advance when latest moves or count rises).", {
|
|
5
5
|
page: z.number().optional().describe("Page number (default 1)"),
|
|
6
6
|
per_page: z.number().optional().describe("Results per page"),
|
|
7
7
|
}, async ({ page, per_page }) => {
|
package/dist/tools/pipelines.js
CHANGED
|
@@ -9,7 +9,7 @@ export const ARCHIVE_ERROR_CODES = "A refusal carries a machine-readable code in
|
|
|
9
9
|
/** above the archive route's 60 s transaction (up to 500 deal moves) plus post-commit events; the client default is 30 s */
|
|
10
10
|
export const ARCHIVE_PIPELINE_TIMEOUT_MS = 75_000;
|
|
11
11
|
export function registerPipelineTools(server, client) {
|
|
12
|
-
server.tool("conduyt_list_pipelines", "List all active sales pipelines with their stages. Use this to discover pipeline and stage UUIDs before creating or moving deals. Each pipeline carries color (one of the 10 palette hex colours, or null until the settings page saves one), automationOnly and sortOrder (the board-menu order); an archived pipeline's pre-archive name is archivedFromName (see conduyt_list_archived_pipelines).", {}, async () => {
|
|
12
|
+
server.tool("conduyt_list_pipelines", "List all active sales pipelines with their stages (soft-deleted stages are never included). Use this to discover pipeline and stage UUIDs before creating or moving deals. Each pipeline carries color (one of the 10 palette hex colours, or null until the settings page saves one), automationOnly and sortOrder (the board-menu order); an archived pipeline's pre-archive name is archivedFromName (see conduyt_list_archived_pipelines).", {}, async () => {
|
|
13
13
|
const result = await client.get("/api/v1/pipelines");
|
|
14
14
|
return formatResult(result);
|
|
15
15
|
});
|
|
@@ -90,7 +90,7 @@ export function registerPipelineTools(server, client) {
|
|
|
90
90
|
const result = await client.post("/api/v1/pipelines/reorder", { order, expectedRevision });
|
|
91
91
|
return formatResult(result);
|
|
92
92
|
}));
|
|
93
|
-
server.tool("conduyt_create_stage", "Add a new stage to an existing pipeline. The stage is appended after the current last stage. Use this to extend a funnel (e.g. add 'On Hold' or 'Declined/Dead').", {
|
|
93
|
+
server.tool("conduyt_create_stage", "Add a new stage to an existing pipeline. The stage is appended after the current last stage. Use this to extend a funnel (e.g. add 'On Hold' or 'Declined/Dead'). A name may be reused after the old stage of that name was deleted (deleted stages are soft-deleted and hidden).", {
|
|
94
94
|
pipelineId: z.string().describe("Pipeline UUID to add the stage to"),
|
|
95
95
|
name: z.string().describe("Stage name"),
|
|
96
96
|
color: z.string().optional().describe("Hex color, e.g. '#3B82F6'"),
|
|
@@ -107,7 +107,7 @@ export function registerPipelineTools(server, client) {
|
|
|
107
107
|
});
|
|
108
108
|
return formatResult(result);
|
|
109
109
|
});
|
|
110
|
-
server.tool("conduyt_update_stage", "Update a stage within a pipeline — rename, recolor, reorder (sortOrder), or change its won/lost/system flags.", {
|
|
110
|
+
server.tool("conduyt_update_stage", "Update a stage within a pipeline — rename, recolor, reorder (sortOrder), or change its won/lost/system flags. A soft-deleted stage (conduyt_delete_stage) answers 404.", {
|
|
111
111
|
pipelineId: z.string().describe("Pipeline UUID"),
|
|
112
112
|
stageId: z.string().describe("Stage UUID to update"),
|
|
113
113
|
name: z.string().optional().describe("Updated stage name"),
|
|
@@ -128,7 +128,7 @@ export function registerPipelineTools(server, client) {
|
|
|
128
128
|
const result = await client.post(`/api/v1/pipelines/${pipelineId}/stages/reorder`, { order, expectedRevision });
|
|
129
129
|
return formatResult(result);
|
|
130
130
|
}));
|
|
131
|
-
strictInput(server.tool("conduyt_delete_stage", "Delete a stage from a pipeline. Strict input: the guards are spelled moveTo and expectedSettingsRevision, any other spelling (move_to, expected_settings_revision, a typo) is InvalidParams and nothing is deleted.
|
|
131
|
+
strictInput(server.tool("conduyt_delete_stage", "Delete a stage from a pipeline (soft delete). Strict input: the guards are spelled moveTo and expectedSettingsRevision, any other spelling (move_to, expected_settings_revision, a typo) is InvalidParams and nothing is deleted. An EMPTY stage can always be deleted, even if deals once used it: the row is kept so deal history, activities, audit rows and reports keep its name, but the stage is hidden from every pipeline and stage list, GET / PATCH on it answer 404, there is NO restore in v1, and its name may be reused by a new stage. Its stage rule, appointment-outcome mappings and status metadata are removed with it. With moveTo (another stage of the same pipeline, same won / lost setting, not a system stage) its live deals move there and the stage is deleted in one transaction. Pass expectedSettingsRevision (the settingsRevision from conduyt_get_settings_revision, read right before) to refuse the delete when settings changed since you read them; the answer is then { id, deleted: true, settingsRevision } with the revision after the delete. A refusal is a 409 whose server message is surfaced verbatim (never swallowed, never retried): deals currently in the stage and no moveTo (\"Cannot delete stage: N deal(s) are in this stage...\"), \"Cannot delete stage: it is used by N active automation(s) (names). Change their stage or pause them first.\" (a published automation references the stage in an action, trigger, condition or wait-until), the DPP status map selects it, plus the coded refusals stale_settings_revision, pipeline_archived and intake_target (enabled intake routes deals into this stage); a moveTo that is not a valid target is a 400. Accepted window: a run parked or in flight before the delete is not tracked; at the step that targets the deleted stage it fails or is skipped with a clear reason in the execution log.", {
|
|
132
132
|
pipelineId: z.string().describe("Pipeline UUID"),
|
|
133
133
|
stageId: z.string().describe("Stage UUID to delete"),
|
|
134
134
|
moveTo: z.string().uuid().optional().describe("Another stage of the same pipeline the live deals move to (sent as move_to)"),
|
package/package.json
CHANGED
|
@@ -115,6 +115,11 @@
|
|
|
115
115
|
"status": "planned",
|
|
116
116
|
"reason": "file listing/metadata is agent-useful; upload stays browser-side"
|
|
117
117
|
},
|
|
118
|
+
{
|
|
119
|
+
"match": "GET /api/v1/chat/messages/",
|
|
120
|
+
"status": "excluded",
|
|
121
|
+
"reason": "UI-only deep-link helper (resolves a message to its channel/position for the app's router); not an agent surface"
|
|
122
|
+
},
|
|
118
123
|
{
|
|
119
124
|
"match": "/api/v1/chat",
|
|
120
125
|
"status": "planned",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-10-
|
|
3
|
-
"toolCount":
|
|
4
|
-
"totalEndpoints":
|
|
2
|
+
"generatedAt": "2026-10-09T04:00:29.593Z",
|
|
3
|
+
"toolCount": 334,
|
|
4
|
+
"totalEndpoints": 1066,
|
|
5
5
|
"counts": {
|
|
6
|
-
"covered":
|
|
7
|
-
"excluded":
|
|
8
|
-
"planned":
|
|
6
|
+
"covered": 299,
|
|
7
|
+
"excluded": 319,
|
|
8
|
+
"planned": 448,
|
|
9
9
|
"uncategorized": 0
|
|
10
10
|
},
|
|
11
11
|
"matrix": {
|
|
@@ -1235,8 +1235,10 @@
|
|
|
1235
1235
|
"reason": "team chat read/post for agents"
|
|
1236
1236
|
},
|
|
1237
1237
|
"GET /api/v1/chat/channels": {
|
|
1238
|
-
"status": "
|
|
1239
|
-
"
|
|
1238
|
+
"status": "covered",
|
|
1239
|
+
"tools": [
|
|
1240
|
+
"conduyt_list_chat_channels"
|
|
1241
|
+
]
|
|
1240
1242
|
},
|
|
1241
1243
|
"POST /api/v1/chat/channels": {
|
|
1242
1244
|
"status": "planned",
|
|
@@ -1251,8 +1253,11 @@
|
|
|
1251
1253
|
"reason": "team chat read/post for agents"
|
|
1252
1254
|
},
|
|
1253
1255
|
"GET /api/v1/chat/channels/:id/messages": {
|
|
1254
|
-
"status": "
|
|
1255
|
-
"
|
|
1256
|
+
"status": "covered",
|
|
1257
|
+
"tools": [
|
|
1258
|
+
"conduyt_list_chat_messages",
|
|
1259
|
+
"conduyt_mark_chat_read"
|
|
1260
|
+
]
|
|
1256
1261
|
},
|
|
1257
1262
|
"POST /api/v1/chat/channels/:id/messages": {
|
|
1258
1263
|
"status": "planned",
|
|
@@ -1318,6 +1323,10 @@
|
|
|
1318
1323
|
"status": "planned",
|
|
1319
1324
|
"reason": "team chat read/post for agents"
|
|
1320
1325
|
},
|
|
1326
|
+
"GET /api/v1/chat/messages/:messageId/location": {
|
|
1327
|
+
"status": "excluded",
|
|
1328
|
+
"reason": "UI-only deep-link helper (resolves a message to its channel/position for the app's router); not an agent surface"
|
|
1329
|
+
},
|
|
1321
1330
|
"GET /api/v1/chat/presence": {
|
|
1322
1331
|
"status": "planned",
|
|
1323
1332
|
"reason": "team chat read/post for agents"
|
|
@@ -1326,6 +1335,12 @@
|
|
|
1326
1335
|
"status": "planned",
|
|
1327
1336
|
"reason": "team chat read/post for agents"
|
|
1328
1337
|
},
|
|
1338
|
+
"GET /api/v1/chat/unread": {
|
|
1339
|
+
"status": "covered",
|
|
1340
|
+
"tools": [
|
|
1341
|
+
"conduyt_get_chat_unread"
|
|
1342
|
+
]
|
|
1343
|
+
},
|
|
1329
1344
|
"GET /api/v1/companies": {
|
|
1330
1345
|
"status": "covered",
|
|
1331
1346
|
"tools": [
|