@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.
- package/dist/mail-attachments.d.ts +57 -0
- package/dist/mail-attachments.js +181 -0
- package/dist/mail-compose.d.ts +53 -0
- package/dist/mail-compose.js +93 -0
- package/dist/mail-tools.d.ts +8 -0
- package/dist/mail-tools.js +41 -24
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/mail-tools.d.ts
CHANGED
|
@@ -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.
|
package/dist/mail-tools.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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`).
|
|
223
|
-
+ "
|
|
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
|
-
|
|
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
|
-
+
|
|
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.
|
|
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",
|