@bli-cockpit/mcp 0.1.5 → 0.1.7

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
 
@@ -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.5",
3
+ "version": "0.1.7",
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",
@@ -29,7 +29,7 @@
29
29
  "readme": "npm run build && node scripts/write-readme-census.mjs"
30
30
  },
31
31
  "dependencies": {
32
- "@bli-cockpit/telemetry-core": "0.1.29",
32
+ "@bli-cockpit/telemetry-core": "0.1.30",
33
33
  "@modelcontextprotocol/sdk": "^1.29.0",
34
34
  "zod": "^4.3.6"
35
35
  },