@bli-cockpit/mcp 0.1.4 → 0.1.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 CHANGED
@@ -34,7 +34,7 @@ about what an agent can reach.
34
34
 
35
35
  <!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->
36
36
 
37
- **56 of 57 Tower verbs have an MCP twin.**
37
+ **64 of 66 Tower verbs have an MCP twin.**
38
38
  Each tool goes through the SAME door its CLI verb calls, with the same
39
39
  collector device token — never a second route and never a service-role
40
40
  reader. `src/verb-census.test.ts` fails when a verb is in none of the
@@ -61,6 +61,14 @@ three tables below.
61
61
  | `cockpit issue update` | `work_update_issue` | `PATCH /api/work/issues/[id]` |
62
62
  | `cockpit jarvis` | `jarvis_ask` | `POST /api/jarvis/cli` |
63
63
  | `cockpit jarvis --trace` | `jarvis_trace` | `GET /api/ops/trace/[id]` |
64
+ | `cockpit mail accounts` | `mail_accounts` | `GET /api/mail/accounts` |
65
+ | `cockpit mail attachment` | `mail_attachment` | `GET /api/mail/attachments/[id]?meta=1` |
66
+ | `cockpit mail detach` | `mail_detach` | `DELETE /api/mail/accounts` |
67
+ | `cockpit mail inbox` | `mail_inbox` | `GET /api/mail/inbox` |
68
+ | `cockpit mail read` | `mail_read` | `GET /api/mail/threads/[id]` |
69
+ | `cockpit mail search` | `mail_search` | `GET /api/mail/search` |
70
+ | `cockpit mail send` | `mail_send` | `POST /api/mail/send` |
71
+ | `cockpit mail sync` | `mail_sync` | `POST /api/mail/accounts/[id]/sync` |
64
72
  | `cockpit model set` | `model_set` | `POST /api/settings/jarvis-model` |
65
73
  | `cockpit model show` | `model_show` | `GET /api/settings/jarvis-model` |
66
74
  | `cockpit msg channels` | `msg_channels` | `GET /api/msg/channels` |
@@ -112,6 +120,7 @@ A claim about the verb's nature, not a backlog.
112
120
  | CLI verb | Why it can never have a twin |
113
121
  | --- | --- |
114
122
  | `cockpit brief edit` | opens the person's own $EDITOR on this machine and files what they changed; an agent has no editor to open (commands/editor.ts) |
123
+ | `cockpit mail add-imap` | carries a Google app password. The CLI reads it from STDIN precisely so it never lands in an argument list; an MCP tool argument travels through a model's context window and whatever transcript store sits behind it, so attaching a mailbox stays a thing a person does at a terminal (BLI-3708) |
115
124
 
116
125
  <!-- END GENERATED verb census -->
117
126
 
@@ -127,6 +136,14 @@ JARVIS itself — the same assistant Tower web chat, the Slack DM and
127
136
  | `jarvis_dispatch` | `POST /api/jarvis/cli` | The coding arm (BLI-2981). Two calls: once for the plan, once with the person's approval code. |
128
137
  | `jarvis_check` | `POST /api/jarvis/cli` | Where a dispatched coding task got to. Spends no approval. |
129
138
 
139
+ **One conversation per call (BLI-3786).** A thread is single-writer: two turns
140
+ in the same conversation at the same time read each other's messages, and one
141
+ can answer the other's question. So every `jarvis_*` tool sends NO thread
142
+ unless the caller named one, the door mints a conversation for that call alone,
143
+ and the answer's `thread_id` is the name it used — pass that back as `thread`
144
+ to continue deliberately. `"main"` is the shared terminal default nobody chose
145
+ and is never sent from here.
146
+
130
147
  `jarvis_ask` answers with the SAME object `cockpit jarvis --json` prints —
131
148
  `answer`, `sources`, `turn_id`, `thread_id`, `trace_thread_id`, `degraded`,
132
149
  `degraded_reasons` — so a script and an agent read one contract. A worked
@@ -136,7 +153,7 @@ example:
136
153
  jarvis_ask { "question": "what did Brandon ship yesterday?" }
137
154
  -> { "answer": "Two pull requests went in…\n\nSource: BLI-3654 — …",
138
155
  "sources": ["Source: BLI-3654 — …"],
139
- "turn_id": "0f3c…", "thread_id": "main",
156
+ "turn_id": "0f3c…", "thread_id": "agent-6b1e…",
140
157
  "degraded": false, "degraded_reasons": [] }
