@bli-cockpit/mcp 0.1.85 → 0.1.86

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.
@@ -0,0 +1,57 @@
1
+ export declare const MAIL_ATTACHMENT_MAX_COUNT = 10;
2
+ export declare const MAIL_ATTACHMENTS_MAX_TOTAL_BYTES = 3000000;
3
+ /** The whole JSON request, under Vercel's 4.5 MB function-body cap with room for the headers. */
4
+ export declare const MAIL_REQUEST_MAX_BYTES = 4400000;
5
+ export declare const MAIL_ATTACHMENT_CONTENT_TYPES: Readonly<Record<string, string>>;
6
+ /**
7
+ * A name a person almost never means to email: the repo's own secret-name rule
8
+ * (`isSecretLikePathSegment`, the one `notes upload` uses: `.env*`, anything with
9
+ * secret, credential, private_key or service_role in it), with `.env.example`
10
+ * let through (a template, no values), plus the private-key names that rule does
11
+ * not know: id_rsa and its siblings, `*.pem`, `*.key`.
12
+ */
13
+ export declare function attachmentLooksSecret(fileName: string): boolean;
14
+ /** The type a file name's extension says it is. Case-insensitive; anything unknown is octet-stream. */
15
+ export declare function mailAttachmentContentType(fileName: string): string;
16
+ /** One file as the send and draft doors take it. */
17
+ export interface WireMailAttachment {
18
+ filename: string;
19
+ content_type: string;
20
+ content_base64: string;
21
+ }
22
+ /** What may be said about the files in a result or a log: counts, sizes and types. Never a name. */
23
+ export interface MailAttachmentSummary {
24
+ count: number;
25
+ bytes: number;
26
+ sizes: number[];
27
+ content_types: string[];
28
+ }
29
+ export type MailAttachmentRefusal = "too_many_attachments" | "attachment_unreadable" | "attachment_too_large" | "attachment_secret_like" | "attachments_unavailable_hosted";
30
+ export type ReadMailAttachmentsResult = {
31
+ ok: true;
32
+ attachments: WireMailAttachment[];
33
+ summary: MailAttachmentSummary;
34
+ } | {
35
+ ok: false;
36
+ reason: MailAttachmentRefusal;
37
+ detail: string;
38
+ };
39
+ /**
40
+ * Reads every `{ path }`, or says which one cannot go and why. Never throws.
41
+ * Hosted first (no disk, no stat), then the count, each file's `stat`, the
42
+ * total, and only then the bytes.
43
+ */
44
+ export declare function readMailAttachments(entries: ReadonlyArray<{
45
+ path: string;
46
+ }> | undefined): Promise<ReadMailAttachmentsResult>;
47
+ /** The second check: the whole request, not just the files. Called with the JSON about to be posted. */
48
+ export declare function mailRequestFits(body: unknown): {
49
+ ok: true;
50
+ bytes: number;
51
+ } | {
52
+ ok: false;
53
+ reason: MailAttachmentRefusal;
54
+ detail: string;
55
+ };
56
+ /** The sentence a result adds after a send or a draft that carried files. */
57
+ export declare function attachmentSentence(summary: MailAttachmentSummary): string;
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `attachments: [{ path }]` for `mail_send` and `mail_draft` (BLI-12661): read
3
+ * the files on the machine THIS server runs on, screen them, and turn them into
4
+ * the `attachments` the two doors take. The twin of the CLI's
5
+ * `commands/mail-attachments.ts` in `@bli-cockpit/local-collector`, copied here
6
+ * because this package depends on neither that one nor the CLI (the same call
7
+ * `agent-door-session.ts` and `notes-write-tools.ts` made for the session file
8
+ * and the note reader). `mail-attachment-parity.test.ts` fails when the caps or
9
+ * the content-type table drift from the CLI's copy.
10
+ *
11
+ * **A hosted server never reads a path.** The same `createServer` answers the
12
+ * stdio bin on a person's laptop AND the hosted `/api/mcp` for ChatGPT, dot and
13
+ * claude.ai, and on the hosted one a path names a file on the HOST, not on the
14
+ * caller's computer: reading it would mail the host's own files to whoever was
15
+ * asked for. `notes_upload` and `sources_check` are left out of the hosted list
16
+ * for exactly this (`REMOTE_EXCLUDED_TOOLS`); `mail_send` and `mail_draft` must
17
+ * stay on it, so the one argument that names a path is refused there, by name,
18
+ * BEFORE the disk is touched: `attachments_unavailable_hosted`. Detected the way
19
+ * everything hosted is, by the request scope (`inRemoteAgentDoorScope`), so a
20
+ * future hosted entry point cannot forget to switch it on. The caller is told
21
+ * where to attach a file instead (the laptop server, or `cockpit mail ... --attach`).
22
+ *
23
+ * Everything else is the CLI's rule, in the same words (a refusal names a file
24
+ * by position and an error code, never by path or name): the count, then each
25
+ * file's `stat`, then the total, and only then a byte read; a refusal is a named
26
+ * reason with its numbers; the server holds the same caps again. See the CLI
27
+ * module for why 3,000,000 bytes (Vercel's 4.5 MB request cap, base64 a third
28
+ * bigger than the file).
29
+ */
30
+ import { readFile, stat } from "node:fs/promises";
31
+ import path from "node:path";
32
+ import { isSecretLikePathSegment } from "@bli-cockpit/telemetry-core";
33
+ import { inRemoteAgentDoorScope } from "./agent-door-session.js";
34
+ export const MAIL_ATTACHMENT_MAX_COUNT = 10;
35
+ export const MAIL_ATTACHMENTS_MAX_TOTAL_BYTES = 3_000_000;
36
+ /** The whole JSON request, under Vercel's 4.5 MB function-body cap with room for the headers. */
37
+ export const MAIL_REQUEST_MAX_BYTES = 4_400_000;
38
+ // BEGIN content types: identical in packages/cockpit-local-collector/src/commands/mail-attachments.ts
39
+ export const MAIL_ATTACHMENT_CONTENT_TYPES = {
40
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
41
+ doc: "application/msword",
42
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
43
+ xls: "application/vnd.ms-excel",
44
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
45
+ ppt: "application/vnd.ms-powerpoint",
46
+ pdf: "application/pdf",
47
+ png: "image/png",
48
+ jpg: "image/jpeg",
49
+ jpeg: "image/jpeg",
50
+ gif: "image/gif",
51
+ webp: "image/webp",
52
+ txt: "text/plain",
53
+ csv: "text/csv",
54
+ md: "text/markdown",
55
+ json: "application/json",
56
+ html: "text/html",
57
+ rtf: "application/rtf",
58
+ zip: "application/zip",
59
+ };
60
+ // END content types
61
+ // BEGIN secret-like names: identical in the CLI and MCP mail-attachments.ts
62
+ /**
63
+ * A name a person almost never means to email: the repo's own secret-name rule
64
+ * (`isSecretLikePathSegment`, the one `notes upload` uses: `.env*`, anything with
65
+ * secret, credential, private_key or service_role in it), with `.env.example`
66
+ * let through (a template, no values), plus the private-key names that rule does
67
+ * not know: id_rsa and its siblings, `*.pem`, `*.key`.
68
+ */
69
+ export function attachmentLooksSecret(fileName) {
70
+ if (/^\.env\.example$/i.test(fileName))
71
+ return false;
72
+ return isSecretLikePathSegment(fileName) || /^id_(rsa|dsa|ecdsa|ed25519)$/i.test(fileName) || /\.(pem|key)$/i.test(fileName);
73
+ }
74
+ // END secret-like names
75
+ const FALLBACK_CONTENT_TYPE = "application/octet-stream";
76
+ /** The type a file name's extension says it is. Case-insensitive; anything unknown is octet-stream. */
77
+ export function mailAttachmentContentType(fileName) {
78
+ const extension = path.extname(fileName).slice(1).toLowerCase();
79
+ return MAIL_ATTACHMENT_CONTENT_TYPES[extension] ?? FALLBACK_CONTENT_TYPE;
80
+ }
81
+ const NOTHING_SENT = "Nothing was sent.";
82
+ function refuse(reason, detail) {
83
+ return { ok: false, reason, detail };
84
+ }
85
+ function bytesLabel(count) {
86
+ return `${count.toLocaleString("en-US")} byte${count === 1 ? "" : "s"}`;
87
+ }
88
+ function totalTooLarge(total) {
89
+ if (total <= MAIL_ATTACHMENTS_MAX_TOTAL_BYTES)
90
+ return null;
91
+ return refuse("attachment_too_large", `The attachments come to ${bytesLabel(total)} and one message carries at most ${bytesLabel(MAIL_ATTACHMENTS_MAX_TOTAL_BYTES)} `
92
+ + "(about 3 MB: the most one request to Tower holds once the files are encoded). "
93
+ + `Share a link for anything bigger. ${NOTHING_SENT}`);
94
+ }
95
+ /**
96
+ * Reads every `{ path }`, or says which one cannot go and why. Never throws.
97
+ * Hosted first (no disk, no stat), then the count, each file's `stat`, the
98
+ * total, and only then the bytes.
99
+ */
100
+ export async function readMailAttachments(entries) {
101
+ const paths = (entries ?? []).map((entry) => entry.path);
102
+ if (paths.length === 0)
103
+ return { ok: true, attachments: [], summary: { count: 0, bytes: 0, sizes: [], content_types: [] } };
104
+ if (inRemoteAgentDoorScope()) {
105
+ return refuse("attachments_unavailable_hosted", "This is the hosted Tower server: a path would name a file on the server, not on your computer, so no file was read and nothing was attached. "
106
+ + "Attach files with the local bli-tower server (`cockpit doctor` registers it) or with `cockpit mail send|draft --attach <path>`. "
107
+ + NOTHING_SENT);
108
+ }
109
+ if (paths.length > MAIL_ATTACHMENT_MAX_COUNT) {
110
+ return refuse("too_many_attachments", `${paths.length} files were attached and one message carries at most ${MAIL_ATTACHMENT_MAX_COUNT}. ${NOTHING_SENT}`);
111
+ }
112
+ // Names first, before a single stat or read: a secret-looking file is never opened.
113
+ const secret = paths.findIndex((filePath) => attachmentLooksSecret(path.basename(filePath)));
114
+ if (secret !== -1) {
115
+ return refuse("attachment_secret_like", `Attachment ${secret + 1} has a name that looks like a key or credential file, so it was not opened. ${NOTHING_SENT}`);
116
+ }
117
+ const sizes = [];
118
+ for (const [index, filePath] of paths.entries()) {
119
+ const label = `Attachment ${index + 1}`;
120
+ let size;
121
+ try {
122
+ const info = await stat(filePath);
123
+ if (!info.isFile())
124
+ return refuse("attachment_unreadable", `${label} is not a regular file: a folder or a device cannot be attached. ${NOTHING_SENT}`);
125
+ size = info.size;
126
+ }
127
+ catch (error) {
128
+ const code = error.code;
129
+ return refuse("attachment_unreadable", code === "ENOENT"
130
+ ? `${label} does not exist. ${NOTHING_SENT}`
131
+ : `${label} could not be read (${code ?? "unknown error"}). ${NOTHING_SENT}`);
132
+ }
133
+ if (size === 0)
134
+ return refuse("attachment_unreadable", `${label} is empty, and an empty file is not worth sending. ${NOTHING_SENT}`);
135
+ sizes.push(size);
136
+ }
137
+ const tooLarge = totalTooLarge(sizes.reduce((sum, size) => sum + size, 0));
138
+ if (tooLarge)
139
+ return tooLarge;
140
+ const attachments = [];
141
+ const actual = [];
142
+ for (const [index, filePath] of paths.entries()) {
143
+ let bytes;
144
+ try {
145
+ bytes = await readFile(filePath);
146
+ }
147
+ catch (error) {
148
+ const code = error.code;
149
+ return refuse("attachment_unreadable", `Attachment ${index + 1} could not be read (${code ?? "unknown error"}). ${NOTHING_SENT}`);
150
+ }
151
+ actual.push(bytes.length);
152
+ const filename = path.basename(filePath);
153
+ attachments.push({ filename, content_type: mailAttachmentContentType(filename), content_base64: bytes.toString("base64") });
154
+ }
155
+ // A file that grew between the stat and the read is the file as it is now.
156
+ const total = actual.reduce((sum, size) => sum + size, 0);
157
+ const grew = totalTooLarge(total);
158
+ if (grew)
159
+ return grew;
160
+ if (actual.includes(0)) {
161
+ return refuse("attachment_unreadable", `Attachment ${actual.indexOf(0) + 1} is empty, and an empty file is not worth sending. ${NOTHING_SENT}`);
162
+ }
163
+ return {
164
+ ok: true,
165
+ attachments,
166
+ summary: { count: attachments.length, bytes: total, sizes: actual, content_types: attachments.map((entry) => entry.content_type) },
167
+ };
168
+ }
169
+ /** The second check: the whole request, not just the files. Called with the JSON about to be posted. */
170
+ export function mailRequestFits(body) {
171
+ const bytes = Buffer.byteLength(JSON.stringify(body), "utf8");
172
+ if (bytes <= MAIL_REQUEST_MAX_BYTES)
173
+ return { ok: true, bytes };
174
+ return refuse("attachment_too_large", `The whole request would be ${bytesLabel(bytes)} (the message, the files encoded as base64, and the JSON around them) `
175
+ + `and Tower takes at most ${bytesLabel(MAIL_REQUEST_MAX_BYTES)}: a request over 4.5 MB is refused by the platform before Tower sees it. `
176
+ + `Shorten the message or send fewer or smaller files. ${NOTHING_SENT}`);
177
+ }
178
+ /** The sentence a result adds after a send or a draft that carried files. */
179
+ export function attachmentSentence(summary) {
180
+ return `${summary.count} attachment${summary.count === 1 ? "" : "s"} went with it (${bytesLabel(summary.bytes)}).`;
181
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The compose half of the `mail_*` tools (BLI-12530, BLI-12661): the JSON that
3
+ * `mail_send` and `mail_draft` post, and the files that ride along. The twin of
4
+ * `commands/mail-compose.ts` in `@bli-cockpit/local-collector`.
5
+ *
6
+ * One shape for both tools, because the two doors take the same fields: a
7
+ * message that can be sent can be saved unsent, and a field added to one cannot
8
+ * be forgotten by the other.
9
+ */
10
+ import { z } from "zod";
11
+ import { type MailAttachmentSummary, type WireMailAttachment } from "./mail-attachments.js";
12
+ /**
13
+ * The `attachments` argument of `mail_send` and `mail_draft`. The count is
14
+ * deliberately NOT capped in the schema: an eleventh file is refused by name,
15
+ * `too_many_attachments`, with the numbers, which a bare validation error would
16
+ * not say.
17
+ */
18
+ export declare const ATTACHMENTS_INPUT: z.ZodOptional<z.ZodArray<z.ZodObject<{
19
+ path: z.ZodString;
20
+ }, z.core.$strip>>>;
21
+ /** The JSON both tools post. `attachments` is present only when there are files. */
22
+ export declare function composedBody(args: Record<string, unknown>, attachments?: readonly WireMailAttachment[]): Record<string, unknown>;
23
+ export type PreparedMessage = {
24
+ ok: true;
25
+ body: Record<string, unknown>;
26
+ summary: MailAttachmentSummary | null;
27
+ } | {
28
+ ok: false;
29
+ reason: string;
30
+ detail: string;
31
+ };
32
+ /**
33
+ * The request a tool posts: the body, plus the files named in `attachments`,
34
+ * read and screened HERE before any request is made. A refusal is the named
35
+ * reason and its numbers (`mail-attachments.ts`); a hosted server refuses the
36
+ * whole argument without touching a disk.
37
+ */
38
+ export declare function prepareMessage(args: Record<string, unknown>): Promise<PreparedMessage>;
39
+ /**
40
+ * Did the door SAY it carried the files that were posted? Claim only what the
41
+ * receipt shows: a Tower that predates attachments answers an ordinary 201,
42
+ * because its schema strips the key it does not know, and the message goes out
43
+ * or is saved WITHOUT its files while a tool that reported from what it posted
44
+ * would say they went. Only the door's own `attachments` receipt (count and
45
+ * bytes, both) says they did. Returns the sentence for an `isError` result when
46
+ * the answer does not say so, else null.
47
+ */
48
+ export declare function unconfirmedAttachments(summary: MailAttachmentSummary | null, carried: unknown, verb: "send" | "draft"): string | null;
49
+ /** What a result says about the files: a sentence, and the structured half. */
50
+ export declare function attachmentResult(summary: MailAttachmentSummary | null): {
51
+ sentence: string;
52
+ structured: Record<string, unknown>;
53
+ };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The compose half of the `mail_*` tools (BLI-12530, BLI-12661): the JSON that
3
+ * `mail_send` and `mail_draft` post, and the files that ride along. The twin of
4
+ * `commands/mail-compose.ts` in `@bli-cockpit/local-collector`.
5
+ *
6
+ * One shape for both tools, because the two doors take the same fields: a
7
+ * message that can be sent can be saved unsent, and a field added to one cannot
8
+ * be forgotten by the other.
9
+ */
10
+ import { z } from "zod";
11
+ import { MAIL_ATTACHMENT_MAX_COUNT, MAIL_ATTACHMENTS_MAX_TOTAL_BYTES, attachmentSentence, mailRequestFits, readMailAttachments, } from "./mail-attachments.js";
12
+ /**
13
+ * The `attachments` argument of `mail_send` and `mail_draft`. The count is
14
+ * deliberately NOT capped in the schema: an eleventh file is refused by name,
15
+ * `too_many_attachments`, with the numbers, which a bare validation error would
16
+ * not say.
17
+ */
18
+ export const ATTACHMENTS_INPUT = z
19
+ .array(z.object({ path: z.string().min(1).max(4096).describe("A path to a file on THIS machine (absolute, or relative to the server's working directory).") }))
20
+ .optional()
21
+ .describe(`Files to attach, as [{ path }], read from THIS machine's disk by the local bli-tower server: up to ${MAIL_ATTACHMENT_MAX_COUNT} and `
22
+ + `${MAIL_ATTACHMENTS_MAX_TOTAL_BYTES.toLocaleString("en-US")} bytes together (about 3 MB: a request to Tower is capped at 4.5 MB and a file travels as base64). `
23
+ + "The type comes from the extension (.docx .pdf .png .jpg .txt .csv .md .zip and more, else application/octet-stream). "
24
+ + "Refused BEFORE anything is sent, by name and with the numbers: too_many_attachments, attachment_unreadable (missing, a folder, empty), attachment_too_large. "
25
+ + "NOT available on the hosted Tower server, which cannot read your computer: there it answers attachments_unavailable_hosted. "
26
+ + "The result reports from Tower's own answer: if Tower does not confirm the files (an older Tower), it is an error, attachments_not_confirmed, that says whether the message went. "
27
+ + "A reply (in_reply_to_message_id) keeps its thread with files in it.");
28
+ /** The JSON both tools post. `attachments` is present only when there are files. */
29
+ export function composedBody(args, attachments = []) {
30
+ return {
31
+ account_id: args.account_id,
32
+ to: args.to.map((address) => ({ address })),
33
+ ...(Array.isArray(args.cc) && args.cc.length > 0
34
+ ? { cc: args.cc.map((address) => ({ address })) }
35
+ : {}),
36
+ subject: args.subject ?? "",
37
+ text: args.text,
38
+ ...(attachments.length > 0 ? { attachments } : {}),
39
+ ...(args.in_reply_to_message_id ? { in_reply_to_message_id: args.in_reply_to_message_id } : {}),
40
+ };
41
+ }
42
+ /**
43
+ * The request a tool posts: the body, plus the files named in `attachments`,
44
+ * read and screened HERE before any request is made. A refusal is the named
45
+ * reason and its numbers (`mail-attachments.ts`); a hosted server refuses the
46
+ * whole argument without touching a disk.
47
+ */
48
+ export async function prepareMessage(args) {
49
+ const read = await readMailAttachments(args.attachments);
50
+ if (!read.ok)
51
+ return read;
52
+ if (read.attachments.length === 0)
53
+ return { ok: true, body: composedBody(args), summary: null };
54
+ const body = composedBody(args, read.attachments);
55
+ const fits = mailRequestFits(body);
56
+ if (!fits.ok)
57
+ return fits;
58
+ return { ok: true, body, summary: read.summary };
59
+ }
60
+ /**
61
+ * Did the door SAY it carried the files that were posted? Claim only what the
62
+ * receipt shows: a Tower that predates attachments answers an ordinary 201,
63
+ * because its schema strips the key it does not know, and the message goes out
64
+ * or is saved WITHOUT its files while a tool that reported from what it posted
65
+ * would say they went. Only the door's own `attachments` receipt (count and
66
+ * bytes, both) says they did. Returns the sentence for an `isError` result when
67
+ * the answer does not say so, else null.
68
+ */
69
+ export function unconfirmedAttachments(summary, carried, verb) {
70
+ if (!summary)
71
+ return null;
72
+ const said = carried !== null && typeof carried === "object" ? carried : {};
73
+ if (said.count === summary.count && said.bytes === summary.bytes)
74
+ return null;
75
+ const files = `${summary.count} attachment${summary.count === 1 ? "" : "s"}`;
76
+ const was = summary.count === 1 ? "was" : "were";
77
+ return verb === "send"
78
+ ? `The message WAS sent (once), but Tower did not confirm that its ${files} ${was} in it (attachments_not_confirmed). `
79
+ + "That usually means this Tower deployment predates attachments, in which case it went out WITHOUT them. "
80
+ + "Do not send it again: look in that mailbox's Sent folder first."
81
+ : `The draft WAS saved, but Tower did not confirm that its ${files} ${was} in it (attachments_not_confirmed). `
82
+ + "That usually means this Tower deployment predates attachments, in which case the draft has NO file. "
83
+ + "Delete it from that mailbox's Drafts and try again once Tower is updated.";
84
+ }
85
+ /** What a result says about the files: a sentence, and the structured half. */
86
+ export function attachmentResult(summary) {
87
+ if (!summary)
88
+ return { sentence: "", structured: {} };
89
+ return {
90
+ sentence: ` ${attachmentSentence(summary)}`,
91
+ structured: { attachments: { count: summary.count, bytes: summary.bytes, content_types: summary.content_types } },
92
+ };
93
+ }
@@ -22,6 +22,14 @@
22
22
  * conversation. It returns the same `?meta=1` read the CLI uses to describe
23
23
  * a file before downloading it, and names the command that fetches it.
24
24
  *
25
+ * **Files (BLI-12661).** `mail_send` and `mail_draft` take `attachments:
26
+ * [{ path }]`, the twin of `--attach <path>`: this server runs on the person's
27
+ * machine, so it reads the paths (`mail-attachments.ts`) and posts name, type
28
+ * and base64 to the same doors. The one thing it must never do is read a path
29
+ * on the HOSTED server, where the path would name a file on the host:
30
+ * `/api/mcp` serves these same tools, so there the argument is refused by name
31
+ * (`attachments_unavailable_hosted`) before any disk is touched.
32
+ *
25
33
  * Same session discipline as `work-tools.ts`: the session is loaded fresh per
26
34
  * call, so a machine with no `cockpit login` pairing still serves this
27
35
  * server's other tools and only a `mail_` call fails, by name.
@@ -22,35 +22,27 @@
22
22
  * conversation. It returns the same `?meta=1` read the CLI uses to describe
23
23
  * a file before downloading it, and names the command that fetches it.
24
24
  *
25
+ * **Files (BLI-12661).** `mail_send` and `mail_draft` take `attachments:
26
+ * [{ path }]`, the twin of `--attach <path>`: this server runs on the person's
27
+ * machine, so it reads the paths (`mail-attachments.ts`) and posts name, type
28
+ * and base64 to the same doors. The one thing it must never do is read a path
29
+ * on the HOSTED server, where the path would name a file on the host:
30
+ * `/api/mcp` serves these same tools, so there the argument is refused by name
31
+ * (`attachments_unavailable_hosted`) before any disk is touched.
32
+ *
25
33
  * Same session discipline as `work-tools.ts`: the session is loaded fresh per
26
34
  * call, so a machine with no `cockpit login` pairing still serves this
27
35
  * server's other tools and only a `mail_` call fails, by name.
28
36
  */
29
37
  import { z } from "zod";
30
38
  import { callAgentDoor } from "./agent-door.js";
39
+ import { ATTACHMENTS_INPUT, attachmentResult, prepareMessage, unconfirmedAttachments } from "./mail-compose.js";
31
40
  import { CONFIRM_INPUT, EMAIL_INPUT, doorFailureText, errorResult, queryString, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
32
41
  function messageLine(message) {
33
42
  const from = message.from_address?.address ?? "(no sender)";
34
43
  return `${message.is_unread ? "*" : " "} ${message.internal_date.slice(0, 16)} ${from} ${message.subject ?? "(no subject)"} [thread ${message.thread_id ?? "?"}]`;
35
44
  }
36
45
  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
- }
54
46
  export function registerMailTools(server, deps) {
55
47
  const register = registrarFor(server);
56
48
  register("mail_accounts", {
@@ -191,6 +183,8 @@ export function registerMailTools(server, deps) {
191
183
  title: "Send mail from one mailbox",
192
184
  description: "Sends a message. `account_id` is REQUIRED and is the address it goes out from — with several mailboxes attached, "
193
185
  + "that is never something to guess. `in_reply_to_message_id` (a message id from mail_read) threads the reply. "
186
+ + "`attachments` ([{ path }]) puts files from this machine in the message, up to 10 and 3,000,000 bytes together; "
187
+ + "see that argument for what it refuses. "
194
188
  + "This actually sends mail to real people: confirm must be true.",
195
189
  inputSchema: {
196
190
  account_id: z.string().uuid().describe("The mailbox it goes out from (mail_accounts). Never guessed."),
@@ -198,6 +192,7 @@ export function registerMailTools(server, deps) {
198
192
  cc: z.array(ADDRESS).max(50).optional().describe("Copied email addresses."),
199
193
  subject: z.string().max(998).describe("The subject line."),
200
194
  text: z.string().min(1).max(500_000).describe("The plain-text body."),
195
+ attachments: ATTACHMENTS_INPUT,
201
196
  in_reply_to_message_id: z.string().uuid().optional().describe("A message id from mail_read: send this as a reply in that thread."),
202
197
  confirm: CONFIRM_INPUT,
203
198
  },
@@ -205,11 +200,20 @@ export function registerMailTools(server, deps) {
205
200
  const refusal = unconfirmed(args, "Sending mail reaches real people and cannot be recalled.");
206
201
  if (refusal)
207
202
  return refusal;
208
- const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/mail/send", composedBody(args));
203
+ // BLI-12661: the files are read and refused here, by name, before the door is asked.
204
+ const message = await prepareMessage(args);
205
+ if (!message.ok)
206
+ return errorResult(`Refused (${message.reason}): ${message.detail}`);
207
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/mail/send", message.body);
209
208
  if (!response.ok)
210
209
  return errorResult(doorFailureText("mail_send", response));
211
210
  const sent = response.body.sent;
212
- return textResult(`Sent from ${sent?.address ?? "that mailbox"} to ${args.to.join(", ")}${sent?.threaded ? " (as a reply)" : ""}.`, { sent });
211
+ // Report only what the door's receipt shows (BLI-12661), never what was posted.
212
+ const notConfirmed = unconfirmedAttachments(message.summary, sent?.attachments, "send");
213
+ if (notConfirmed)
214
+ return errorResult(notConfirmed);
215
+ const files = attachmentResult(message.summary);
216
+ return textResult(`Sent from ${sent?.address ?? "that mailbox"} to ${args.to.join(", ")}${sent?.threaded ? " (as a reply)" : ""}.${files.sentence}`, { sent, ...files.structured });
213
217
  }));
214
218
  register("mail_draft", {
215
219
  title: "Save a draft in one mailbox",
@@ -219,27 +223,40 @@ export function registerMailTools(server, deps) {
219
223
  + "draft is saved in (mail_accounts) — with several attached, that is never something to guess. "
220
224
  + "`in_reply_to_message_id` (a message id from mail_read) threads the draft into that conversation the way mail_send "
221
225
  + "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.",
226
+ + "did (`threaded`). `attachments` ([{ path }]) puts files from this machine in the draft, up to 10 and 3,000,000 bytes "
227
+ + "together; see that argument for what it refuses. Only the mailbox's OWNER can draft into it, and only Gmail "
228
+ + "mailboxes can take one: an IMAP mailbox is refused as imap_drafts_unsupported. Answers the provider draft id, "
229
+ + "the thread id and the account id.",
224
230
  inputSchema: {
225
231
  account_id: z.string().uuid().describe("The mailbox the draft is saved in (mail_accounts). Never guessed."),
226
232
  to: z.array(ADDRESS).min(1).max(50).describe("Recipient email addresses, so the draft is ready to send as it is."),
227
233
  cc: z.array(ADDRESS).max(50).optional().describe("Copied email addresses."),
228
234
  subject: z.string().max(998).describe("The subject line. For a reply, keep the original's subject or Gmail will not thread it."),
229
235
  text: z.string().min(1).max(500_000).describe("The plain-text body."),
236
+ attachments: ATTACHMENTS_INPUT,
230
237
  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
238
  },
232
239
  }, async (args) => withSession(deps, async (session) => {
233
- const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/mail/drafts", composedBody(args));
240
+ // BLI-12661: the files are read and refused here, by name, before the door is asked.
241
+ const message = await prepareMessage(args);
242
+ if (!message.ok)
243
+ return errorResult(`Refused (${message.reason}): ${message.detail}`);
244
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/mail/drafts", message.body);
234
245
  if (!response.ok)
235
246
  return errorResult(doorFailureText("mail_draft", response));
236
247
  const draft = response.body.draft;
248
+ // Report only what the door's receipt shows (BLI-12661), never what was posted.
249
+ const notConfirmed = unconfirmedAttachments(message.summary, draft?.attachments, "draft");
250
+ if (notConfirmed)
251
+ return errorResult(notConfirmed);
252
+ const files = attachmentResult(message.summary);
237
253
  // Both halves carry the ids (BLI-10432): a client that hands the model only
238
254
  // `content`, or only `structuredContent`, must still learn where the draft is.
239
255
  return textResult(`Saved a draft in ${draft?.address ?? "that mailbox"} for ${args.to.join(", ")}`
240
256
  + `${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 });
257
+ + "Nothing was sent: it waits in Drafts for a person to read, edit and send."
258
+ + `${files.sentence} `
259
+ + `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, ...files.structured });
243
260
  }));
244
261
  register("mail_attachment", {
245
262
  title: "Describe an attachment",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.85",
3
+ "version": "0.1.86",
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",