@bli-cockpit/mcp 0.1.83 → 0.1.85

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",
@@ -1,4 +1,39 @@
1
1
  import { type ToolDeps } from "./tool-result.js";
2
+ /**
3
+ * BLI-12561: the sentences about the GitHub counts, worded exactly as the /usage
4
+ * page and `cockpit usage people` word them (`apps/dashboard/src/lib/usage/
5
+ * usage-github-words.ts` is the original; every copy has the same cases). This
6
+ * package does not import the dashboard, so the words are carried here.
7
+ */
8
+ interface GithubCounts {
9
+ match: string;
10
+ pull_requests_opened: number | null;
11
+ pull_requests_merged: number | null;
12
+ commits: number | null;
13
+ }
14
+ interface GithubBlock {
15
+ status: "ok" | "unavailable";
16
+ reason?: string;
17
+ detail?: string;
18
+ counts_label?: string;
19
+ repositories?: unknown[];
20
+ oldest_pull_request_at?: string | null;
21
+ newest_pull_request_at?: string | null;
22
+ unattributed?: {
23
+ pull_requests_opened: number;
24
+ pull_requests_merged: number;
25
+ commits: number;
26
+ };
27
+ }
28
+ export declare function githubWords(github: GithubBlock, people: readonly {
29
+ github?: GithubCounts;
30
+ }[]): {
31
+ lines: string[];
32
+ label: string;
33
+ };
34
+ /** The sentence appended to the text half, or the line that says a dashboard sent no GitHub block. */
35
+ export declare function githubSentence(body: Record<string, unknown>): string;
2
36
  export declare function registerUsageTools(server: {
3
37
  registerTool: (...args: never[]) => unknown;
4
38
  }, deps: ToolDeps): void;
39
+ export {};
@@ -6,10 +6,43 @@ import { dataResult, doorFailureText, errorResult, registrarFor, withSession } f
6
6
  // half alone) could say how many sessions were counted and never whose they
7
7
  // were or what they cost (BLI-10432).
8
8
  const WINDOW_BOUND = "<n>d (1 to 365), <n>h (1 to 8760), or an ISO timestamp";