141
158
 
142
159
  jarvis_trace { "turn_id": "0f3c…" } # or "last"
@@ -24,6 +24,15 @@
24
24
  * `jarvis_ask` answers with `jarvis-answer-envelope.ts` — the same object
25
25
  * `cockpit jarvis --json` prints. A script and an agent read one shape.
26
26
  *
27
+ * ## One conversation per call (BLI-3786)
28
+ *
29
+ * A thread is single-writer: two turns in one conversation at the same time
30
+ * read each other's messages, and one can answer the other's question. Every
31
+ * tool here therefore sends NO thread unless the caller named one, and the
32
+ * door mints a conversation for that call alone and reports it as `thread_id`.
33
+ * Passing that id back is how an agent continues deliberately. `"main"` is the
34
+ * shared terminal default nobody chose and is never sent from here.
35
+ *
27
36
  * ## The approval code, and what this surface can and cannot prove
28
37
  *
29
38
  * The coding arm's gate (BLI-2981) has two locks: the code is an HMAC over the
@@ -24,6 +24,15 @@
24
24
  * `jarvis_ask` answers with `jarvis-answer-envelope.ts` — the same object
25
25
  * `cockpit jarvis --json` prints. A script and an agent read one shape.
26
26
  *
27
+ * ## One conversation per call (BLI-3786)
28
+ *
29
+ * A thread is single-writer: two turns in one conversation at the same time
30
+ * read each other's messages, and one can answer the other's question. Every
31
+ * tool here therefore sends NO thread unless the caller named one, and the
32
+ * door mints a conversation for that call alone and reports it as `thread_id`.
33
+ * Passing that id back is how an agent continues deliberately. `"main"` is the
34
+ * shared terminal default nobody chose and is never sent from here.
35
+ *
27
36
  * ## The approval code, and what this surface can and cannot prove
28
37
  *
29
38
  * The coding arm's gate (BLI-2981) has two locks: the code is an HMAC over the
