conduyt-mcp 4.105.4 → 4.105.6
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 +16 -1
- package/dist/register-all.js +2 -0
- package/dist/tool-annotations.js +10 -0
- package/dist/tools/ai-teammate-connect.d.ts +26 -0
- package/dist/tools/ai-teammate-connect.js +113 -0
- package/dist/tools/chat.d.ts +23 -0
- package/dist/tools/chat.js +121 -8
- package/dist/tools/routine-key-redaction.d.ts +22 -0
- package/dist/tools/routine-key-redaction.js +88 -0
- package/package.json +1 -1
- package/scripts/coverage-matrix.json +59 -5
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
|
+
## 392 Tools
|
|
6
6
|
|
|
7
7
|
### Discovery
|
|
8
8
|
| Tool | Description |
|
|
@@ -258,6 +258,8 @@ The Mailchimp OAuth connect and callback are browser-only: connect Mailchimp in
|
|
|
258
258
|
| `conduyt_react_chat_message` | React to a Team Chat message as the authenticated user/agent; an AI teammate passes `claimToken` (the turn prompt's reply claim token, plus `sourceMessageId` when it reacts to another message of the thread) and the reaction ENDS that turn (outcome reacted); 409 `reaction_stale_claim` / `turn_finished` write nothing |
|
|
259
259
|
| `conduyt_stay_silent` | (#802) the turn prompt's stay_silent: ends an AI teammate's turn with no response (`POST /chat/agents/me/inbox/claim` action `silent` for the turn's `messageId`, generation and listener id read from `claimToken`); 409 `turn_finished` when it already replied or reacted |
|
|
260
260
|
| `conduyt_get_chat_linked_record` | (#802) the turn prompt's linked_record: `GET /chat/channels/{id}/linked-record`, the channel's linked deal or contact as a read-only summary `{ kind, record }` under the caller's visibility; one 404 for not a member / no link / not visible |
|
|
261
|
+
| `conduyt_get_chat_thread` | (#684, thread view on) `GET /chat/channels/{id}/messages/{messageId}/thread`: one thread by its ROOT, `{ root, replies (oldest first), total, earlierCount, hasEarlier, earlierCursor, aiReactions, replying }`; `limit` 1-50 (default 20, the latest page), `before` = `earlierCursor`; an AI teammate reads the thread it answers in with the turn prompt's Reply parent id |
|
|
262
|
+
| `conduyt_list_chat_thread_files` | (#684, thread view on) `GET /chat/channels/{id}/messages/{messageId}/files`: the files shared in one thread, oldest first, `{ data, hasMore }` (200 per read) |
|
|
261
263
|
| `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) |
|
|
262
264
|
| `conduyt_get_chat_realtime_token` | `GET /realtime/token`: a signed Ably token request for the realtime pipe (`channelIds` up to 10, sent as repeated `channel=`); `{ enabled: false, reason }` when the account switch is off; returns `accountId` / `userId` |
|
|
263
265
|
| `conduyt_get_chat_notifications` | `GET /chat/channels/{id}/notifications`: your level `{ level, isDefault, notify, updatedAt, membershipId, membershipSince, membershipSeq }` |
|
|
@@ -266,8 +268,21 @@ The Mailchimp OAuth connect and callback are browser-only: connect Mailchimp in
|
|
|
266
268
|
|
|
267
269
|
`conduyt_list_chat_messages` also takes `afterSeq` (messages committed after that seq; once, a whole number, never with `cursor`) and every message carries `version` (a counter bumped on each update). `conduyt_get_chat_unread` rows carry `mentions`, `notify`, `updatedAt`, `membershipId` (null = no membership), `membershipSince`, `membershipSeq`, and the response `teamChatRealtime` and `membershipSeqHigh`; `conduyt_list_chat_channels` rows carry `mentionCount` and the same membership fields.
|
|
268
270
|
|
|
271
|
+
The thread view (#684): `conduyt_list_chat_messages` takes `topLevel: true` (top-level messages with their reply chip `thread`, and the replies shown in the channel with `threadReply` `{ reason: aiFirstAnswer | alsoInChannel, root }`; never with `afterSeq`) and `conduyt_reply_chat_message` takes `alsoInChannel: true` ("Also send to #channel" on a person's reply). Both are sent only when true. They and the two thread tools work only while the workspace's switch is on (`conduyt_list_chat_channels` then carries `teamChatThreads: true`); while it is off each answers an error result with `code: team_chat_threads_off` and the sentence "The thread view is not switched on for this workspace (account setting teamChatThreads)." Against a server that predates the thread view: the two thread tools answer a 404 error result that says so, `topLevel` is reported as not applied (an error result, never a page of every reply), and an `alsoInChannel` reply is posted in the thread only, with a note saying so (never re-sent).
|
|
272
|
+
|
|
269
273
|
AI teammate turns (#802, the app listener's #655 phase 1): a turn run through Codex (`conduyt agent listen --adapter codex`) carries the thread so far and, when the channel is linked, "This channel is linked to deal <id>" (read it with `conduyt_get_chat_linked_record`), and ends with exactly ONE of `conduyt_reply_chat_message` (with `idempotencyKey` + `claimToken`), `conduyt_react_chat_message` (with `claimToken`) or `conduyt_stay_silent`; a second, different one is refused 409 `turn_finished`. The inbox itself (`/chat/agents/me/*`: the event's `context.recent`, `channel.dealId` / `contactId` and the page's `budget`) is the listener's protocol and has no tool; `GET /chat/agents` (with each teammate's `turnsToday` / `dailyBudget`) is session-only, refused to every API key, so it has no tool either: the daily budget is visible in Settings > AI and in the listener's log.
|
|
270
274
|
|
|
275
|
+
### A Grok Bot as an AI teammate (#1140)
|
|
276
|
+
| Tool | Description |
|
|
277
|
+
|------|-------------|
|
|
278
|
+
| `conduyt_connect_ai_teammate` | Step 1 (`POST /chat/agents/{id}/connect`): the message to paste into Grok Bot and the teammate's token, returned ONCE (`{ token, tokenPrefix, message, toolsUrl, agent }`); the token goes into Grok Bot's masked connector field, never into a chat. Every call revokes the earlier tokens. The calling key needs the write tier and the chat and deals scopes |
|
|
279
|
+
| `conduyt_set_ai_teammate_webhook` | Step 3 (`PUT /chat/agents/{id}/webhook`): `paste` (the block Grok Bot shows) OR `address` + `key`; checked, stored (the key encrypted), tested at once: `{ stored, test, status }`. The key is write-only: never returned, and any returned text has it replaced by `[hidden]`. A test that was not accepted is an error result with its one sentence (`key_refused`, `routine_gone`, `unreachable`, `not_accepted`, `rate_limited`; `teammate_not_live` is a 409 before any test) |
|
|
280
|
+
| `conduyt_test_ai_teammate_webhook` | The card's Test button (`POST /chat/agents/{id}/webhook/test`): the hello again to the stored routine; `{ test, status }` |
|
|
281
|
+
| `conduyt_get_ai_teammate_webhook` | The status the card polls (`GET /chat/agents/{id}/webhook`): `state` not_connected / waiting_for_hello / listening / not_listening, `problem { code, message }`, the address masked; poll it after a test until listening |
|
|
282
|
+
| `conduyt_disconnect_ai_teammate` | `DELETE /chat/agents/{id}/webhook`: tokens revoked, the routine's key cleared, pending deliveries cancelled; the teammate and its messages stay; never blocked by billing |
|
|
283
|
+
|
|
284
|
+
Owners and admins, with a person's API key; an AI teammate's own key is refused. The teammate is named by its id (a UUID): the teammate list is session-only, so an API key cannot look up a handle. Against a server without these routes (an HTML 404) each tool answers "This Conduyt server does not have the Grok Bot connection yet (it predates it); connect the teammate in Settings > AI instead."
|
|
285
|
+
|
|
271
286
|
The remaining Team Chat mutations (edit/delete, pins, claim/handoff and typing/presence writes) are not wrapped yet; sends, thread replies, reactions and direct uploads are wrapped. `GET /chat/messages/{messageId}/location` is a UI deep-link helper and deliberately not wrapped. `GET /cron/team-chat-digest` is a CRON_SECRET-gated job and not key-callable.
|
|
272
287
|
|
|
273
288
|
#### What the API publishes (realtime events, not tools)
|
package/dist/register-all.js
CHANGED
|
@@ -26,6 +26,7 @@ import { registerMailchimpTools } from "./tools/mailchimp.js";
|
|
|
26
26
|
import { registerStatusMetadataTools } from "./tools/status-metadata.js";
|
|
27
27
|
import { registerNotificationTools } from "./tools/notifications.js";
|
|
28
28
|
import { registerChatTools } from "./tools/chat.js";
|
|
29
|
+
import { registerAiTeammateConnectTools } from "./tools/ai-teammate-connect.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";
|
|
@@ -90,6 +91,7 @@ export function registerAllTools(server, client) {
|
|
|
90
91
|
registerStatusMetadataTools(server, client);
|
|
91
92
|
registerNotificationTools(server, client);
|
|
92
93
|
registerChatTools(server, client);
|
|
94
|
+
registerAiTeammateConnectTools(server, client);
|
|
93
95
|
registerCustomFieldTools(server, client);
|
|
94
96
|
registerSmartListTools(server, client);
|
|
95
97
|
registerUserTools(server, client);
|
package/dist/tool-annotations.js
CHANGED
|
@@ -188,6 +188,16 @@ export const TOOL_ANNOTATIONS = {
|
|
|
188
188
|
// (#802) ends an AI teammate's turn with outcome silent; a repeat on the claim it completed answers the same (like an acknowledgement)
|
|
189
189
|
conduyt_stay_silent: update("Stay Silent on a Chat Turn"),
|
|
190
190
|
conduyt_get_chat_linked_record: ro("Get Chat Linked Record"),
|
|
191
|
+
// (#684) the thread reads recover a first-use 403 through GET /chat/channels, which creates auto-channel memberships (as conduyt_list_chat_messages)
|
|
192
|
+
conduyt_get_chat_thread: update("Get Chat Thread"),
|
|
193
|
+
// (#1140 A3) the Grok Bot teammate connect tools: connect mints a token and revokes the earlier ones on every call; set stores the
|
|
194
|
+
// routine key (replacing the last) and sends a test each call; test sends one; disconnect revokes the tokens
|
|
195
|
+
conduyt_connect_ai_teammate: row("Connect AI Teammate", false, true, false),
|
|
196
|
+
conduyt_set_ai_teammate_webhook: row("Set AI Teammate Webhook", false, false, false),
|
|
197
|
+
conduyt_test_ai_teammate_webhook: create("Test AI Teammate Webhook"),
|
|
198
|
+
conduyt_get_ai_teammate_webhook: ro("Get AI Teammate Webhook"),
|
|
199
|
+
conduyt_disconnect_ai_teammate: destroy("Disconnect AI Teammate"),
|
|
200
|
+
conduyt_list_chat_thread_files: update("List Chat Thread Files"),
|
|
191
201
|
conduyt_mark_chat_read: row("Mark Chat Read", false, false, false), // every call re-announces the read receipt to the reader's other tabs and devices, even when the marker did not move (chat/channels/[id]/messages/route.ts:126)
|
|
192
202
|
conduyt_get_chat_realtime_token: ro("Get Chat Realtime Token"),
|
|
193
203
|
conduyt_get_chat_notifications: update("Get Chat Notifications"),
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { ConduytApiError, ConduytClient } from "../client.js";
|
|
3
|
+
export { hide } from "./routine-key-redaction.js";
|
|
4
|
+
/**
|
|
5
|
+
* (#1140 lane A3) Connecting a Grok Bot routine as an AI teammate, the tri-surface twin of the Settings > AI card and of the CLI's
|
|
6
|
+
* `agent connect` / `agent webhook set|test|status` / `agent disconnect`. Built against conduyt-app lane A2b at 0c0088508
|
|
7
|
+
* (app/api/v1/chat/agents/[id]/connect, .../webhook, .../webhook/test; every sentence lives in lib/agent-webhook-connect.ts) and the
|
|
8
|
+
* concept page Cerebro domains/conduyt/wiki/concepts/grok-bot-teammate-webhook-and-remote-tools-2026-11-05.md (section S).
|
|
9
|
+
*
|
|
10
|
+
* The routine's key is WRITE-ONLY: it goes into the PUT body and nowhere else. The server never answers it, and every text these tools
|
|
11
|
+
* return (results and refusals) is passed through `hide`, which replaces it wherever it appears. Every refusal is an error result with
|
|
12
|
+
* ONE plain sentence: the server's own (the S.3 and input sentences are written for people) or ours where its text is not one (today's
|
|
13
|
+
* production answers these paths with an HTML 404; the switch answers "Not found").
|
|
14
|
+
*/
|
|
15
|
+
export declare const OLDER_SERVER_SENTENCE = "This Conduyt server does not have the Grok Bot connection yet (it predates it); connect the teammate in Settings > AI instead.";
|
|
16
|
+
export declare const AI_OFF_SENTENCE = "AI teammates are switched off for this workspace; an owner or admin turns them on in Settings > AI.";
|
|
17
|
+
export declare const NOT_FOUND_SENTENCE = "There is no AI teammate with that id in this workspace (it may have been revoked).";
|
|
18
|
+
export declare const TEAMMATE_KEY_SENTENCE = "This is an AI teammate's own key; connect a Grok Bot with a person's API key (an owner or admin).";
|
|
19
|
+
export declare const NO_CREDENTIALS_SENTENCE = "Give the routine's address and key: paste (the block Grok Bot shows), or address and key.";
|
|
20
|
+
/** the one sentence for a refused call, and its code when the API sent one */
|
|
21
|
+
export declare function refusalOf(err: ConduytApiError): {
|
|
22
|
+
status: number | null;
|
|
23
|
+
code: string | null;
|
|
24
|
+
sentence: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function registerAiTeammateConnectTools(server: McpServer, client: ConduytClient): void;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { ConduytApiError, formatToolError } from "../client.js";
|
|
3
|
+
import { strictInput } from "./strict.js";
|
|
4
|
+
import { hide, secretsInPaste, secretsOfKey } from "./routine-key-redaction.js";
|
|
5
|
+
export { hide } from "./routine-key-redaction.js";
|
|
6
|
+
/**
|
|
7
|
+
* (#1140 lane A3) Connecting a Grok Bot routine as an AI teammate, the tri-surface twin of the Settings > AI card and of the CLI's
|
|
8
|
+
* `agent connect` / `agent webhook set|test|status` / `agent disconnect`. Built against conduyt-app lane A2b at 0c0088508
|
|
9
|
+
* (app/api/v1/chat/agents/[id]/connect, .../webhook, .../webhook/test; every sentence lives in lib/agent-webhook-connect.ts) and the
|
|
10
|
+
* concept page Cerebro domains/conduyt/wiki/concepts/grok-bot-teammate-webhook-and-remote-tools-2026-11-05.md (section S).
|
|
11
|
+
*
|
|
12
|
+
* The routine's key is WRITE-ONLY: it goes into the PUT body and nowhere else. The server never answers it, and every text these tools
|
|
13
|
+
* return (results and refusals) is passed through `hide`, which replaces it wherever it appears. Every refusal is an error result with
|
|
14
|
+
* ONE plain sentence: the server's own (the S.3 and input sentences are written for people) or ours where its text is not one (today's
|
|
15
|
+
* production answers these paths with an HTML 404; the switch answers "Not found").
|
|
16
|
+
*/
|
|
17
|
+
export const OLDER_SERVER_SENTENCE = "This Conduyt server does not have the Grok Bot connection yet (it predates it); connect the teammate in Settings > AI instead.";
|
|
18
|
+
export const AI_OFF_SENTENCE = "AI teammates are switched off for this workspace; an owner or admin turns them on in Settings > AI.";
|
|
19
|
+
export const NOT_FOUND_SENTENCE = "There is no AI teammate with that id in this workspace (it may have been revoked).";
|
|
20
|
+
export const TEAMMATE_KEY_SENTENCE = "This is an AI teammate's own key; connect a Grok Bot with a person's API key (an owner or admin).";
|
|
21
|
+
export const NO_CREDENTIALS_SENTENCE = "Give the routine's address and key: paste (the block Grok Bot shows), or address and key.";
|
|
22
|
+
function errorDetailsOf(message) {
|
|
23
|
+
for (let i = message.indexOf("{"); i !== -1; i = message.indexOf("{", i + 1)) {
|
|
24
|
+
try {
|
|
25
|
+
const v = JSON.parse(message.slice(i));
|
|
26
|
+
if (v && typeof v === "object" && !Array.isArray(v))
|
|
27
|
+
return v;
|
|
28
|
+
}
|
|
29
|
+
catch { /* not the details object */ }
|
|
30
|
+
}
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
/** the API's own error line (between "Conduyt API <status>: " and its Retry-After tag or details object) */
|
|
34
|
+
function apiErrorText(message) {
|
|
35
|
+
const m = /^Conduyt API \d{3}: ([\s\S]*)$/.exec(message);
|
|
36
|
+
let t = m ? m[1] : message;
|
|
37
|
+
const brace = t.search(/ \[Retry-After: |\s\{/);
|
|
38
|
+
if (brace !== -1)
|
|
39
|
+
t = t.slice(0, brace);
|
|
40
|
+
return t.replace(/\s+/g, " ").trim();
|
|
41
|
+
}
|
|
42
|
+
/** the one sentence for a refused call, and its code when the API sent one */
|
|
43
|
+
export function refusalOf(err) {
|
|
44
|
+
const details = errorDetailsOf(err.message);
|
|
45
|
+
const code = typeof details.code === "string" ? details.code : null;
|
|
46
|
+
const text = apiErrorText(err.message);
|
|
47
|
+
const status = err.status;
|
|
48
|
+
if (code === "ai_teammates_off")
|
|
49
|
+
return { status, code, sentence: AI_OFF_SENTENCE };
|
|
50
|
+
if (code === "agent_route_refused")
|
|
51
|
+
return { status, code, sentence: TEAMMATE_KEY_SENTENCE };
|
|
52
|
+
if (status === 404 && text === "Agent not found")
|
|
53
|
+
return { status, code, sentence: NOT_FOUND_SENTENCE };
|
|
54
|
+
// today's production: its HTML not-found page (404 text/html, before any auth); a 405: a route without this method
|
|
55
|
+
if (status === 404 || status === 405)
|
|
56
|
+
return { status, code, sentence: OLDER_SERVER_SENTENCE };
|
|
57
|
+
return { status, code, sentence: text.slice(0, 600) || `Conduyt answered ${status} without an explanation.` };
|
|
58
|
+
}
|
|
59
|
+
/** a structured, non-retried error result: { ok: false, status, code?, error: <one sentence>, ...extra } */
|
|
60
|
+
function connectError(sentence, secrets, extra = {}) {
|
|
61
|
+
const base = formatToolError(hide(sentence, secrets));
|
|
62
|
+
return { ...base, content: [{ type: "text", text: hide(JSON.stringify({ ok: false, error: sentence, ...extra }, null, 2), secrets) }] };
|
|
63
|
+
}
|
|
64
|
+
/** a result whose text is redacted of the secrets */
|
|
65
|
+
function connectResult(data, secrets) {
|
|
66
|
+
return { content: [{ type: "text", text: hide(JSON.stringify(data, null, 2), secrets) }] };
|
|
67
|
+
}
|
|
68
|
+
/** runs one call: an HTTP refusal becomes the one-sentence error result; any other failure is re-thrown with the secrets hidden */
|
|
69
|
+
async function attempt(secrets, call, done) {
|
|
70
|
+
try {
|
|
71
|
+
return done(await call());
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (err instanceof ConduytApiError && err.code === "http") {
|
|
75
|
+
const r = refusalOf(err);
|
|
76
|
+
return connectError(r.sentence, secrets, { status: r.status, ...(r.code ? { code: r.code } : {}) });
|
|
77
|
+
}
|
|
78
|
+
throw new Error(hide(err instanceof Error ? err.message : String(err), secrets));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const unwrap = (res) => (res && typeof res === "object" && "data" in res ? res.data : res);
|
|
82
|
+
/** a test's answer: the data, or (the test was not accepted) an error result with the S.3 sentence and the whole answer beside it */
|
|
83
|
+
function testOutcome(secrets) {
|
|
84
|
+
return (res) => {
|
|
85
|
+
const d = unwrap(res);
|
|
86
|
+
if (d?.test?.ok === true)
|
|
87
|
+
return connectResult(d, secrets);
|
|
88
|
+
const problem = d?.test?.problem;
|
|
89
|
+
return connectError(problem?.message ?? "Grok Bot did not accept the test.", secrets, { status: 200, ...(problem?.code ? { code: problem.code } : {}), answer: d });
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const TEAMMATE_ID = z.string().uuid().describe("The Grok Bot teammate's id (a UUID: the agent.id of conduyt_connect_ai_teammate's answer, or the senderId of its messages). A handle cannot be looked up with an API key (the teammate list is session-only)");
|
|
93
|
+
const WHO = "Owners and admins, with a person's API key (the chat scope); an AI teammate's own key is refused (403 agent_route_refused). The teammate must have the provider Grok Bot (409 agent_not_webhook_provider otherwise). Answers 404 ai_teammates_off while AI teammates are switched off and 402 plan_required when the workspace is not entitled. Against a server that predates these routes (an HTML 404) the error says so.";
|
|
94
|
+
export function registerAiTeammateConnectTools(server, client) {
|
|
95
|
+
strictInput(server.tool("conduyt_connect_ai_teammate", `Step 1 of connecting a Grok Bot as an AI teammate (POST /api/v1/chat/agents/{id}/connect). Mints the teammate's token and returns it ONCE: { token, tokenPrefix, message, toolsUrl, agent }. Give the person the message to paste into Grok Bot and the token to paste into Grok Bot's masked connector field; NEVER post the token in Team Chat or any message, and never call this again just to read it: every call revokes the teammate's earlier tokens (the card's "Get a new token"). The calling key must hold the write tier and the chat and deals scopes (403 api_key_scope_insufficient otherwise). 409 agent_listener_webhook_conflict while the teammate's listener is running; 409 teammate_not_live when its owner lost access. Step 3 is conduyt_set_ai_teammate_webhook. ${WHO}`, { teammateId: TEAMMATE_ID }, async ({ teammateId }) => attempt([], () => client.post(`/api/v1/chat/agents/${teammateId}/connect`, {}), (res) => connectResult(unwrap(res), []))));
|
|
96
|
+
strictInput(server.tool("conduyt_set_ai_teammate_webhook", `Step 3 of connecting a Grok Bot (PUT /api/v1/chat/agents/{id}/webhook): the routine's address and key, as Grok Bot shows them. Send EITHER paste (the whole block the person copied: it must hold one https address and one key) OR address and key. Conduyt checks the address (https, a public host), stores both (the key encrypted), sends the test at once (10 s) and answers { stored, test: { ok, statusCode, runUuid, problem }, status }. The key is write-only: it is never returned, and any text this tool returns has it replaced by [hidden]. A test that was not accepted is an error result { ok: false, error: <its one sentence>, status: 200, code (key_refused, routine_gone, unreachable, not_accepted, rate_limited), answer: <the whole answer> }; an accepted test leaves status Waiting for Grok Bot to say hello: poll conduyt_get_ai_teammate_webhook (every few seconds, up to about 3 minutes) until state is listening or a problem is named. Refusals: 400 with the input codes (input_conflict, address_missing, address_invalid, address_ambiguous, key_missing, key_invalid, key_ambiguous), 422 address_invalid / unreachable, 409 agent_token_missing (run conduyt_connect_ai_teammate first), agent_listener_webhook_conflict, agent_webhook_contended, teammate_not_live, 429 after 20 tests per teammate in 10 minutes, 503 while a delivery is in flight. ${WHO}`, {
|
|
97
|
+
teammateId: TEAMMATE_ID,
|
|
98
|
+
paste: z.string().min(1).max(12_000).optional().describe("The block Grok Bot shows (address and key together); not with address or key"),
|
|
99
|
+
address: z.string().min(1).max(2_048).optional().describe("The routine's address (https://...)"),
|
|
100
|
+
key: z.string().min(1).max(600).optional().describe("The routine's key (write-only: never returned). The bare key or the \"Authorization: Bearer ...\" header value"),
|
|
101
|
+
}, async ({ teammateId, paste, address, key }) => {
|
|
102
|
+
// (r2, Codex grokA3-mcp-r1 P1) every form the server could take as the key (routine-key-redaction.ts: the server's rules ported,
|
|
103
|
+
// plus the floor of every token as typed), for each input that can carry it: the key field, the paste, an address box holding both
|
|
104
|
+
const secrets = [...(key !== undefined ? secretsOfKey(key) : []), ...(paste !== undefined ? secretsInPaste(paste) : []), ...(address !== undefined ? secretsInPaste(address) : [])];
|
|
105
|
+
if (paste === undefined && address === undefined)
|
|
106
|
+
return connectError(NO_CREDENTIALS_SENTENCE, secrets, { status: null, code: "address_missing" });
|
|
107
|
+
// the body as ONE literal: the contract gate reads the request fields (paste, address, key) from it
|
|
108
|
+
return attempt(secrets, () => client.put(`/api/v1/chat/agents/${teammateId}/webhook`, { ...(paste !== undefined ? { paste } : {}), ...(address !== undefined ? { address } : {}), ...(key !== undefined ? { key } : {}) }), testOutcome(secrets));
|
|
109
|
+
}));
|
|
110
|
+
strictInput(server.tool("conduyt_test_ai_teammate_webhook", `The card's Test button (POST /api/v1/chat/agents/{id}/webhook/test): sends the hello again to the stored routine (nothing is pasted); an accepted test turns a stored connection live. Answers { test, status } like conduyt_set_ai_teammate_webhook (a test that was not accepted is an error result with its sentence); then poll conduyt_get_ai_teammate_webhook for the hello. 409 agent_webhook_not_connected when nothing is stored; 429 after 20 tests per teammate in 10 minutes. ${WHO}`, { teammateId: TEAMMATE_ID }, async ({ teammateId }) => attempt([], () => client.post(`/api/v1/chat/agents/${teammateId}/webhook/test`, {}), testOutcome([]))));
|
|
111
|
+
strictInput(server.tool("conduyt_get_ai_teammate_webhook", `The Grok Bot connection's status (GET /api/v1/chat/agents/{id}/webhook), what the Settings card polls: { state (not_connected | waiting_for_hello | listening | not_listening), label, problem: { code, message } | null (the sentence of what to fix), waitingUntil, connected, address (masked), lastAttempt, tokenUsedAt, lastMessageAt }. Never carries the key. ${WHO}`, { teammateId: TEAMMATE_ID }, async ({ teammateId }) => attempt([], () => client.get(`/api/v1/chat/agents/${teammateId}/webhook`), (res) => connectResult(unwrap(res), []))));
|
|
112
|
+
strictInput(server.tool("conduyt_disconnect_ai_teammate", `Disconnect a Grok Bot teammate (DELETE /api/v1/chat/agents/{id}/webhook; never blocked by billing): its tokens are revoked, the routine's key is cleared and pending deliveries are cancelled; the teammate and its messages stay. Answers { disconnected, revokedKeys, webhook, cancelledDeliveries }. Safe to repeat. Connect it again with conduyt_connect_ai_teammate then conduyt_set_ai_teammate_webhook. ${WHO.replace(" and 402 plan_required when the workspace is not entitled", "")}`, { teammateId: TEAMMATE_ID }, async ({ teammateId }) => attempt([], () => client.del(`/api/v1/chat/agents/${teammateId}/webhook`), (res) => connectResult(unwrap(res), []))));
|
|
113
|
+
}
|
package/dist/tools/chat.d.ts
CHANGED
|
@@ -2,5 +2,28 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
2
2
|
import { ConduytClient } from "../client.js";
|
|
3
3
|
/** The messages route's membership refusal (403 "Not a member of this channel"); any other 403 (a permission) is NOT this. */
|
|
4
4
|
export declare function isMembershipRefusal(err: unknown): boolean;
|
|
5
|
+
/** the plain sentence for the 409 team_chat_threads_off every thread view route and parameter answers while the switch is off */
|
|
6
|
+
export declare const THREADS_OFF_SENTENCE = "The thread view is not switched on for this workspace (account setting teamChatThreads).";
|
|
7
|
+
export declare const THREAD_NOT_FOUND_SENTENCE = "Not found: the message is not a thread root you can see in this channel (a reply id, an unknown or hidden message, a root past the 90-day window with no reply inside it), or this Conduyt server predates the thread view.";
|
|
8
|
+
/**
|
|
9
|
+
* (#684) a thread view refusal as a plain-sentence error result: 409 team_chat_threads_off (offSentence) and, on the two new routes,
|
|
10
|
+
* a 404 (an older server has no such route, or the id is not a visible root). null for anything else, which the caller rethrows as is.
|
|
11
|
+
*/
|
|
12
|
+
export declare function threadsRefusal(err: unknown, opts: {
|
|
13
|
+
offSentence: string;
|
|
14
|
+
notFound?: boolean;
|
|
15
|
+
}): {
|
|
16
|
+
content: {
|
|
17
|
+
type: "text";
|
|
18
|
+
text: string;
|
|
19
|
+
}[];
|
|
20
|
+
isError: true;
|
|
21
|
+
} | null;
|
|
22
|
+
/** (#684) a topLevel=true page: every row carries `thread` (a root) or `threadReply` (a reply shown in the channel). A server that
|
|
23
|
+
* predates the thread view ignores the parameter and serves every message without either key. An empty page cannot tell. */
|
|
24
|
+
export declare function topLevelApplied(page: unknown): boolean;
|
|
25
|
+
/** (#684) after a reply sent with alsoInChannel: the note when the stored metadata does not carry the flag (an older server ignored it;
|
|
26
|
+
* the reply WAS posted and must not be sent again); null when it does or there is no metadata to judge by. */
|
|
27
|
+
export declare function alsoInChannelNote(posted: unknown): string | null;
|
|
5
28
|
/** Team chat read side (app 10-09, live sha 3869c79ba): the unread vector, messages carrying `seq`, and the read acknowledgement. */
|
|
6
29
|
export declare function registerChatTools(server: McpServer, client: ConduytClient): void;
|
package/dist/tools/chat.js
CHANGED
|
@@ -41,9 +41,49 @@ async function withMembershipBootstrap(client, read) {
|
|
|
41
41
|
return read();
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
// ---- (#684) the Team Chat thread view (app lane A de0a86727 + lane D files, behind the account switch teamChatThreads) ----
|
|
45
|
+
/** the plain sentence for the 409 team_chat_threads_off every thread view route and parameter answers while the switch is off */
|
|
46
|
+
export const THREADS_OFF_SENTENCE = "The thread view is not switched on for this workspace (account setting teamChatThreads).";
|
|
47
|
+
export const THREAD_NOT_FOUND_SENTENCE = "Not found: the message is not a thread root you can see in this channel (a reply id, an unknown or hidden message, a root past the 90-day window with no reply inside it), or this Conduyt server predates the thread view.";
|
|
48
|
+
/** a structured, non-retried error result: { ok: false, status, code?, error: <plain sentence>, message: <the API's error line> } */
|
|
49
|
+
function threadsError(status, code, sentence, message) {
|
|
50
|
+
const base = formatToolError(sentence);
|
|
51
|
+
return { ...base, content: [{ type: "text", text: JSON.stringify({ ok: false, status, ...(code ? { code } : {}), error: sentence, message: message.slice(0, 500) }, null, 2) }] };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* (#684) a thread view refusal as a plain-sentence error result: 409 team_chat_threads_off (offSentence) and, on the two new routes,
|
|
55
|
+
* a 404 (an older server has no such route, or the id is not a visible root). null for anything else, which the caller rethrows as is.
|
|
56
|
+
*/
|
|
57
|
+
export function threadsRefusal(err, opts) {
|
|
58
|
+
if (!(err instanceof ConduytApiError) || err.code !== "http")
|
|
59
|
+
return null;
|
|
60
|
+
if (err.status === 409 && errorDetailsOf(err.message)?.code === "team_chat_threads_off")
|
|
61
|
+
return threadsError(409, "team_chat_threads_off", opts.offSentence, err.message);
|
|
62
|
+
if (err.status === 404 && opts.notFound)
|
|
63
|
+
return threadsError(404, null, THREAD_NOT_FOUND_SENTENCE, err.message);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
/** (#684) a topLevel=true page: every row carries `thread` (a root) or `threadReply` (a reply shown in the channel). A server that
|
|
67
|
+
* predates the thread view ignores the parameter and serves every message without either key. An empty page cannot tell. */
|
|
68
|
+
export function topLevelApplied(page) {
|
|
69
|
+
const inner = page && typeof page === "object" && "data" in page ? page.data : page;
|
|
70
|
+
const rows = inner && typeof inner === "object" && Array.isArray(inner.data) ? inner.data : null;
|
|
71
|
+
return rows !== null && rows.every((r) => r !== null && typeof r === "object" && ("thread" in r || "threadReply" in r));
|
|
72
|
+
}
|
|
73
|
+
/** (#684) after a reply sent with alsoInChannel: the note when the stored metadata does not carry the flag (an older server ignored it;
|
|
74
|
+
* the reply WAS posted and must not be sent again); null when it does or there is no metadata to judge by. */
|
|
75
|
+
export function alsoInChannelNote(posted) {
|
|
76
|
+
const row = posted && typeof posted === "object" && "data" in posted ? posted.data : posted;
|
|
77
|
+
const meta = row && typeof row === "object" ? row.metadata : undefined;
|
|
78
|
+
if (!meta || typeof meta !== "object" || Array.isArray(meta))
|
|
79
|
+
return null;
|
|
80
|
+
if (meta.alsoInChannel === true)
|
|
81
|
+
return null;
|
|
82
|
+
return "Note: the reply was posted in the thread only: this Conduyt server did not record alsoInChannel (it predates the thread view). Do not send it again.";
|
|
83
|
+
}
|
|
44
84
|
/** Team chat read side (app 10-09, live sha 3869c79ba): the unread vector, messages carrying `seq`, and the read acknowledgement. */
|
|
45
85
|
export function registerChatTools(server, client) {
|
|
46
|
-
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. Each row also carries the Team Chat 2.0 fields mentionCount (unread @mentions of you), notify (your stored notification level: all, mentions, mute, or null = the kind's default), updatedAt (when notify last changed, null = never), membershipId (your member row; changes after a removal and re-add), membershipSince (when this membership began) and membershipSeq (strictly increasing incarnation number). Each row also carries agentsRespond (boolean, #613 lane A: false = an admin muted AI teammates in that channel) and, ONLY while the account's AI teammates switch is on, aiTeammates: [{ userId, lastHeartbeatAt, replying }] (the paired AI teammates that are members of the channel; lastHeartbeatAt is their last listener heartbeat or null, replying is true while one is working a turn in that channel; derive the state like the app: heartbeat within 60 seconds = Listening, listening and replying = Replying, otherwise Away). The aiTeammates key is absent when the switch is off. 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 () => {
|
|
86
|
+
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. Each row also carries the Team Chat 2.0 fields mentionCount (unread @mentions of you), notify (your stored notification level: all, mentions, mute, or null = the kind's default), updatedAt (when notify last changed, null = never), membershipId (your member row; changes after a removal and re-add), membershipSince (when this membership began) and membershipSeq (strictly increasing incarnation number). Each row also carries agentsRespond (boolean, #613 lane A: false = an admin muted AI teammates in that channel) and, ONLY while the account's AI teammates switch is on, aiTeammates: [{ userId, lastHeartbeatAt, replying }] (the paired AI teammates that are members of the channel; lastHeartbeatAt is their last listener heartbeat or null, replying is true while one is working a turn in that channel; derive the state like the app: heartbeat within 60 seconds = Listening, listening and replying = Replying, otherwise Away). The aiTeammates key is absent when the switch is off. 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. (#684) While the workspace's thread view is on, the answer also carries teamChatThreads: true next to data (the key is ABSENT when it is off): only then do conduyt_list_chat_messages topLevel, conduyt_get_chat_thread, conduyt_list_chat_thread_files and conduyt_reply_chat_message alsoInChannel work.", {}, async () => {
|
|
47
87
|
const result = await client.get("/api/v1/chat/channels");
|
|
48
88
|
return formatResult(result);
|
|
49
89
|
}));
|
|
@@ -51,14 +91,17 @@ export function registerChatTools(server, client) {
|
|
|
51
91
|
const result = await client.get("/api/v1/chat/unread");
|
|
52
92
|
return formatResult(result);
|
|
53
93
|
}));
|
|
54
|
-
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), and version, a server counter bumped on every update (edit, delete, reaction, pin, claim, handoff) so a client can tell which copy of a message is newer. afterSeq=N is the realtime catch-up read: the messages committed AFTER seq N (give it once, a whole number 0-999999999, never together with cursor: both are refused here before any request); the page is still newest first and hasMore says more follow it (then re-read the latest page). 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.", {
|
|
94
|
+
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), and version, a server counter bumped on every update (edit, delete, reaction, pin, claim, handoff) so a client can tell which copy of a message is newer. afterSeq=N is the realtime catch-up read: the messages committed AFTER seq N (give it once, a whole number 0-999999999, never together with cursor: both are refused here before any request); the page is still newest first and hasMore says more follow it (then re-read the latest page). 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. (#684) topLevel: true serves the thread view's channel (top-level messages with their reply chip `thread`, and the replies shown in the channel with `threadReply`); a server that predates the thread view, which ignores the parameter, is reported as an error instead of a page of every reply.", {
|
|
55
95
|
channelId: z.string().uuid().describe("Channel UUID"),
|
|
56
96
|
limit: z.number().int().min(1).max(100).optional().describe("Messages per page (1-100, default 50)"),
|
|
57
97
|
cursor: z.string().uuid().optional().describe("nextCursor from the previous page (a message UUID)"),
|
|
58
98
|
afterSeq: z.number().int().min(0).max(999_999_999).optional().describe("Only messages committed after this seq (the realtime catch-up read). Not combinable with cursor"),
|
|
59
|
-
|
|
99
|
+
topLevel: z.boolean().optional().describe("(#684, only while the workspace's thread view is on) true = the channel as the thread view shows it: every top-level message with `thread` (its reply chip { replyCount, lastReplyAt, repliers (up to 3), replierCount }, null when it has no reply), plus the replies shown in the channel with `threadReply` { reason: aiFirstAnswer | alsoInChannel, root: { id, excerpt, deleted } | null }; every other reply stays in its thread (conduyt_get_chat_thread). Sent only when true; never with afterSeq. 409 team_chat_threads_off while the thread view is off"),
|
|
100
|
+
}, async ({ channelId, limit, cursor, afterSeq, topLevel }) => {
|
|
60
101
|
if (afterSeq !== undefined && cursor !== undefined)
|
|
61
102
|
throw new Error("afterSeq and cursor cannot be combined");
|
|
103
|
+
if (topLevel === true && afterSeq !== undefined)
|
|
104
|
+
throw new Error("topLevel and afterSeq cannot be combined (the catch-up read serves every message); nothing was sent");
|
|
62
105
|
const params = new URLSearchParams();
|
|
63
106
|
if (limit !== undefined)
|
|
64
107
|
params.set("limit", String(limit));
|
|
@@ -66,8 +109,25 @@ export function registerChatTools(server, client) {
|
|
|
66
109
|
params.set("cursor", cursor);
|
|
67
110
|
if (afterSeq !== undefined)
|
|
68
111
|
params.set("afterSeq", String(afterSeq));
|
|
69
|
-
|
|
70
|
-
|
|
112
|
+
if (topLevel === true)
|
|
113
|
+
params.set("topLevel", "true");
|
|
114
|
+
if (topLevel !== true) {
|
|
115
|
+
const result = await withMembershipBootstrap(client, () => client.get(`/api/v1/chat/channels/${channelId}/messages?${params}`));
|
|
116
|
+
return formatResult(result);
|
|
117
|
+
}
|
|
118
|
+
let page;
|
|
119
|
+
try {
|
|
120
|
+
page = await withMembershipBootstrap(client, () => client.get(`/api/v1/chat/channels/${channelId}/messages?${params}`));
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
const refused = threadsRefusal(err, { offSentence: THREADS_OFF_SENTENCE });
|
|
124
|
+
if (refused)
|
|
125
|
+
return refused;
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
if (!topLevelApplied(page))
|
|
129
|
+
return formatToolError("This Conduyt server did not apply topLevel: its messages carry no thread summaries (it predates the thread view). Read the channel without topLevel.");
|
|
130
|
+
return formatResult(page);
|
|
71
131
|
}));
|
|
72
132
|
strictInput(server.tool("conduyt_send_chat_message", "Send a Team Chat message as the API key's user (POST /api/v1/chat/channels/{id}/messages). The caller must be a channel member and have the communications:create permission. AI-agent keys are subject to the server's six-consecutive-agent-turn cap; a 409 agent_human_required means no message was sent (markerCreated: true on the refusal that inserted the one human-handoff line, false while the thread stays paused). A 409 agents_muted_in_channel means an admin turned \"agents respond here\" off for this channel: the post was refused, nothing was written, and it must not be retried (same shape as agent_human_required). body is plain Team Chat text (1-10,000 characters).", {
|
|
73
133
|
channelId: z.string().uuid().describe("Channel UUID"),
|
|
@@ -82,14 +142,30 @@ export function registerChatTools(server, client) {
|
|
|
82
142
|
parentId: z.string().uuid().describe("Top-level message UUID to reply to"),
|
|
83
143
|
body: z.string().trim().min(1).max(10_000).describe("Reply text"),
|
|
84
144
|
idempotencyKey: z.string().regex(/^[A-Za-z0-9:_.-]{1,128}$/).optional().describe("Paired AI agent keys only: a key unique per agent and source turn (the listener uses reply:<source message id>). The same key returns the reply already created (200, idempotentReplay: true) instead of posting a second one; a key used in another channel is a 409 idempotency_key_reused. A person's key gets 400."),
|
|
145
|
+
alsoInChannel: z.boolean().optional().describe("(#684, a person's reply, only while the workspace's thread view is on) true = \"Also send to #channel\": the ONE reply is also shown in the channel (topLevel reads list it with threadReply.reason alsoInChannel); never a second message or notification. Sent only when true. 409 team_chat_threads_off while the thread view is off (nothing posted); an AI teammate never sets it (its first answer already shows in the channel): refused here with claimToken, 400 from the server otherwise"),
|
|
85
146
|
claimToken: z.string().regex(/^[1-9][0-9]{0,8}:[A-Za-z0-9:_.-]{8,128}$/).optional().describe("Paired AI agent keys only; REQUIRED with idempotencyKey reply:<source message id> (refused otherwise, nothing posted): the listener's claim token <generation>:<listener id> from the turn prompt. The reply completes that turn only when posted in the source's channel and thread with the current generation's token; a reply:<source> reply anywhere else, or with a stale token, is refused 409 (reply_wrong_channel / reply_wrong_thread / reply_stale_claim) with nothing posted, so a corrected retry still completes the turn."),
|
|
86
|
-
}, async ({ channelId, parentId, body, idempotencyKey, claimToken }) => {
|
|
147
|
+
}, async ({ channelId, parentId, body, idempotencyKey, claimToken, alsoInChannel }) => {
|
|
87
148
|
// (#599 r7) a reply to an inbox turn (idempotencyKey reply:<source message id>) always names its claim
|
|
88
149
|
if (!claimToken && typeof idempotencyKey === "string" && /^reply:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(idempotencyKey)) {
|
|
89
150
|
throw new Error("claimToken is required with an idempotencyKey reply:<source message id> (the turn prompt's reply claim token); nothing was posted");
|
|
90
151
|
}
|
|
91
|
-
|
|
92
|
-
|
|
152
|
+
// (#684) the server refuses alsoInChannel from an AI teammate (400): a turn reply (claim token) never sends it
|
|
153
|
+
if (alsoInChannel === true && claimToken)
|
|
154
|
+
throw new Error("alsoInChannel is for a person's reply: an AI teammate's first answer already shows in the channel; nothing was posted");
|
|
155
|
+
const also = alsoInChannel === true;
|
|
156
|
+
let result;
|
|
157
|
+
try {
|
|
158
|
+
result = await client.post(`/api/v1/chat/channels/${channelId}/messages`, { body, parentId, ...(idempotencyKey ? { idempotencyKey } : {}), ...(claimToken ? { claimToken } : {}), ...(also ? { alsoInChannel: true } : {}) });
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
const refused = also ? threadsRefusal(err, { offSentence: `${THREADS_OFF_SENTENCE} Nothing was posted: send it again without alsoInChannel.` }) : null;
|
|
162
|
+
if (refused)
|
|
163
|
+
return refused;
|
|
164
|
+
throw err;
|
|
165
|
+
}
|
|
166
|
+
const note = also ? alsoInChannelNote(result) : null;
|
|
167
|
+
const out = formatResult(result);
|
|
168
|
+
return note ? { ...out, content: [...out.content, { type: "text", text: note }] } : out;
|
|
93
169
|
}));
|
|
94
170
|
strictInput(server.tool("conduyt_react_chat_message", "Add a reaction to a Team Chat message as the API key's user (POST /api/v1/chat/channels/{id}/messages/{messageId}/reactions). The server accepts only its Team Chat reaction set and returns the fully hydrated message. Repeating the same reaction is safe and leaves one reaction from this user. (#802, app #655 c) An AI teammate ENDS its turn with a reaction by passing claimToken (the turn prompt's reply claim token): the reaction then completes that turn (outcome reacted) in the same transaction, so the turn needs no reply. React to the turn's message or one in its thread; when it is another message of the thread, pass the turn's message id as sourceMessageId. Exactly one of a reply, a reaction or conduyt_stay_silent ends a turn: a token that is not the running holder's, or a message outside the turn's thread, is refused 409 reaction_stale_claim, and a turn that already ended (a reply, a silence, another reaction) 409 turn_finished with its outcome; nothing is written on either, do not retry. A person's key sending claimToken gets 400.", {
|
|
95
171
|
channelId: z.string().uuid().describe("Channel UUID"),
|
|
@@ -116,6 +192,43 @@ export function registerChatTools(server, client) {
|
|
|
116
192
|
const result = await client.get(`/api/v1/chat/channels/${channelId}/linked-record`);
|
|
117
193
|
return formatResult(result);
|
|
118
194
|
}));
|
|
195
|
+
strictInput(server.tool("conduyt_get_chat_thread", "(#684, only while the workspace's thread view is on) Read ONE Team Chat thread by its ROOT, the top-level message (GET /api/v1/chat/channels/{id}/messages/{messageId}/thread): { root, replies (oldest first), total (every counted reply), earlierCount, hasEarlier, earlierCursor (send as before for the page below), aiReactions: [{ messageId, agent, emoji, at }] (an AI teammate's turn that ended with a reaction instead of a reply: \"<name> reacted <emoji>. No reply needed.\" under messageId), replying: [{ userId }] (AI teammates whose turn on this thread is running) }. Without before: the LATEST limit replies (default 20, 1 to 50). Counted replies are not deleted, inside the 90-day window and under the caller's deal visibility. A deleted root is served as stored (deletedAt set, body \"Message deleted\"). An AI teammate reads the whole thread it is answering in with this: pass the turn prompt's Channel id and its Reply parent id (the thread's root), then page back with before while hasEarlier. Members only: a 403 'Not a member of this channel' is recovered once like the other reads. Refusals are error results with a plain sentence, never retried: 409 team_chat_threads_off (the thread view is off), 404 (a reply id, an unknown or hidden message, a root past the window with no reply inside it, or a server that predates the thread view), 400 limit / before.", {
|
|
196
|
+
channelId: z.string().uuid().describe("Channel UUID"),
|
|
197
|
+
messageId: z.string().uuid().describe("The thread's ROOT message UUID (a top-level message; for an AI teammate's turn: the Reply parent id)"),
|
|
198
|
+
limit: z.number().int().min(1).max(50).optional().describe("Replies per page, 1-50 (default 20)"),
|
|
199
|
+
before: z.number().int().min(0).max(999_999_999).optional().describe("earlierCursor from the previous page (a reply's seq): the replies below it"),
|
|
200
|
+
}, async ({ channelId, messageId, limit, before }) => {
|
|
201
|
+
const params = new URLSearchParams();
|
|
202
|
+
if (limit !== undefined)
|
|
203
|
+
params.set("limit", String(limit));
|
|
204
|
+
if (before !== undefined)
|
|
205
|
+
params.set("before", String(before));
|
|
206
|
+
try {
|
|
207
|
+
const result = await withMembershipBootstrap(client, () => client.get(`/api/v1/chat/channels/${channelId}/messages/${messageId}/thread?${params}`));
|
|
208
|
+
return formatResult(result);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
const refused = threadsRefusal(err, { offSentence: THREADS_OFF_SENTENCE, notFound: true });
|
|
212
|
+
if (refused)
|
|
213
|
+
return refused;
|
|
214
|
+
throw err;
|
|
215
|
+
}
|
|
216
|
+
}));
|
|
217
|
+
strictInput(server.tool("conduyt_list_chat_thread_files", "(#684, only while the workspace's thread view is on) The files shared in ONE Team Chat thread (GET /api/v1/chat/channels/{id}/messages/{messageId}/files): the root's attachments and every reply's, oldest first, at most 200 per read: { data: [{ id (<message id>:<position>), messageId, name, type, size, url, fileId (the CRM file id, opened through GET /files/{id}/download, or null), sharedBy { id, name, isAgent }, sharedAt }], hasMore (true when the thread holds more than 200) }. A deleted message contributes nothing; only messages inside the 90-day window count. messageId is the ROOT. Members only (a first-use 403 is recovered once). Refusals are error results with a plain sentence, never retried: 409 team_chat_threads_off, 404 (a reply id, an unknown or hidden message, a root past the window with no reply inside it, or a server that predates the thread view).", {
|
|
218
|
+
channelId: z.string().uuid().describe("Channel UUID"),
|
|
219
|
+
messageId: z.string().uuid().describe("The thread's ROOT message UUID"),
|
|
220
|
+
}, async ({ channelId, messageId }) => {
|
|
221
|
+
try {
|
|
222
|
+
const result = await withMembershipBootstrap(client, () => client.get(`/api/v1/chat/channels/${channelId}/messages/${messageId}/files`));
|
|
223
|
+
return formatResult(result);
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
const refused = threadsRefusal(err, { offSentence: THREADS_OFF_SENTENCE, notFound: true });
|
|
227
|
+
if (refused)
|
|
228
|
+
return refused;
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
}));
|
|
119
232
|
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.", {
|
|
120
233
|
channelId: z.string().uuid().describe("Channel UUID"),
|
|
121
234
|
readUpTo: z.string().uuid().describe("UUID of the newest message you have seen in this channel"),
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (#1140 lane A3 r2) What to redact so the Grok Bot routine key never leaves this program: every form the server could take as the
|
|
3
|
+
* key, plus a floor. THIS FILE IS IDENTICAL in conduyt-cli (src/routine-key-redaction.ts) and conduyt-mcp
|
|
4
|
+
* (src/tools/routine-key-redaction.ts), and so is its test (routine-key-redaction.test.ts beside it): the two repos cannot import from
|
|
5
|
+
* each other or from the app, so the same table runs against each copy and a drift fails a test. Findings it answers: Codex
|
|
6
|
+
* grokA3-cli-r1 P2 2 and grokA3-mcp-r1 P1.
|
|
7
|
+
*/
|
|
8
|
+
/** replaces every appearance of each secret (4 characters or more) in a text about to be printed or returned */
|
|
9
|
+
export declare function hide(text: string, secrets: readonly string[]): string;
|
|
10
|
+
/** :157-163 normalizeRoutineKey (the key field, and every candidate of a block at :191) */
|
|
11
|
+
export declare function serverNormalizeKey(raw: string): string | null;
|
|
12
|
+
/** :176-198 parseRoutineBlock's key extraction: the text without its addresses (:184), then crsr_ tokens (:186), the token after
|
|
13
|
+
* "Bearer " (:187), the token after a line-leading "key:" / "key =" (:188), each with trailing punctuation cut and normalized (:191).
|
|
14
|
+
* Returns EVERY normalized candidate (the server takes exactly one; all of them are hidden). */
|
|
15
|
+
export declare function serverKeyCandidates(text: string): string[];
|
|
16
|
+
/** the FLOOR: every whitespace-separated token of the input as typed (not an address) of 8 characters or more, in each shape a strip
|
|
17
|
+
* of surrounding quotes or brackets, a leading label (key: / key= / Authorization: / Bearer) or trailing punctuation gives it */
|
|
18
|
+
export declare function rawKeyTokens(text: string): string[];
|
|
19
|
+
/** what to hide for a pasted block (the server's candidates, then the floor) */
|
|
20
|
+
export declare function secretsInPaste(text: string): string[];
|
|
21
|
+
/** what to hide for a key given on its own (:213 normalizes the key field; :210 reads an address-alone body as a block) */
|
|
22
|
+
export declare function secretsOfKey(raw: string): string[];
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* (#1140 lane A3 r2) What to redact so the Grok Bot routine key never leaves this program: every form the server could take as the
|
|
3
|
+
* key, plus a floor. THIS FILE IS IDENTICAL in conduyt-cli (src/routine-key-redaction.ts) and conduyt-mcp
|
|
4
|
+
* (src/tools/routine-key-redaction.ts), and so is its test (routine-key-redaction.test.ts beside it): the two repos cannot import from
|
|
5
|
+
* each other or from the app, so the same table runs against each copy and a drift fails a test. Findings it answers: Codex
|
|
6
|
+
* grokA3-cli-r1 P2 2 and grokA3-mcp-r1 P1.
|
|
7
|
+
*/
|
|
8
|
+
/** replaces every appearance of each secret (4 characters or more) in a text about to be printed or returned */
|
|
9
|
+
export function hide(text, secrets) {
|
|
10
|
+
let out = text;
|
|
11
|
+
// (r2) longest first: a shorter secret inside a longer one never leaves the longer one's tail behind
|
|
12
|
+
for (const s of [...secrets].sort((a, b) => b.length - a.length))
|
|
13
|
+
if (s && s.length >= 4)
|
|
14
|
+
out = out.split(s).join("[hidden]");
|
|
15
|
+
return out;
|
|
16
|
+
}
|
|
17
|
+
// A PORT of the server's extraction, conduyt-app lane A2b 0c0088508 lib/agent-webhook-connect.ts, rule for rule and in its order.
|
|
18
|
+
// It is used ONLY to know what to hide, never to decide what is sent (the server parses the paste itself). Where the port and the
|
|
19
|
+
// server could ever disagree, the floor (rawKeyTokens) still hides the key: every token of the input as typed, in every shape a
|
|
20
|
+
// strip of quotes, labels and trailing punctuation can give it.
|
|
21
|
+
/** lib/agent-webhook-connect.ts:146 KEY_RE */
|
|
22
|
+
const SERVER_KEY_RE = /^[A-Za-z0-9._~+/=-]{8,512}$/;
|
|
23
|
+
/** :147 URL_IN_TEXT_RE */
|
|
24
|
+
const SERVER_URL_IN_TEXT_RE = /https?:\/\/[^\s"'`<>]+/gi;
|
|
25
|
+
/** :148 TRAILING_PUNCT_RE */
|
|
26
|
+
const SERVER_TRAILING_PUNCT_RE = /[.,;:!?)\]}]+$/;
|
|
27
|
+
/** :150-154 unquote */
|
|
28
|
+
function serverUnquote(raw) {
|
|
29
|
+
let out = raw.trim();
|
|
30
|
+
while (out.length >= 2 && /^["'`<]/.test(out) && /["'`>]$/.test(out))
|
|
31
|
+
out = out.slice(1, -1).trim();
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/** :157-163 normalizeRoutineKey (the key field, and every candidate of a block at :191) */
|
|
35
|
+
export function serverNormalizeKey(raw) {
|
|
36
|
+
let s = serverUnquote(raw);
|
|
37
|
+
s = s.replace(/^authorization\s*:\s*/i, "");
|
|
38
|
+
s = s.replace(/^bearer\s+/i, "");
|
|
39
|
+
s = serverUnquote(s);
|
|
40
|
+
return SERVER_KEY_RE.test(s) ? s : null;
|
|
41
|
+
}
|
|
42
|
+
/** :176-198 parseRoutineBlock's key extraction: the text without its addresses (:184), then crsr_ tokens (:186), the token after
|
|
43
|
+
* "Bearer " (:187), the token after a line-leading "key:" / "key =" (:188), each with trailing punctuation cut and normalized (:191).
|
|
44
|
+
* Returns EVERY normalized candidate (the server takes exactly one; all of them are hidden). */
|
|
45
|
+
export function serverKeyCandidates(text) {
|
|
46
|
+
const rest = text.replace(SERVER_URL_IN_TEXT_RE, " ");
|
|
47
|
+
const found = [];
|
|
48
|
+
for (const m of rest.matchAll(/(?:^|[^A-Za-z0-9_])(crsr_[A-Za-z0-9_-]+)/g))
|
|
49
|
+
found.push(m[1]);
|
|
50
|
+
for (const m of rest.matchAll(/\bbearer\s+([^\s"'`]+)/gi))
|
|
51
|
+
found.push(m[1]);
|
|
52
|
+
for (const m of rest.matchAll(/^[ \t]*(?:the\s+)?key\s*[:=][ \t]*["'`]?([^\s"'`]+)/gim))
|
|
53
|
+
found.push(m[1]);
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const c of found) {
|
|
56
|
+
const k = serverNormalizeKey(c.replace(SERVER_TRAILING_PUNCT_RE, ""));
|
|
57
|
+
if (k)
|
|
58
|
+
out.push(k);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** the FLOOR: every whitespace-separated token of the input as typed (not an address) of 8 characters or more, in each shape a strip
|
|
63
|
+
* of surrounding quotes or brackets, a leading label (key: / key= / Authorization: / Bearer) or trailing punctuation gives it */
|
|
64
|
+
export function rawKeyTokens(text) {
|
|
65
|
+
const out = new Set();
|
|
66
|
+
const add = (t) => { if (t.length >= 8 && !/^https?:\/\//i.test(t))
|
|
67
|
+
out.add(t); };
|
|
68
|
+
for (const tok of text.split(/\s+/)) {
|
|
69
|
+
const shapes = [tok];
|
|
70
|
+
for (let i = 0; i < 3; i++) {
|
|
71
|
+
for (const t of [...shapes]) {
|
|
72
|
+
shapes.push(t.replace(/^["'`<(\[{]+|["'`>)\]}]+$/g, ""), t.replace(SERVER_TRAILING_PUNCT_RE, ""), t.replace(/^(?:key\s*[:=]|authorization\s*:|bearer\s*:?)/i, ""));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
for (const t of shapes)
|
|
76
|
+
add(t);
|
|
77
|
+
}
|
|
78
|
+
return [...out];
|
|
79
|
+
}
|
|
80
|
+
/** what to hide for a pasted block (the server's candidates, then the floor) */
|
|
81
|
+
export function secretsInPaste(text) {
|
|
82
|
+
return [...new Set([...serverKeyCandidates(text), ...rawKeyTokens(text)])];
|
|
83
|
+
}
|
|
84
|
+
/** what to hide for a key given on its own (:213 normalizes the key field; :210 reads an address-alone body as a block) */
|
|
85
|
+
export function secretsOfKey(raw) {
|
|
86
|
+
const n = serverNormalizeKey(raw);
|
|
87
|
+
return [...new Set([raw, raw.trim(), ...(n ? [n] : []), ...rawKeyTokens(raw)])].filter((s) => s.length >= 4);
|
|
88
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-11-
|
|
3
|
-
"toolCount":
|
|
4
|
-
"totalEndpoints":
|
|
2
|
+
"generatedAt": "2026-11-06T18:54:56.539Z",
|
|
3
|
+
"toolCount": 392,
|
|
4
|
+
"totalEndpoints": 1160,
|
|
5
5
|
"counts": {
|
|
6
|
-
"covered":
|
|
7
|
-
"excluded":
|
|
6
|
+
"covered": 346,
|
|
7
|
+
"excluded": 356,
|
|
8
8
|
"planned": 458,
|
|
9
9
|
"uncategorized": 0
|
|
10
10
|
},
|
|
@@ -1327,10 +1327,40 @@
|
|
|
1327
1327
|
"status": "excluded",
|
|
1328
1328
|
"reason": "(#599) session-only (assertSessionOnly): an admin manages AI teammates in Settings > AI; every API key is refused"
|
|
1329
1329
|
},
|
|
1330
|
+
"POST /api/v1/chat/agents/:id/connect": {
|
|
1331
|
+
"status": "covered",
|
|
1332
|
+
"tools": [
|
|
1333
|
+
"conduyt_connect_ai_teammate"
|
|
1334
|
+
]
|
|
1335
|
+
},
|
|
1330
1336
|
"POST /api/v1/chat/agents/:id/pair-code": {
|
|
1331
1337
|
"status": "excluded",
|
|
1332
1338
|
"reason": "(#599) session-only (assertSessionOnly): an admin manages AI teammates in Settings > AI; every API key is refused"
|
|
1333
1339
|
},
|
|
1340
|
+
"GET /api/v1/chat/agents/:id/webhook": {
|
|
1341
|
+
"status": "covered",
|
|
1342
|
+
"tools": [
|
|
1343
|
+
"conduyt_get_ai_teammate_webhook"
|
|
1344
|
+
]
|
|
1345
|
+
},
|
|
1346
|
+
"PUT /api/v1/chat/agents/:id/webhook": {
|
|
1347
|
+
"status": "covered",
|
|
1348
|
+
"tools": [
|
|
1349
|
+
"conduyt_set_ai_teammate_webhook"
|
|
1350
|
+
]
|
|
1351
|
+
},
|
|
1352
|
+
"DELETE /api/v1/chat/agents/:id/webhook": {
|
|
1353
|
+
"status": "covered",
|
|
1354
|
+
"tools": [
|
|
1355
|
+
"conduyt_disconnect_ai_teammate"
|
|
1356
|
+
]
|
|
1357
|
+
},
|
|
1358
|
+
"POST /api/v1/chat/agents/:id/webhook/test": {
|
|
1359
|
+
"status": "covered",
|
|
1360
|
+
"tools": [
|
|
1361
|
+
"conduyt_test_ai_teammate_webhook"
|
|
1362
|
+
]
|
|
1363
|
+
},
|
|
1334
1364
|
"POST /api/v1/chat/agents/me/heartbeat": {
|
|
1335
1365
|
"status": "excluded",
|
|
1336
1366
|
"reason": "(#599) the listener protocol of a paired agent key (long poll, per-event ack, heartbeat): served by the npm CLI `conduyt agent listen`, not a request/response MCP tool"
|
|
@@ -1349,6 +1379,18 @@
|
|
|
1349
1379
|
"conduyt_stay_silent"
|
|
1350
1380
|
]
|
|
1351
1381
|
},
|
|
1382
|
+
"GET /api/v1/chat/agents/me/mcp": {
|
|
1383
|
+
"status": "excluded",
|
|
1384
|
+
"reason": "(#599) the listener protocol of a paired agent key (long poll, per-event ack, heartbeat): served by the npm CLI `conduyt agent listen`, not a request/response MCP tool"
|
|
1385
|
+
},
|
|
1386
|
+
"POST /api/v1/chat/agents/me/mcp": {
|
|
1387
|
+
"status": "excluded",
|
|
1388
|
+
"reason": "(#599) the listener protocol of a paired agent key (long poll, per-event ack, heartbeat): served by the npm CLI `conduyt agent listen`, not a request/response MCP tool"
|
|
1389
|
+
},
|
|
1390
|
+
"DELETE /api/v1/chat/agents/me/mcp": {
|
|
1391
|
+
"status": "excluded",
|
|
1392
|
+
"reason": "(#599) the listener protocol of a paired agent key (long poll, per-event ack, heartbeat): served by the npm CLI `conduyt agent listen`, not a request/response MCP tool"
|
|
1393
|
+
},
|
|
1352
1394
|
"POST /api/v1/chat/agents/pair": {
|
|
1353
1395
|
"status": "excluded",
|
|
1354
1396
|
"reason": "(#599) the single-use pair-code exchange that MINTS the agent's key: the listener's first call (npm CLI `conduyt agent pair` / `listen --pair-code`), never made with an existing key"
|
|
@@ -1419,6 +1461,12 @@
|
|
|
1419
1461
|
"status": "planned",
|
|
1420
1462
|
"reason": "team chat read/post for agents"
|
|
1421
1463
|
},
|
|
1464
|
+
"GET /api/v1/chat/channels/:id/messages/:messageId/files": {
|
|
1465
|
+
"status": "covered",
|
|
1466
|
+
"tools": [
|
|
1467
|
+
"conduyt_list_chat_thread_files"
|
|
1468
|
+
]
|
|
1469
|
+
},
|
|
1422
1470
|
"POST /api/v1/chat/channels/:id/messages/:messageId/handoff": {
|
|
1423
1471
|
"status": "planned",
|
|
1424
1472
|
"reason": "team chat read/post for agents"
|
|
@@ -1437,6 +1485,12 @@
|
|
|
1437
1485
|
"status": "planned",
|
|
1438
1486
|
"reason": "team chat read/post for agents"
|
|
1439
1487
|
},
|
|
1488
|
+
"GET /api/v1/chat/channels/:id/messages/:messageId/thread": {
|
|
1489
|
+
"status": "covered",
|
|
1490
|
+
"tools": [
|
|
1491
|
+
"conduyt_get_chat_thread"
|
|
1492
|
+
]
|
|
1493
|
+
},
|
|
1440
1494
|
"GET /api/v1/chat/channels/:id/notifications": {
|
|
1441
1495
|
"status": "covered",
|
|
1442
1496
|
"tools": [
|