9
+ const noun = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
10
+ const PLACED = new Set(["github_login", "counted_on_other_account"]);
11
+ export function githubWords(github, people) {
12
+ const label = github.counts_label ?? "";
13
+ if (github.status === "unavailable") {
14
+ return { lines: [`GitHub counts are unavailable: ${github.reason ?? "unknown_reason"}${github.detail ? ` (${github.detail})` : ""}.`], label };
15
+ }
16
+ const sum = (field) => people.reduce((total, person) => total + (person.github?.[field] ?? 0), 0);
17
+ const range = github.oldest_pull_request_at && github.newest_pull_request_at
18
+ ? `; mirrored pull requests run from ${github.oldest_pull_request_at} to ${github.newest_pull_request_at}`
19
+ : "; the mirror holds no pull requests";
20
+ const lines = [
21
+ `GitHub: ${noun(sum("pull_requests_opened"), "pull request")} opened, ${sum("pull_requests_merged")} merged, ${noun(sum("commits"), "commit")}, ` +
22
+ `from ${noun((github.repositories ?? []).length, "mirrored repository", "mirrored repositories")}${range}.`,
23
+ ];
24
+ const unmatched = github.unattributed;
25
+ if (unmatched && unmatched.pull_requests_opened + unmatched.pull_requests_merged + unmatched.commits > 0) {
26
+ lines.push(`Not matched to a person: ${noun(unmatched.pull_requests_opened, "pull request")} opened, ${unmatched.pull_requests_merged} merged, ${noun(unmatched.commits, "commit")}.`);
27
+ }
28
+ const unplaced = people.filter((person) => person.github && !PLACED.has(person.github.match)).length;
29
+ if (unplaced > 0)
30
+ lines.push(`${noun(unplaced, "person cannot", "people cannot")} be matched to a GitHub login, so their GitHub counts are unknown, not zero.`);
31
+ return { lines, label };
32
+ }
33
+ /** The sentence appended to the text half, or the line that says a dashboard sent no GitHub block. */
34
+ export function githubSentence(body) {
35
+ const github = body.github;
36
+ if (github === undefined)
37
+ return " GitHub counts: this dashboard sent none (it predates them).";
38
+ const people = Array.isArray(body.people) ? body.people : [];
39
+ const words = githubWords(github, people);
40
+ return ` ${[...words.lines, words.label].filter(Boolean).join(" ")}`;
41
+ }
9
42
  export function registerUsageTools(server, deps) {
10
43
  registrarFor(server)("usage_people", {
11
44
  title: "Read usage by person",
12
- description: "Per-person Claude Code and Codex token usage, list-price equivalent (not actual spend), and extraction coverage, over a window (default the last 30 days). groupBy adds a per-person breakdown; groupBy topic or ticket lists the labels usage_sessions takes as subject or task.",
45
+ description: "Per-person Claude Code and Codex token usage, list-price equivalent (not actual spend), and extraction coverage, over a window (default the last 30 days). Each person also carries GitHub pull requests opened, pull requests merged and commits from the pull requests Tower mirrors from GitHub (null means Tower cannot match that person to a GitHub login: unknown, not zero); the github block says which repositories the mirror holds and what matched nobody. groupBy adds a per-person breakdown; groupBy topic or ticket lists the labels usage_sessions takes as subject or task.",
13
46
  inputSchema: {
14
47
  minConfidence: z.enum(["high", "medium", "low"]).optional().describe("With groupBy task_type or topic: only labels at or above this confidence."),
15
48
  groupBy: z.enum(["repo", "task_type", "topic", "ticket"]).optional().describe("Break each person's usage down by repo, task type, topic or ticket."),
@@ -43,7 +76,11 @@ export function registerUsageTools(server, deps) {
43
76
  const priceLabel = typeof response.body.api_list_price_equivalent_label === "string"
44
77
  ? ` ${response.body.api_list_price_equivalent_label}.`
45
78
  : "";
46
- return dataResult(`${coverage?.sessions_extracted ?? 0} of ${coverage?.sessions_observed ?? 0} sessions extracted.${why} ${coverage?.sessions_counted_at_upload ?? 0} of ${coverage?.sessions_observed ?? 0} sessions counted at upload, ${coverage?.sessions_counted_by_server ?? 0} by server backfill, ${coverage?.sessions_counted_by_ladder ?? 0} by the ladder.${args.groupBy === "task_type" ? ` ${coverage?.sessions_labelled ?? 0} of ${coverage?.sessions_observed ?? 0} sessions labelled by topic.` : ""}${priceLabel}`, response.body);
79
+ // BLI-12561: the same GitHub sentences the page and the CLI print, so an
80
+ // agent reading only the text half cannot quote a pull-request count without
81
+ // what it covers and who it left out.
82
+ const github = githubSentence(response.body);
83
+ return dataResult(`${coverage?.sessions_extracted ?? 0} of ${coverage?.sessions_observed ?? 0} sessions extracted.${why} ${coverage?.sessions_counted_at_upload ?? 0} of ${coverage?.sessions_observed ?? 0} sessions counted at upload, ${coverage?.sessions_counted_by_server ?? 0} by server backfill, ${coverage?.sessions_counted_by_ladder ?? 0} by the ladder.${args.groupBy === "task_type" ? ` ${coverage?.sessions_labelled ?? 0} of ${coverage?.sessions_observed ?? 0} sessions labelled by topic.` : ""}${priceLabel}${github}`, response.body);
47
84
  }));
48
85
  registrarFor(server)("usage_sessions", {
49
86
  title: "Read task or subject sessions",
@@ -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.85",
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",