@@ -70,10 +79,12 @@ export function registerJarvisTools(server, deps) {
70
79
  title: "Ask JARVIS",
71
80
  description: "Asks JARVIS one question and returns its answer, the Source: lines behind it, this turn's "
72
81
  + "turn_id and the conversation's thread_id. Same JARVIS, same tool belt and same evidence "
73
- + "rules as Tower web chat, the Slack DM and `cockpit jarvis`. Pass thread to continue an "
74
- + "earlier conversation; pass the returned turn_id to jarvis_trace to see how the answer was "
75
- + "reached. JARVIS speaks as the person this machine is paired to and cannot be made to "
76
- + "speak as anyone else.",
82
+ + "rules as Tower web chat, the Slack DM and `cockpit jarvis`. A thread is SINGLE-WRITER: "
83
+ + "omit thread and this turn gets a FRESH conversation of its own, so two questions asked at "
84
+ + "the same time can never read each other's; pass the thread_id the answer returned to "
85
+ + "continue that conversation deliberately. Pass the returned turn_id to jarvis_trace to see "
86
+ + "how the answer was reached. JARVIS speaks as the person this machine is paired to and "
87
+ + "cannot be made to speak as anyone else.",
77
88
  inputSchema: {
78
89
  question: z.string().min(1).max(50_000).describe("What to ask, in plain words."),
79
90
  thread: z
@@ -81,7 +92,9 @@ export function registerJarvisTools(server, deps) {
81
92
  .min(1)
82
93
  .max(200)
83
94
  .optional()
84
- .describe('The conversation to continue. Omit for "main", the default terminal thread.'),
95
+ .describe("The thread_id of a conversation to continue, exactly as an earlier answer returned "
96
+ + 'it. Omit for a fresh conversation for this call alone. "main" is the shared '
97
+ + "default nobody chose, so it is treated as naming nothing here."),
85
98
  subject: z
86
99
  .string()
87
100
  .min(1)
@@ -105,7 +118,10 @@ export function registerJarvisTools(server, deps) {
105
118
  }, async (args) => withSession(deps, async (session) => {
106
119
  const { result } = await takeTurn(deps, session, "jarvis_ask", {
107
120
  question: String(args.question ?? ""),
108
- thread: typeof args.thread === "string" ? args.thread : "main",
121
+ // BLI-3786: no thread means no thread. The door mints one for this
122
+ // call and reports it as thread_id; sending "main" here would put
123
+ // every agent on one conversation, which is the bug this replaced.
124
+ ...(typeof args.thread === "string" ? { thread: args.thread } : {}),
109
125
  ...(typeof args.subject === "string" ? { subject: args.subject } : {}),
110
126
  ...(typeof args.date === "string" ? { date: args.date } : {}),
111
127
  ...(typeof args.model === "string" ? { model: args.model } : {}),
@@ -201,7 +217,13 @@ export function registerJarvisTools(server, deps) {
201
217
  .optional()
202
218
  .describe("The 8-character hex confirmation code THE PERSON fetched for this proposal and handed "
203
219
  + "to you. Omit it on the first call — that call is never given one."),
204
- thread: z.string().min(1).max(200).optional().describe('Defaults to "main".'),
220
+ thread: z
221
+ .string()
222
+ .min(1)
223
+ .max(200)
224
+ .optional()
225
+ .describe("The thread_id of a conversation to continue. Omit for a fresh one — the approval "
226
+ + "code is bound to the PLAN, not to a conversation, so a dispatch needs no thread."),
205
227
  },
206
228
  }, async (args) => withSession(deps, async (session) => {
207
229
  const log = deps.log ?? defaultLog;
@@ -231,7 +253,7 @@ export function registerJarvisTools(server, deps) {
231
253
  ? ["", `The person approved this plan. Confirmation code: ${code.toLowerCase()}`]
232
254
  : []),
233
255
  ].join("\n");
234
- const { result } = await takeTurn(deps, session, "jarvis_dispatch", { question, thread: typeof args.thread === "string" ? args.thread : "main" }, remember);
256
+ const { result } = await takeTurn(deps, session, "jarvis_dispatch", { question, ...(typeof args.thread === "string" ? { thread: args.thread } : {}) }, remember);
235
257
  return result;
236
258
  }));
237
259
  register("jarvis_check", {
@@ -246,14 +268,19 @@ export function registerJarvisTools(server, deps) {
246
268
  .max(200)
247
269
  .optional()
248
270
  .describe("The task id JARVIS named when it dispatched. Omit to ask about the recent ones."),
249
- thread: z.string().min(1).max(200).optional().describe('Defaults to "main".'),
271
+ thread: z
272
+ .string()
273
+ .min(1)
274
+ .max(200)
275
+ .optional()
276
+ .describe("The thread_id of a conversation to continue. Omit for a fresh one."),
250
277
  },
251
278
  }, async (args) => withSession(deps, async (session) => {
252
279
  const task = typeof args.task === "string" ? args.task.trim() : "";
253
280
  const question = task.length > 0
254
281
  ? `Check the coding task ${task} and tell me its status, its branch and its pull request if it has one.`
255
282
  : "Check the coding tasks dispatched for me recently and tell me the status of each.";
256
- const { result } = await takeTurn(deps, session, "jarvis_check", { question, thread: typeof args.thread === "string" ? args.thread : "main" }, remember);
283
+ const { result } = await takeTurn(deps, session, "jarvis_check", { question, ...(typeof args.thread === "string" ? { thread: args.thread } : {}) }, remember);
257
284
  return result;
258
285
  }));
259
286
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `mail_*` MCP tools (BLI-3708) — the mailboxes a person attached, on the
3
+ * `bli-tower` server, over the same `/api/mail/**` doors `cockpit mail` and
4
+ * the browser call, authenticated with this machine's collector device token.
5
+ *
6
+ * The point, in one sentence: an agent working on somebody's behalf should be
7
+ * able to READ the mail that explains a ticket — the invoice, the vendor's
8
+ * reply, the bank notice — and send a reply, without a browser and without a
9
+ * second copy of anybody's credentials.
10
+ *
11
+ * TWO DELIBERATE ASYMMETRIES with the CLI, both recorded in `verb-census.ts`:
12
+ *
13
+ * - **`cockpit mail add-imap` has NO MCP twin.** It carries a Google app
14
+ * password, and the CLI takes that on stdin precisely so it never lands in
15
+ * an argument list. An MCP tool argument IS an argument list — it travels
16
+ * through a model's context window and whatever transcript store sits behind
17
+ * it. Attaching a mailbox stays a thing a person does at a terminal.
18
+ * - **`mail_attachment` answers METADATA, not bytes.** The CLI writes the file
19
+ * to `--out <path>`; an MCP tool has nowhere to put ten megabytes but the
20
+ * conversation. It returns the same `?meta=1` read the CLI uses to describe
21
+ * a file before downloading it, and names the command that fetches it.
22
+ *
23
+ * Same session discipline as `work-tools.ts`: the session is loaded fresh per
24
+ * call, so a machine with no `cockpit login` pairing still serves this
25
+ * server's other tools and only a `mail_` call fails, by name.
26
+ */
27
+ import { type ToolDeps } from "./tool-result.js";
28
+ export type MailDeps = ToolDeps;
29
+ export declare function registerMailTools(server: {
30
+ registerTool: (...args: never[]) => unknown;
31
+ }, deps: MailDeps): void;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * `mail_*` MCP tools (BLI-3708) — the mailboxes a person attached, on the
3
+ * `bli-tower` server, over the same `/api/mail/**` doors `cockpit mail` and
4
+ * the browser call, authenticated with this machine's collector device token.
5
+ *
6
+ * The point, in one sentence: an agent working on somebody's behalf should be
7
+ * able to READ the mail that explains a ticket — the invoice, the vendor's
8
+ * reply, the bank notice — and send a reply, without a browser and without a
9
+ * second copy of anybody's credentials.
10
+ *
11
+ * TWO DELIBERATE ASYMMETRIES with the CLI, both recorded in `verb-census.ts`:
12
+ *
13
+ * - **`cockpit mail add-imap` has NO MCP twin.** It carries a Google app
14
+ * password, and the CLI takes that on stdin precisely so it never lands in
15
+ * an argument list. An MCP tool argument IS an argument list — it travels
16
+ * through a model's context window and whatever transcript store sits behind
17
+ * it. Attaching a mailbox stays a thing a person does at a terminal.
18
+ * - **`mail_attachment` answers METADATA, not bytes.** The CLI writes the file
19
+ * to `--out <path>`; an MCP tool has nowhere to put ten megabytes but the
20
+ * conversation. It returns the same `?meta=1` read the CLI uses to describe
21
+ * a file before downloading it, and names the command that fetches it.
22
+ *
23
+ * Same session discipline as `work-tools.ts`: the session is loaded fresh per
24
+ * call, so a machine with no `cockpit login` pairing still serves this
25
+ * server's other tools and only a `mail_` call fails, by name.
26
+ */
27
+ import { z } from "zod";
28
+ import { callAgentDoor } from "./agent-door.js";
29
+ import { CONFIRM_INPUT, doorFailureText, errorResult, queryString, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
30
+ function messageLine(message) {
31
+ const from = message.from_address?.address ?? "(no sender)";
32
+ return `${message.is_unread ? "*" : " "} ${message.internal_date.slice(0, 16)} ${from} ${message.subject ?? "(no subject)"} [thread ${message.thread_id ?? "?"}]`;
33
+ }
34
+ const ADDRESS = z.string().email().max(320);
35
+ export function registerMailTools(server, deps) {
36
+ const register = registrarFor(server);
37
+ register("mail_accounts", {
38
+ title: "List attached mailboxes",
39
+ description: "Every mailbox attached to Tower that you may see — address, provider (gmail_oauth or imap), health and last sync. "
40
+ + "One person may have several; there is no default mailbox anywhere in this surface, so start here when you need an account id.",
41
+ inputSchema: {},
42
+ }, async () => withSession(deps, async (session) => {
43
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/mail/accounts");
44
+ if (!response.ok)
45
+ return errorResult(doorFailureText("mail_accounts", response));
46
+ const accounts = (Array.isArray(response.body.accounts) ? response.body.accounts : []);
47
+ const lines = accounts
48
+ .map((account) => `${account.address} ${account.provider} ${account.status}${account.status_reason ? ` (${account.status_reason})` : ""} ${account.id}`)
49
+ .join("\n");
50
+ return textResult(accounts.length === 0
51
+ ? "No mailboxes are attached. A person attaches one with `cockpit mail add-imap` (personal) or the browser (work)."
52
+ : `${accounts.length} mailbox(es).\n${lines}`, { accounts });
53
+ }));
54
+ register("mail_inbox", {
55
+ title: "Read the unified inbox",
56
+ description: "Messages across every mailbox you may read, newest first. Never a body — call mail_read on a thread for that. "
57
+ + "`account_id` narrows to one mailbox; `unread` and `label` filter; `before` (an ISO instant) pages backwards.",
58
+ inputSchema: {
59
+ account_id: z.string().uuid().optional(),
60
+ unread: z.boolean().optional(),
61
+ label: z.string().min(1).max(100).optional(),
62
+ before: z.string().min(1).max(40).optional(),
63
+ limit: z.number().int().min(1).max(200).optional(),
64
+ },
65
+ }, async (args) => withSession(deps, async (session) => {
66
+ const query = new URLSearchParams();
67
+ if (args.account_id)
68
+ query.set("account_id", String(args.account_id));
69
+ if (args.unread === true)
70
+ query.set("unread", "1");
71
+ if (args.label)
72
+ query.set("label", String(args.label));
73
+ if (args.before)
74
+ query.set("before", String(args.before));
75
+ if (args.limit)
76
+ query.set("limit", String(args.limit));
77
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/mail/inbox${queryString(query)}`);
78
+ if (!response.ok)
79
+ return errorResult(doorFailureText("mail_inbox", response));
80
+ const messages = (Array.isArray(response.body.messages) ? response.body.messages : []);
81
+ return textResult(messages.length === 0
82
+ ? "No mail matches that."
83
+ : `${messages.length} message(s).\n${messages.map(messageLine).join("\n")}`, { messages, accounts: response.body.accounts ?? [] });
84
+ }));
85
+ register("mail_read", {
86
+ title: "Read one conversation",
87
+ description: "One thread with every message's text body, oldest first. Thread ids come from mail_inbox or mail_search. "
88
+ + "Read the thread before saying what somebody wrote — a snippet is the first 240 characters and nothing more.",
89
+ inputSchema: { thread_id: z.string().uuid() },
90
+ }, async (args) => withSession(deps, async (session) => {
91
+ const threadId = String(args.thread_id ?? "");
92
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/mail/threads/${encodeURIComponent(threadId)}`);
93
+ if (!response.ok)
94
+ return errorResult(doorFailureText("mail_read", response));
95
+ const thread = response.body.thread;
96
+ const messages = (Array.isArray(response.body.messages) ? response.body.messages : []);
97
+ const rendered = messages
98
+ .map((message) => `--- ${message.internal_date} ${message.from_address?.address ?? "(no sender)"} [${message.id}]\n`
99
+ + (message.body_text ?? message.snippet ?? "(no text body — this message is HTML only)").trim())
100
+ .join("\n\n");
101
+ return textResult(`${thread?.subject ?? "(no subject)"} — ${thread?.account_address ?? "?"}, ${messages.length} message(s)\n\n${rendered}`, { thread, messages });
102
+ }));
103
+ register("mail_search", {
104
+ title: "Search your mail",
105
+ description: "Full text over the subject and plain-text body of every message you may read. Quoted phrases and OR work the way "
106
+ + "they do in a search box. Returns headers only; call mail_read on a hit's thread to read it.",
107
+ inputSchema: {
108
+ query: z.string().min(1).max(400),
109
+ account_id: z.string().uuid().optional(),
110
+ limit: z.number().int().min(1).max(200).optional(),
111
+ },
112
+ }, async (args) => withSession(deps, async (session) => {
113
+ const query = new URLSearchParams({ q: String(args.query ?? "") });
114
+ if (args.account_id)
115
+ query.set("account_id", String(args.account_id));
116
+ if (args.limit)
117
+ query.set("limit", String(args.limit));
118
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/mail/search${queryString(query)}`);
119
+ if (!response.ok)
120
+ return errorResult(doorFailureText("mail_search", response));
121
+ const matches = (Array.isArray(response.body.matches) ? response.body.matches : []);
122
+ return textResult(matches.length === 0
123
+ ? `Nothing in your mail matched "${String(args.query)}".`
124
+ : `${matches.length} match(es).\n${matches.map(messageLine).join("\n")}`, { matches });
125
+ }));
126
+ register("mail_send", {
127
+ title: "Send mail from one mailbox",
128
+ description: "Sends a message. `account_id` is REQUIRED and is the address it goes out from — with several mailboxes attached, "
129
+ + "that is never something to guess. `in_reply_to_message_id` (a message id from mail_read) threads the reply. "
130
+ + "This actually sends mail to real people: confirm must be true.",
131
+ inputSchema: {
132
+ account_id: z.string().uuid(),
133
+ to: z.array(ADDRESS).min(1).max(50),
134
+ cc: z.array(ADDRESS).max(50).optional(),
135
+ subject: z.string().max(998),
136
+ text: z.string().min(1).max(500_000),
137
+ in_reply_to_message_id: z.string().uuid().optional(),
138
+ confirm: CONFIRM_INPUT,
139
+ },
140
+ }, async (args) => withSession(deps, async (session) => {
141
+ const refusal = unconfirmed(args, "Sending mail reaches real people and cannot be recalled.");
142
+ if (refusal)
143
+ return refusal;
144
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/mail/send", {
145
+ account_id: args.account_id,
146
+ to: args.to.map((address) => ({ address })),
147
+ ...(Array.isArray(args.cc) && args.cc.length > 0
148
+ ? { cc: args.cc.map((address) => ({ address })) }
149
+ : {}),
150
+ subject: args.subject ?? "",
151
+ text: args.text,
152
+ ...(args.in_reply_to_message_id ? { in_reply_to_message_id: args.in_reply_to_message_id } : {}),
153
+ });
154
+ if (!response.ok)
155
+ return errorResult(doorFailureText("mail_send", response));
156
+ const sent = response.body.sent;
157
+ return textResult(`Sent from ${sent?.address ?? "that mailbox"} to ${args.to.join(", ")}${sent?.threaded ? " (as a reply)" : ""}.`, { sent });
158
+ }));
159
+ register("mail_attachment", {
160
+ title: "Describe an attachment",
161
+ description: "The filename, mime type and size of one attachment. Attachments are POINTERS in Tower — nothing is stored — and "
162
+ + "the bytes are not returned here: `cockpit mail attachment <id> --out <path>` downloads the file at a terminal.",
163
+ inputSchema: { attachment_id: z.string().uuid() },
164
+ }, async (args) => withSession(deps, async (session) => {
165
+ const id = String(args.attachment_id ?? "");
166
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/mail/attachments/${encodeURIComponent(id)}?meta=1`);
167
+ if (!response.ok)
168
+ return errorResult(doorFailureText("mail_attachment", response));
169
+ const attachment = response.body.attachment;
170
+ return textResult(`${attachment?.filename ?? "(unnamed)"} — ${attachment?.mime_type ?? "unknown type"}, ${attachment?.size_bytes ?? "?"} bytes. `
171
+ + `Download it with: cockpit mail attachment ${id} --out <path>`, { attachment });
172
+ }));
173
+ register("mail_sync", {
174
+ title: "Read a mailbox now",
175
+ description: "Runs one sync pass on one mailbox instead of waiting for the 15-minute cron, and answers with what landed or the "
176
+ + "reason it did not (token_revoked, history_gap, auth_failed, quota…). Only the mailbox's OWNER may do this.",
177
+ inputSchema: { account_id: z.string().uuid() },
178
+ }, async (args) => withSession(deps, async (session) => {
179
+ const id = String(args.account_id ?? "");
180
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", `/api/mail/accounts/${encodeURIComponent(id)}/sync`);
181
+ if (!response.ok)
182
+ return errorResult(doorFailureText("mail_sync", response));
183
+ const run = response.body.run;
184
+ if (run?.reason && run.reason !== "ok") {
185
+ return errorResult(`That mailbox did not sync (${run.reason})${run.detail ? `: ${run.detail}` : ""}.`);
186
+ }
187
+ return textResult(`Synced: ${run?.messagesAdded ?? 0} new, ${run?.messagesUpdated ?? 0} updated.`, { run: run ?? {} });
188
+ }));
189
+ register("mail_detach", {
190
+ title: "Detach a mailbox",
191
+ description: "Removes a mailbox from Tower along with its stored mail and its credential. The mail itself is untouched at the "
192
+ + "provider — this unhooks Tower, it does not delete anybody's email. Irreversible here: confirm must be true.",
193
+ inputSchema: { account_id: z.string().uuid(), confirm: CONFIRM_INPUT },
194
+ }, async (args) => withSession(deps, async (session) => {
195
+ const refusal = unconfirmed(args, "Detaching removes this mailbox's stored mail from Tower.");
196
+ if (refusal)
197
+ return refusal;
198
+ const id = String(args.account_id ?? "");
199
+ const response = await callAgentDoor(session, deps.fetchImpl, "DELETE", `/api/mail/accounts?account_id=${encodeURIComponent(id)}`);
200
+ if (!response.ok)
201
+ return errorResult(doorFailureText("mail_detach", response));
202
+ return textResult(`Detached ${id}. Its stored mail and its credential went with it.`, { detached: id });
203
+ }));
204
+ }
package/dist/server.js CHANGED
@@ -10,6 +10,7 @@ import { registerBriefTools } from "./brief-tools.js";
10
10
  import { registerBriefWriteTools } from "./brief-write-tools.js";
11
11
  import { registerDocsMsgTools } from "./docs-msg-tools.js";
12
12
  import { registerJarvisTools } from "./jarvis-tools.js";
13
+ import { registerMailTools } from "./mail-tools.js";
13
14
  import { registerNotesTools } from "./notes-tools.js";
14
15
  import { registerNotesWriteTools } from "./notes-write-tools.js";
15
16
  import { registerOpsTools } from "./ops-tools.js";
@@ -386,6 +387,11 @@ export function createServer(deps) {
386
387
  // the docs/msg tools above, for the same reason: a coding session should
387
388
  // file and move a Tower issue the way it files and moves a Linear one.
388
389
  registerWorkTools(server, { fetchImpl: deps.fetchImpl });
390
+ // BLI-3708: the mailboxes a person attached. Reads, a send and a sync over
391
+ // the same /api/mail/** doors `cockpit mail` calls; `add-imap` deliberately
392
+ // has no twin (see mail-tools.ts's header — a credential is not a tool
393
+ // argument).
394
+ registerMailTools(server, { fetchImpl: deps.fetchImpl });
389
395
  // BLI-3732: `jarvis_*` — the assistant itself, on the same device-token path
390
396
  // as the three families above, over the doors `cockpit jarvis` already
391
397
  // calls. It is the last Tower surface that had a CLI door and no MCP one.
@@ -148,6 +148,14 @@ export const MCP_TWINS = {
148
148
  "issue comment": { tool: "work_comment_issue", door: "POST /api/work/issues/[id]/comments" },
149
149
  "issue history": { tool: "work_issue_history", door: "GET /api/work/issues/[id]/history" },
150
150
  "project list": { tool: "work_list_projects", door: "GET /api/work/projects" },
151
+ "mail accounts": { tool: "mail_accounts", door: "GET /api/mail/accounts" },
152
+ "mail inbox": { tool: "mail_inbox", door: "GET /api/mail/inbox" },
153
+ "mail read": { tool: "mail_read", door: "GET /api/mail/threads/[id]" },
154
+ "mail search": { tool: "mail_search", door: "GET /api/mail/search" },
155
+ "mail send": { tool: "mail_send", door: "POST /api/mail/send" },
156
+ "mail attachment": { tool: "mail_attachment", door: "GET /api/mail/attachments/[id]?meta=1" },
157
+ "mail sync": { tool: "mail_sync", door: "POST /api/mail/accounts/[id]/sync" },
158
+ "mail detach": { tool: "mail_detach", door: "DELETE /api/mail/accounts" },
151
159
  "brief read": { tool: "brief_read", door: "GET /api/jarvis/brief" },
152
160
  "brief history": { tool: "brief_history", door: "GET /api/jarvis/brief?history=1" },
153
161
  "brief status": { tool: "brief_status", door: "GET /api/ops/brief-status" },
@@ -194,6 +202,7 @@ export const MCP_TWINS = {
194
202
  };
195
203
  /** Verbs that can never have an MCP twin, and why. A claim, not a backlog. */
196
204
  export const TERMINAL_ONLY = {
205
+ "mail add-imap": "carries a Google app password. The CLI reads it from STDIN precisely so it never lands in an argument list; an MCP tool argument travels through a model's context window and whatever transcript store sits behind it, so attaching a mailbox stays a thing a person does at a terminal (BLI-3708)",
197
206
  "brief edit": "opens the person's own $EDITOR on this machine and files what they changed; an agent has no editor to open (commands/editor.ts)",
198
207
  };
199
208
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",