@bli-cockpit/mcp 0.1.83 → 0.1.84

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
@@ -109,7 +109,7 @@ about what an agent can reach.
109
109
 
110
110
  <!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->
111
111
 
112
- **123 of 128 Tower verbs have an MCP twin.**
112
+ **124 of 129 Tower verbs have an MCP twin.**
113
113
  Each tool goes through the SAME door its CLI verb calls, with the same
114
114
  collector device token — never a second route and never a service-role
115
115
  reader. `src/verb-census.test.ts` fails when a verb is in none of the
@@ -172,6 +172,7 @@ three tables below.
172
172
  | `cockpit mail accounts` | `mail_accounts` | `GET /api/mail/accounts` |
173
173
  | `cockpit mail attachment` | `mail_attachment` | `GET /api/mail/attachments/[id]?meta=1` |
174
174
  | `cockpit mail detach` | `mail_detach` | `DELETE /api/mail/accounts` |
175
+ | `cockpit mail draft` | `mail_draft` | `POST /api/mail/drafts` |
175
176
  | `cockpit mail inbox` | `mail_inbox` | `GET /api/mail/inbox` |
176
177
  | `cockpit mail read` | `mail_read` | `GET /api/mail/threads/[id]` |
177
178
  | `cockpit mail search` | `mail_search` | `GET /api/mail/search` |
@@ -6,7 +6,9 @@
6
6
  * The point, in one sentence: an agent working on somebody's behalf should be
7
7
  * able to READ the mail that explains a ticket — the invoice, the vendor's
8
8
  * reply, the bank notice — and send a reply, without a browser and without a
9
- * second copy of anybody's credentials.
9
+ * second copy of anybody's credentials. Or, when the person should be the one
10
+ * to press send, save the reply as a DRAFT in their own mailbox (`mail_draft`,
11
+ * BLI-12530): it reaches nobody, so unlike `mail_send` it needs no `confirm`.
10
12
  *
11
13
  * TWO DELIBERATE ASYMMETRIES with the CLI, both recorded in `verb-census.ts`:
12
14
  *
@@ -6,7 +6,9 @@
6
6
  * The point, in one sentence: an agent working on somebody's behalf should be
7
7
  * able to READ the mail that explains a ticket — the invoice, the vendor's
8
8
  * reply, the bank notice — and send a reply, without a browser and without a
9
- * second copy of anybody's credentials.
9
+ * second copy of anybody's credentials. Or, when the person should be the one
10
+ * to press send, save the reply as a DRAFT in their own mailbox (`mail_draft`,
11
+ * BLI-12530): it reaches nobody, so unlike `mail_send` it needs no `confirm`.
10
12
  *
11
13
  * TWO DELIBERATE ASYMMETRIES with the CLI, both recorded in `verb-census.ts`:
12
14
  *
@@ -32,6 +34,23 @@ function messageLine(message) {
32
34
  return `${message.is_unread ? "*" : " "} ${message.internal_date.slice(0, 16)} ${from} ${message.subject ?? "(no subject)"} [thread ${message.thread_id ?? "?"}]`;
33
35
  }
34
36
  const ADDRESS = EMAIL_INPUT.describe("An email address.");
37
+ /**
38
+ * The JSON `mail_send` and `mail_draft` post. One shape, because the two doors
39
+ * take the same fields: a message that can be sent can be saved unsent, and a
40
+ * field added to one cannot be forgotten by the other.
41
+ */
42
+ function composedBody(args) {
43
+ return {
44
+ account_id: args.account_id,
45
+ to: args.to.map((address) => ({ address })),
46
+ ...(Array.isArray(args.cc) && args.cc.length > 0
47
+ ? { cc: args.cc.map((address) => ({ address })) }
48
+ : {}),
49
+ subject: args.subject ?? "",
50
+ text: args.text,
51
+ ...(args.in_reply_to_message_id ? { in_reply_to_message_id: args.in_reply_to_message_id } : {}),
52
+ };
53
+ }
35
54
  export function registerMailTools(server, deps) {
36
55
  const register = registrarFor(server);
37
56
  register("mail_accounts", {
@@ -186,21 +205,42 @@ export function registerMailTools(server, deps) {
186
205
  const refusal = unconfirmed(args, "Sending mail reaches real people and cannot be recalled.");
187
206
  if (refusal)
188
207
  return refusal;
189
- const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/mail/send", {
190
- account_id: args.account_id,
191
- to: args.to.map((address) => ({ address })),
192
- ...(Array.isArray(args.cc) && args.cc.length > 0
193
- ? { cc: args.cc.map((address) => ({ address })) }
194
- : {}),
195
- subject: args.subject ?? "",
196
- text: args.text,
197
- ...(args.in_reply_to_message_id ? { in_reply_to_message_id: args.in_reply_to_message_id } : {}),
198
- });
208
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/mail/send", composedBody(args));
199
209
  if (!response.ok)
200
210
  return errorResult(doorFailureText("mail_send", response));
201
211
  const sent = response.body.sent;
202
212
  return textResult(`Sent from ${sent?.address ?? "that mailbox"} to ${args.to.join(", ")}${sent?.threaded ? " (as a reply)" : ""}.`, { sent });
203
213
  }));
214
+ register("mail_draft", {
215
+ title: "Save a draft in one mailbox",
216
+ description: "Saves a message as a DRAFT in one of YOUR mailboxes, for a person to read, edit and send themselves. Use it "
217
+ + "when you are preparing mail on somebody's behalf and they should be the one to press send. NOTHING IS SENT and "
218
+ + "nobody is notified, so unlike mail_send there is no confirm flag. `account_id` is REQUIRED and is the mailbox the "
219
+ + "draft is saved in (mail_accounts) — with several attached, that is never something to guess. "
220
+ + "`in_reply_to_message_id` (a message id from mail_read) threads the draft into that conversation the way mail_send "
221
+ + "threads a reply; Gmail files it there only when the subject matches the original's, and the answer says whether it "
222
+ + "did (`threaded`). Only the mailbox's OWNER can draft into it, and only Gmail mailboxes can take one: an IMAP "
223
+ + "mailbox is refused as imap_drafts_unsupported. Answers the provider draft id, the thread id and the account id.",
224
+ inputSchema: {
225
+ account_id: z.string().uuid().describe("The mailbox the draft is saved in (mail_accounts). Never guessed."),
226
+ to: z.array(ADDRESS).min(1).max(50).describe("Recipient email addresses, so the draft is ready to send as it is."),
227
+ cc: z.array(ADDRESS).max(50).optional().describe("Copied email addresses."),
228
+ subject: z.string().max(998).describe("The subject line. For a reply, keep the original's subject or Gmail will not thread it."),
229
+ text: z.string().min(1).max(500_000).describe("The plain-text body."),
230
+ in_reply_to_message_id: z.string().uuid().optional().describe("A message id from mail_read: save this as a reply in that thread."),
231
+ },
232
+ }, async (args) => withSession(deps, async (session) => {
233
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/mail/drafts", composedBody(args));
234
+ if (!response.ok)
235
+ return errorResult(doorFailureText("mail_draft", response));
236
+ const draft = response.body.draft;
237
+ // Both halves carry the ids (BLI-10432): a client that hands the model only
238
+ // `content`, or only `structuredContent`, must still learn where the draft is.
239
+ return textResult(`Saved a draft in ${draft?.address ?? "that mailbox"} for ${args.to.join(", ")}`
240
+ + `${draft?.threaded ? " (in the reply's thread)" : args.in_reply_to_message_id ? " (Gmail did NOT file it in that thread: its subject has to match the original's)" : ""}. `
241
+ + "Nothing was sent: it waits in Drafts for a person to read, edit and send. "
242
+ + `draft_id=${draft?.provider_draft_id ?? "(none returned)"} thread_id=${draft?.provider_thread_id ?? "(none)"} account_id=${draft?.account_id ?? String(args.account_id)}`, { draft });
243
+ }));
204
244
  register("mail_attachment", {
205
245
  title: "Describe an attachment",
206
246
  description: "The filename, mime type and size of one attachment. Attachments are POINTERS in Tower — nothing is stored — and "
@@ -21,7 +21,7 @@ export declare const DRY_RUN_TOOLS: readonly ["ops_recompile", "memory_requeue"]
21
21
  * candidates, share calendars. Never called by the smoke; covered by the unit
22
22
  * suites (`write-tools.test.ts` and each family's own).
23
23
  */
24
- export declare const WRITE_TOOLS: readonly ["emit_event", "docs_create", "docs_update", "docs_delete", "docs_link", "msg_create_channel", "msg_dm", "msg_send", "work_create_issue", "work_update_issue", "work_move_issue", "work_comment_issue", "careers_rescreen", "careers_send_takehome", "careers_invite", "careers_grade", "careers_decide", "careers_followup", "careers_interview", "careers_takehome_set", "careers_settings_set", "rename_resolve", "github_merge", "github_retitle", "github_rerun", "mail_send", "mail_sync", "mail_detach", "cal_create", "cal_share", "cal_sync", "cal_detach", "cal_delete", "jarvis_dispatch", "notes_mkdir", "notes_rmdir", "notes_rename", "notes_upload", "notes_paste", "notes_share", "notes_unshare", "notes_move", "notes_sync", "notes_detach", "memory_experience", "scout_start", "scout_dismiss", "scout_undo", "brief_rewrite", "brief_correct", "brief_done", "brief_undone", "settings_set", "settings_delete", "model_set", "team_invite", "team_role", "team_device_revoke"];
24
+ export declare const WRITE_TOOLS: readonly ["emit_event", "docs_create", "docs_update", "docs_delete", "docs_link", "msg_create_channel", "msg_dm", "msg_send", "work_create_issue", "work_update_issue", "work_move_issue", "work_comment_issue", "careers_rescreen", "careers_send_takehome", "careers_invite", "careers_grade", "careers_decide", "careers_followup", "careers_interview", "careers_takehome_set", "careers_settings_set", "rename_resolve", "github_merge", "github_retitle", "github_rerun", "mail_send", "mail_draft", "mail_sync", "mail_detach", "cal_create", "cal_share", "cal_sync", "cal_detach", "cal_delete", "jarvis_dispatch", "notes_mkdir", "notes_rmdir", "notes_rename", "notes_upload", "notes_paste", "notes_share", "notes_unshare", "notes_move", "notes_sync", "notes_detach", "memory_experience", "scout_start", "scout_dismiss", "scout_undo", "brief_rewrite", "brief_correct", "brief_done", "brief_undone", "settings_set", "settings_delete", "model_set", "team_invite", "team_role", "team_device_revoke"];
25
25
  /** Reads that spend a model turn. The smoke's `--no-model` leaves these out. */
26
26
  export declare const MODEL_SPENDING_TOOLS: readonly ["jarvis_ask", "jarvis_check"];
27
27
  /**
@@ -116,6 +116,8 @@ export const WRITE_TOOLS = [
116
116
  "github_retitle",
117
117
  "github_rerun",
118
118
  "mail_send",
119
+ // BLI-12530: writes into a mailbox (a draft), reaches nobody. Never smoked.
120
+ "mail_draft",
119
121
  "mail_sync",
120
122
  "mail_detach",
121
123
  "cal_create",
@@ -268,6 +268,8 @@ export const MCP_TWINS = {
268
268
  "mail read": { tool: "mail_read", door: "GET /api/mail/threads/[id]" },
269
269
  "mail search": { tool: "mail_search", door: "GET /api/mail/search" },
270
270
  "mail send": { tool: "mail_send", door: "POST /api/mail/send" },
271
+ // BLI-12530: the same message, saved unsent in the owner's own mailbox.
272
+ "mail draft": { tool: "mail_draft", door: "POST /api/mail/drafts" },
271
273
  "mail attachment": { tool: "mail_attachment", door: "GET /api/mail/attachments/[id]?meta=1" },
272
274
  "mail sync": { tool: "mail_sync", door: "POST /api/mail/accounts/[id]/sync" },
273
275
  "mail sync-status": { tool: "mail_sync_status", door: "GET /api/mail/sync-status" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.83",
3
+ "version": "0.1.84",
4
4
  "private": false,
5
5
  "description": "bli-tower: an MCP server over BLI Cockpit's agent doors: JARVIS (jarvis_*), documents (docs_*), channels (msg_*), issues (work_*), the daily page (brief_*), meeting notes (notes_*), the ops board (ops_status/slack_*), settings/team/model, Scout and the workbook, plus the legacy event-stream tools (emit_event, get_ticket_timeline, get_active_tickets).",
6
6
  "type": "module",