@bli-cockpit/mcp 0.1.58 → 0.1.61

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.
@@ -11,7 +11,25 @@
11
11
  */
12
12
  import { z } from "zod";
13
13
  import { callAgentDoor } from "./agent-door.js";
14
- import { doorFailureText, errorResult, registrarFor, textResult, withSession, } from "./tool-result.js";
14
+ import { DEFAULT_LIST_LIMIT, doorFailureText, errorResult, moreNote, registrarFor, textResult, withSession, } from "./tool-result.js";
15
+ /** A canonical uuid: a ref that already IS an id needs no list read to resolve. */
16
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
17
+ /** The door's own ceiling on `?limit=` (`/api/docs/documents` `DOCUMENT_LIST_MAX_LIMIT`). */
18
+ const DOCS_LIST_MAX = 1000;
19
+ /**
20
+ * The tree as the text half prints it: id, title, slug, children. The door
21
+ * also sends visibility, source and a timestamp per node, which nothing here
22
+ * promised and which made the structured half 48 KB for 226 documents
23
+ * (BLI-10432). `docs_list` carries those fields for a flat read.
24
+ */
25
+ function slimTree(nodes) {
26
+ return nodes.map((node) => ({
27
+ id: node.id,
28
+ title: node.title,
29
+ slug: node.slug ?? null,
30
+ children: slimTree(Array.isArray(node.children) ? node.children : []),
31
+ }));
32
+ }
15
33
  /** Indented lines, deepest last — the same shape `cockpit docs tree` prints. */
16
34
  function renderDocTree(nodes, depth) {
17
35
  return nodes
@@ -25,6 +43,27 @@ function renderDocTree(nodes, depth) {
25
43
  function countTree(nodes) {
26
44
  return nodes.reduce((count, node) => count + 1 + countTree(node.children ?? []), 0);
27
45
  }
46
+ /**
47
+ * The fields `docs_list` promises, and only those. The door's row also carries
48
+ * `created_by`, `updated_by`, `created_at`, `import_truncated` and
49
+ * `missing_block_count`: 105 KB for 226 documents before this (BLI-10432).
50
+ */
51
+ function docListRow(row) {
52
+ return {
53
+ id: row.id,
54
+ slug: row.slug ?? null,
55
+ title: row.title,
56
+ visibility: row.visibility,
57
+ source: row.source ?? null,
58
+ parent_id: row.parent_id ?? null,
59
+ updated_at: row.updated_at,
60
+ body_chars: row.body_chars ?? null,
61
+ };
62
+ }
63
+ function docListLine(row) {
64
+ return `${row.id} ${row.slug ?? "-"} ${row.title} [${row.visibility}${row.source ? `, ${row.source}` : ""}, `
65
+ + `${row.body_chars ?? "?"} chars, parent ${row.parent_id ?? "root"}, updated ${row.updated_at}]`;
66
+ }
28
67
  /**
29
68
  * `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
30
69
  * The list itself is metadata only — asking for a whole library used to cost
@@ -39,10 +78,18 @@ export function docsListPath(args) {
39
78
  params.set("query", args.query);
40
79
  if (typeof args.limit === "number" && Number.isInteger(args.limit))
41
80
  params.set("limit", String(args.limit));
81
+ // No limit named: one page plus one row, so "more exist" is read off the
82
+ // door instead of guessed from a full page (BLI-10432).
83
+ else if (args.limit === undefined)
84
+ params.set("limit", String(DEFAULT_LIST_LIMIT + 1));
42
85
  const search = params.toString();
43
86
  return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
44
87
  }
45
88
  async function resolveDocumentId(deps, session, ref) {
89
+ // An id needs no lookup: the read itself answers 404 by name when it is
90
+ // wrong. Only a slug costs the whole-library list read (BLI-10432).
91
+ if (UUID_PATTERN.test(ref))
92
+ return { status: "ok", id: ref };
46
93
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
47
94
  if (!response.ok)
48
95
  return { status: "list_failed", text: doorFailureText("docs_list", response) };
@@ -59,24 +106,33 @@ async function resolveChannelId(deps, session, ref) {
59
106
  const match = channels.find((channel) => channel.id === ref || channel.name === bare);
60
107
  return match ? { status: "ok", id: match.id } : { status: "not_found" };
61
108
  }
109
+ /** One message as a line: id (msg_thread's `thread_parent_id`), time, author, text. */
110
+ function messageLine(message) {
111
+ return `${String(message.id ?? "?")} ${String(message.created_at ?? "?")} `
112
+ + `${String(message.agent_label ?? message.user_id ?? "?")} ${String(message.content ?? "")}`;
113
+ }
62
114
  export function registerDocsMsgTools(server, deps) {
63
115
  const register = registrarFor(server);
64
116
  register("docs_list", {
65
117
  title: "List Tower documents",
66
118
  description: "Every document you may read: id, slug, title, visibility, source, parent_id and body_chars (how long the body is). "
67
119
  + "Never a document's body — call docs_read for that. Narrow before reading: parent_id (a document id, or \"root\" for the "
68
- + "top of the tree), query (a case-insensitive substring matched in the database over title AND body, never fuzzy), limit (1-1000).",
120
+ + "top of the tree), query (a case-insensitive substring matched in the database over title AND body, never fuzzy), "
121
+ + `limit (1-1000, default ${DEFAULT_LIST_LIMIT}; the answer says when more exist).`,
69
122
  inputSchema: {
70
123
  parent_id: z.string().min(1).max(64).optional().describe('A document id to list under, or "root" for the top of the tree.'),
71
124
  query: z.string().min(1).max(200).optional().describe("Keep only documents whose title or body contains this text."),
72
- limit: z.number().int().min(1).max(1000).optional().describe("At most this many documents."),
125
+ limit: z.number().int().min(1).max(1000).optional().describe(`At most this many documents. Default ${DEFAULT_LIST_LIMIT}.`),
73
126
  },
74
127
  }, async (args) => withSession(deps, async (session) => {
75
128
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", docsListPath(args));
76
129
  if (!response.ok)
77
130
  return errorResult(doorFailureText("docs_list", response));
78
- const documents = response.body.documents ?? [];
79
- return textResult(`${documents.length} document(s).`, { documents });
131
+ const all = (Array.isArray(response.body.documents) ? response.body.documents : []);
132
+ const more = args.limit === undefined && all.length > DEFAULT_LIST_LIMIT;
133
+ const rows = more ? all.slice(0, DEFAULT_LIST_LIMIT) : all;
134
+ const lines = rows.map(docListLine).join("\n");
135
+ return textResult(`${rows.length} document(s).${moreNote(more, DOCS_LIST_MAX)}${lines ? `\n${lines}` : ""}`, { documents: rows.map(docListRow), more });
80
136
  }));
81
137
  register("docs_tree", {
82
138
  title: "Read the Tower document tree",
@@ -87,9 +143,9 @@ export function registerDocsMsgTools(server, deps) {
87
143
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/tree");
88
144
  if (!response.ok)
89
145
  return errorResult(doorFailureText("docs_tree", response));
90
- const tree = (Array.isArray(response.body.tree) ? response.body.tree : []);
146
+ const tree = slimTree((Array.isArray(response.body.tree) ? response.body.tree : []));
91
147
  const rendered = renderDocTree(tree, 0);
92
- return textResult(`${countTree(tree)} document(s).${rendered ? `\n${rendered}` : ""}`, { tree });
148
+ return textResult(`${countTree(tree)} document(s), each as "title (slug or id)"; docs_read takes either.${rendered ? `\n${rendered}` : ""}`, { tree });
93
149
  }));
94
150
  register("docs_read", {
95
151
  title: "Read a Tower document",
@@ -112,10 +168,10 @@ export function registerDocsMsgTools(server, deps) {
112
168
  title: "Create a Tower document",
113
169
  description: "Creates a new document. Omitting body creates an empty one, matching the browser's own default.",
114
170
  inputSchema: {
115
- title: z.string().min(1).max(200),
116
- body_markdown: z.string().max(200_000).optional(),
117
- parent_id: z.string().uuid().optional(),
118
- visibility: z.enum(["org", "private"]).optional(),
171
+ title: z.string().min(1).max(200).describe("The document's title."),
172
+ body_markdown: z.string().max(200_000).optional().describe("The body, markdown. Omit for an empty document."),
173
+ parent_id: z.string().uuid().optional().describe("A document id to nest this one under. Omit for the top of the tree."),
174
+ visibility: z.enum(["org", "private"]).optional().describe("org: everyone signed in may read it. private: only you."),
119
175
  },
120
176
  }, async (args) => withSession(deps, async (session) => {
121
177
  const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/docs/documents", {
@@ -135,10 +191,10 @@ export function registerDocsMsgTools(server, deps) {
135
191
  + "pass change. Emptying a document that holds text needs allow_empty: true.",
136
192
  inputSchema: {
137
193
  id: z.string().min(1).max(200).describe("A document id, or its slug."),
138
- title: z.string().min(1).max(200).optional(),
139
- body_markdown: z.string().max(200_000).optional(),
140
- visibility: z.enum(["org", "private"]).optional(),
141
- parent_id: z.string().uuid().nullable().optional(),
194
+ title: z.string().min(1).max(200).optional().describe("A new title."),
195
+ body_markdown: z.string().max(200_000).optional().describe("The WHOLE new body, markdown. It replaces the old one; it is not appended."),
196
+ visibility: z.enum(["org", "private"]).optional().describe("org: everyone signed in may read it. private: only you."),
197
+ parent_id: z.string().uuid().nullable().optional().describe("Move it under this document id; null moves it to the top of the tree."),
142
198
  allow_empty: z
143
199
  .boolean()
144
200
  .optional()
@@ -169,21 +225,40 @@ export function registerDocsMsgTools(server, deps) {
169
225
  }));
170
226
  register("msg_channels", {
171
227
  title: "List Tower channels",
172
- description: "Every channel you are a member of.",
228
+ description: "Every channel you are a member of: id, name (a direct message has none and lists its participants instead), "
229
+ + "private, and description. msg_read takes the id or the name.",
173
230
  inputSchema: {},
174
231
  }, async () => withSession(deps, async (session) => {
175
232
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/msg/channels");
176
233
  if (!response.ok)
177
234
  return errorResult(doorFailureText("msg_channels", response));
178
- const channels = response.body.channels ?? [];
179
- return textResult(`${channels.length} channel(s).`, { channels });
235
+ const channels = (Array.isArray(response.body.channels) ? response.body.channels : []);
236
+ const rows = channels.map((channel) => ({
237
+ id: channel.id,
238
+ name: channel.name ?? null,
239
+ description: channel.description ?? null,
240
+ is_private: channel.is_private === true,
241
+ is_dm: channel.is_dm === true,
242
+ ...(channel.is_dm === true ? { dm_participants: channel.dm_participants ?? [] } : {}),
243
+ archived_at: channel.archived_at ?? null,
244
+ }));
245
+ const lines = rows
246
+ .map((row) => {
247
+ const label = row.is_dm
248
+ ? `DM with ${JSON.stringify(row.dm_participants ?? [])}`
249
+ : `#${String(row.name ?? "?")}${row.is_private ? " (private)" : ""}`;
250
+ return `${String(row.id)} ${label}${row.archived_at ? " [archived]" : ""}`
251
+ + `${row.description ? ` ${String(row.description)}` : ""}`;
252
+ })
253
+ .join("\n");
254
+ return textResult(`${rows.length} channel(s).${lines ? `\n${lines}` : ""}`, { channels: rows });
180
255
  }));
181
256
  register("msg_read", {
182
257
  title: "Read a Tower channel",
183
258
  description: "Recent messages of one channel, newest first. `channel` is a channel id, or its name with or without a leading #.",
184
259
  inputSchema: {
185
- channel: z.string().min(1).max(200),
186
- limit: z.number().int().min(1).max(200).optional(),
260
+ channel: z.string().min(1).max(200).describe("A channel id, or its name with or without a leading #."),
261
+ limit: z.number().int().min(1).max(200).optional().describe("At most this many messages, newest first."),
187
262
  },
188
263
  }, async (args) => withSession(deps, async (session) => {
189
264
  const ref = String(args.channel ?? "");
@@ -196,15 +271,19 @@ export function registerDocsMsgTools(server, deps) {
196
271
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/msg/channels/${encodeURIComponent(resolved.id)}/messages${query}`);
197
272
  if (!response.ok)
198
273
  return errorResult(doorFailureText("msg_read", response));
199
- const messages = response.body.messages ?? [];
200
- return textResult(`${messages.length} message(s) in ${ref}.`, { channelId: resolved.id, messages });
274
+ const messages = (Array.isArray(response.body.messages) ? response.body.messages : []);
275
+ // The text half carries the messages themselves, each with its id,
276
+ // which is what msg_thread takes. It used to say only "2 message(s)",
277
+ // so a Claude-family client never saw one (BLI-10432).
278
+ const lines = messages.map(messageLine).join("\n");
279
+ return textResult(`${messages.length} message(s) in ${ref}, newest first; each line is id, time, author, text.${lines ? `\n${lines}` : ""}`, { channelId: resolved.id, messages });
201
280
  }));
202
281
  register("msg_thread", {
203
282
  title: "Read a Tower message thread",
204
283
  description: "The replies under one message, oldest first. `thread_parent_id` is the id of the message the thread hangs "
205
284
  + "off — msg_read returns it. `channel` is a channel id, or its name with or without a leading #.",
206
285
  inputSchema: {
207
- channel: z.string().min(1).max(200),
286
+ channel: z.string().min(1).max(200).describe("A channel id, or its name with or without a leading #."),
208
287
  thread_parent_id: z.string().uuid().describe("The id of the message the replies hang off."),
209
288
  },
210
289
  }, async (args) => withSession(deps, async (session) => {
@@ -221,11 +300,7 @@ export function registerDocsMsgTools(server, deps) {
221
300
  const messages = (Array.isArray(response.body.messages) ? response.body.messages : []);
222
301
  // Oldest first: a thread reads as a conversation, unlike the channel
223
302
  // list, which the door returns newest-first.
224
- const lines = [...messages]
225
- .reverse()
226
- .map((message) => `${String(message.created_at ?? "?")} ${String(message.agent_label ?? message.user_id ?? "?")} `
227
- + `${String(message.content ?? "")}`)
228
- .join("\n");
303
+ const lines = [...messages].reverse().map(messageLine).join("\n");
229
304
  return textResult(`${messages.length} repl${messages.length === 1 ? "y" : "ies"} in ${ref}.${lines ? `\n${lines}` : ""}`, { channelId: resolved.id, threadParentId: parent, messages });
230
305
  }));
231
306
  register("msg_create_channel", {
@@ -233,9 +308,9 @@ export function registerDocsMsgTools(server, deps) {
233
308
  description: "Creates a channel and returns its id. `private` means membership decides who may read it. `member_emails` names who joins at birth BY EMAIL — an address Tower does not carry refuses the whole create, so no half-built channel is left behind.",
234
309
  inputSchema: {
235
310
  name: z.string().min(1).max(80).describe("The channel name. A leading # is accepted and dropped."),
236
- description: z.string().max(500).optional(),
237
- private: z.boolean().optional(),
238
- member_emails: z.array(z.string().min(3).max(320)).max(200).optional(),
311
+ description: z.string().max(500).optional().describe("One line saying what the channel is for."),
312
+ private: z.boolean().optional().describe("true: only members may read it. Default false."),
313
+ member_emails: z.array(z.string().min(3).max(320)).max(200).optional().describe("Email addresses of the people who join at birth. You join either way."),
239
314
  },
240
315
  }, async (args) => withSession(deps, async (session) => {
241
316
  const raw = String(args.name ?? "");
@@ -281,9 +356,9 @@ export function registerDocsMsgTools(server, deps) {
281
356
  title: "Send a Tower message",
282
357
  description: "Posts a message to one channel. `channel` is a channel id, or its name with or without a leading #.",
283
358
  inputSchema: {
284
- channel: z.string().min(1).max(200),
285
- content: z.string().min(1).max(8_000),
286
- thread_parent_id: z.string().uuid().optional(),
359
+ channel: z.string().min(1).max(200).describe("A channel id, or its name with or without a leading #."),
360
+ content: z.string().min(1).max(8_000).describe("The message text."),
361
+ thread_parent_id: z.string().uuid().optional().describe("Reply in the thread under this message id (msg_read prints ids)."),
287
362
  },
288
363
  }, async (args) => withSession(deps, async (session) => {
289
364
  const ref = String(args.channel ?? "");
@@ -26,12 +26,12 @@
26
26
  */
27
27
  import { z } from "zod";
28
28
  import { callAgentDoor } from "./agent-door.js";
29
- import { CONFIRM_INPUT, doorFailureText, errorResult, queryString, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
29
+ import { CONFIRM_INPUT, EMAIL_INPUT, doorFailureText, errorResult, queryString, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
30
30
  function messageLine(message) {
31
31
  const from = message.from_address?.address ?? "(no sender)";
32
32
  return `${message.is_unread ? "*" : " "} ${message.internal_date.slice(0, 16)} ${from} ${message.subject ?? "(no subject)"} [thread ${message.thread_id ?? "?"}]`;
33
33
  }
34
- const ADDRESS = z.string().email().max(320);
34
+ const ADDRESS = EMAIL_INPUT.describe("An email address.");
35
35
  export function registerMailTools(server, deps) {
36
36
  const register = registrarFor(server);
37
37
  register("mail_accounts", {
@@ -56,11 +56,11 @@ export function registerMailTools(server, deps) {
56
56
  description: "Messages across every mailbox you may read, newest first. Never a body — call mail_read on a thread for that. "
57
57
  + "`account_id` narrows to one mailbox; `unread` and `label` filter; `before` (an ISO instant) pages backwards.",
58
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(),
59
+ account_id: z.string().uuid().optional().describe("One mailbox id (mail_accounts). Omit for every mailbox you may read."),
60
+ unread: z.boolean().optional().describe("true: unread messages only."),
61
+ label: z.string().min(1).max(100).optional().describe("Only messages carrying this provider label, e.g. INBOX or IMPORTANT."),
62
+ before: z.string().min(1).max(40).optional().describe("An ISO instant: messages received before it, for the next page back."),
63
+ limit: z.number().int().min(1).max(200).optional().describe("At most this many messages, newest first."),
64
64
  },
65
65
  }, async (args) => withSession(deps, async (session) => {
66
66
  const query = new URLSearchParams();
@@ -84,30 +84,51 @@ export function registerMailTools(server, deps) {
84
84
  }));
85
85
  register("mail_read", {
86
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() },
87
+ description: "One thread with every message's text body, oldest first, and each message's attachments by id (the id "
88
+ + "mail_attachment takes). Thread ids come from mail_inbox or mail_search. Read the thread before saying what "
89
+ + "somebody wrote — a snippet is the first 240 characters and nothing more.",
90
+ inputSchema: { thread_id: z.string().uuid().describe("A thread id, as mail_inbox or mail_search print it.") },
90
91
  }, async (args) => withSession(deps, async (session) => {
91
92
  const threadId = String(args.thread_id ?? "");
92
- const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/mail/threads/${encodeURIComponent(threadId)}`);
93
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET",
94
+ // `?attachments=1` adds each message's attachment pointers. Without
95
+ // it no tool here ever showed an attachment id, so mail_attachment
96
+ // could not be reached from an agent at all (BLI-10432).
97
+ `/api/mail/threads/${encodeURIComponent(threadId)}?attachments=1`);
93
98
  if (!response.ok)
94
99
  return errorResult(doorFailureText("mail_read", response));
95
100
  const thread = response.body.thread;
96
101
  const messages = (Array.isArray(response.body.messages) ? response.body.messages : []);
102
+ const attachments = (response.body.attachments && typeof response.body.attachments === "object"
103
+ ? response.body.attachments
104
+ : {});
97
105
  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())
106
+ .map((message) => {
107
+ const files = (attachments[message.id] ?? [])
108
+ .map((file) => `\n attachment ${file.id} ${file.filename ?? "(unnamed)"} ${file.mime_type ?? "?"} ${file.size_bytes ?? "?"} bytes${file.is_inline ? " (inline)" : ""}`)
109
+ .join("");
110
+ return `--- ${message.internal_date} ${message.from_address?.address ?? "(no sender)"} [${message.id}]\n`
111
+ + (message.body_text ?? message.snippet ?? "(no text body — this message is HTML only)").trim()
112
+ + files;
113
+ })
100
114
  .join("\n\n");
101
- return textResult(`${thread?.subject ?? "(no subject)"} — ${thread?.account_address ?? "?"}, ${messages.length} message(s)\n\n${rendered}`, { thread, messages });
115
+ // The HTML body is the text body again with markup, and nothing on
116
+ // this surface renders it; its length stays so an HTML-only message
117
+ // still says it has one.
118
+ const slim = messages.map(({ body_html: html, ...rest }) => ({
119
+ ...rest,
120
+ body_html_chars: typeof html === "string" ? html.length : 0,
121
+ }));
122
+ return textResult(`${thread?.subject ?? "(no subject)"} — ${thread?.account_address ?? "?"}, ${messages.length} message(s)\n\n${rendered}`, { thread, messages: slim, attachments });
102
123
  }));
103
124
  register("mail_search", {
104
125
  title: "Search your mail",
105
126
  description: "Full text over the subject and plain-text body of every message you may read. Quoted phrases and OR work the way "
106
127
  + "they do in a search box. Returns headers only; call mail_read on a hit's thread to read it.",
107
128
  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(),
129
+ query: z.string().min(1).max(400).describe("Words to find. Quoted phrases and OR work."),
130
+ account_id: z.string().uuid().optional().describe("One mailbox id (mail_accounts). Omit for every mailbox you may read."),
131
+ limit: z.number().int().min(1).max(200).optional().describe("At most this many matches, newest first."),
111
132
  },
112
133
  }, async (args) => withSession(deps, async (session) => {
113
134
  const query = new URLSearchParams({ q: String(args.query ?? "") });
@@ -129,12 +150,12 @@ export function registerMailTools(server, deps) {
129
150
  + "that is never something to guess. `in_reply_to_message_id` (a message id from mail_read) threads the reply. "
130
151
  + "This actually sends mail to real people: confirm must be true.",
131
152
  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(),
153
+ account_id: z.string().uuid().describe("The mailbox it goes out from (mail_accounts). Never guessed."),
154
+ to: z.array(ADDRESS).min(1).max(50).describe("Recipient email addresses."),
155
+ cc: z.array(ADDRESS).max(50).optional().describe("Copied email addresses."),
156
+ subject: z.string().max(998).describe("The subject line."),
157
+ text: z.string().min(1).max(500_000).describe("The plain-text body."),
158
+ in_reply_to_message_id: z.string().uuid().optional().describe("A message id from mail_read: send this as a reply in that thread."),
138
159
  confirm: CONFIRM_INPUT,
139
160
  },
140
161
  }, async (args) => withSession(deps, async (session) => {
@@ -160,7 +181,7 @@ export function registerMailTools(server, deps) {
160
181
  title: "Describe an attachment",
161
182
  description: "The filename, mime type and size of one attachment. Attachments are POINTERS in Tower — nothing is stored — and "
162
183
  + "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() },
184
+ inputSchema: { attachment_id: z.string().uuid().describe("An attachment id, as mail_read lists it.") },
164
185
  }, async (args) => withSession(deps, async (session) => {
165
186
  const id = String(args.attachment_id ?? "");
166
187
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/mail/attachments/${encodeURIComponent(id)}?meta=1`);
@@ -174,7 +195,7 @@ export function registerMailTools(server, deps) {
174
195
  title: "Read a mailbox now",
175
196
  description: "Runs one sync pass on one mailbox instead of waiting for the 15-minute cron, and answers with what landed or the "
176
197
  + "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() },
198
+ inputSchema: { account_id: z.string().uuid().describe("A mailbox id you own (mail_accounts).") },
178
199
  }, async (args) => withSession(deps, async (session) => {
179
200
  const id = String(args.account_id ?? "");
180
201
  const response = await callAgentDoor(session, deps.fetchImpl, "POST", `/api/mail/accounts/${encodeURIComponent(id)}/sync`);
@@ -190,7 +211,7 @@ export function registerMailTools(server, deps) {
190
211
  title: "Detach a mailbox",
191
212
  description: "Removes a mailbox from Tower along with its stored mail and its credential. The mail itself is untouched at the "
192
213
  + "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 },
214
+ inputSchema: { account_id: z.string().uuid().describe("A mailbox id you own (mail_accounts)."), confirm: CONFIRM_INPUT },
194
215
  }, async (args) => withSession(deps, async (session) => {
195
216
  const refusal = unconfirmed(args, "Detaching removes this mailbox's stored mail from Tower.");
196
217
  if (refusal)
@@ -16,6 +16,20 @@
16
16
  import { z } from "zod";
17
17
  import { callAgentDoor } from "./agent-door.js";
18
18
  import { doorFailureText, errorResult, queryString, registrarFor, textResult, withSession, } from "./tool-result.js";
19
+ /** `{merge: 2, rewrite: 1}` as `merge 2, rewrite 1`; `none` when empty. */
20
+ function counts(value) {
21
+ if (!value || typeof value !== "object")
22
+ return "none";
23
+ const entries = Object.entries(value);
24
+ return entries.length === 0 ? "none" : entries.map(([key, count]) => `${key} ${String(count)}`).join(", ");
25
+ }
26
+ function runLine(run) {
27
+ const cost = typeof run.costUsd === "number" ? `$${run.costUsd.toFixed(4)}` : "$?";
28
+ return `${String(run.startedAt ?? "?")} ${String(run.containerTag ?? "?")} ${String(run.mode ?? "?")} `
29
+ + `clusters ${String(run.clusters ?? 0)}; proposed ${counts(run.proposed)}; applied ${counts(run.applied)}; `
30
+ + `refused ${counts(run.refused)}; ${cost}${run.model ? ` ${String(run.model)}` : ""}`
31
+ + `${run.reason ? ` (${String(run.reason)})` : ""}`;
32
+ }
19
33
  export function registerMemoryConsolidationTools(server, deps) {
20
34
  registrarFor(server)("memory_consolidation", {
21
35
  title: "Read the nightly memory consolidation runs",
@@ -44,10 +58,14 @@ export function registerMemoryConsolidationTools(server, deps) {
44
58
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/memory/consolidation${queryString(params)}`);
45
59
  if (!response.ok)
46
60
  return errorResult(doorFailureText("memory_consolidation", response));
47
- const runs = Array.isArray(response.body.runs) ? response.body.runs : [];
61
+ const runs = (Array.isArray(response.body.runs) ? response.body.runs : []);
48
62
  const reason = typeof response.body.reason === "string" ? response.body.reason : "runs_read";
63
+ // One line per run in the text half. It used to carry only the count,
64
+ // so a Claude-family client never saw a run (BLI-10432).
65
+ const lines = runs.map(runLine).join("\n");
49
66
  return textResult(runs.length === 0
50
67
  ? `No consolidation runs are visible (${reason}). The pass is a nightly cron and its receipts are readable by a super_admin; this is the access rule answering, not an outage.`
51
- : `${runs.length} consolidation run${runs.length === 1 ? "" : "s"}. report = proposals only, nothing on the shelf changed; apply = accepted edits written as supersessions, which never delete anything.`, response.body);
68
+ : `${runs.length} consolidation run${runs.length === 1 ? "" : "s"}. report = proposals only, nothing on the shelf changed; apply = accepted edits written as supersessions, which never delete anything.`
69
+ + `\n${lines}`, response.body);
52
70
  }));
53
71
  }
@@ -7,7 +7,11 @@ export function registerMemoryExperienceTools(server, deps) {
7
7
  registrarFor(server)("memory_experience", {
8
8
  title: "Log a memory-store experience",
9
9
  description: "Append an experience receipt locally and post to Tower. Offline receipts ship on the next collector sync. Copy the recall marker: [bli] = BLI Memory, ◪ = Supermemory. Opinions only; never prompts, memory bodies or secrets.",
10
- inputSchema: { store: z.enum(["bli", "supermemory", "both"]), verdict: z.enum(["win", "loss", "noise"]), reason: z.string().min(1).max(500) },
10
+ inputSchema: {
11
+ store: z.enum(["bli", "supermemory", "both"]).describe("Which store the recall came from: [bli] is BLI Memory, ◪ is Supermemory."),
12
+ verdict: z.enum(["win", "loss", "noise"]).describe("win: it shaped the answer. loss: it misled or was missing. noise: it came back and did not matter."),
13
+ reason: z.string().min(1).max(500).describe("Why, in one line. Never a prompt, a memory body or a secret."),
14
+ },
11
15
  }, async (args) => {
12
16
  const entry = await appendExperience({ store: args.store, verdict: args.verdict, reason: String(args.reason) }, { homeDir: deps.homeDir, agent: "MCP", project: "workspace" });
13
17
  const delivery = await shipExperience(entry, async (body) => {
@@ -28,7 +28,7 @@ export function registerModelsTools(server, deps) {
28
28
  description: "Every model Tower knows about: the provider's documented context window, price per million, reasoning "
29
29
  + "modes and tool support, beside our measured latency, observed tokens/s and last probe verdict. "
30
30
  + "`STALE:<field>` means a measurement contradicted the documentation, with the command that re-reads the "
31
- + "provider. Read-only.",
31
+ + "provider. One line per model; models_card reads one model in full. Read-only.",
32
32
  inputSchema: {},
33
33
  }, async () => withSession(deps, async (session) => {
34
34
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/models/cards");
@@ -39,10 +39,23 @@ export function registerModelsTools(server, deps) {
39
39
  const lines = (Array.isArray(body.lines) ? body.lines : []);
40
40
  const stale = cards.filter((card) => (card.stale?.length ?? 0) > 0).map((card) => card.id ?? "?");
41
41
  const unreadable = (Array.isArray(body.unreadable) ? body.unreadable : []);
42
+ // The structured half is the shelf's INDEX, the same facts the lines
43
+ // print. Whole cards (documented, measured and bench blocks) were 39 KB
44
+ // for 14 models on 2026-09-26, and models_card reads one in full
45
+ // (BLI-10432).
46
+ const index = cards.map((card) => ({
47
+ id: card.id ?? null,
48
+ display_name: card.display_name ?? null,
49
+ provider: card.provider ?? null,
50
+ model: card.model ?? null,
51
+ status: card.status ?? null,
52
+ slots: card.slots ?? null,
53
+ stale: (card.stale ?? []).map((entry) => entry.field ?? "?"),
54
+ }));
42
55
  return textResult(`${cards.length} model card(s)`
43
56
  + `${stale.length > 0 ? `, ${stale.length} stale: ${stale.join(", ")}` : ", none stale"}`
44
57
  + `${unreadable.length > 0 ? `, ${unreadable.length} unreadable: ${unreadable.join(", ")}` : ""}`
45
- + `${lines.length > 0 ? `\n${lines.join("\n")}` : ""}`, { cards, stale, unreadable });
58
+ + `${lines.length > 0 ? `\n${lines.join("\n")}` : ""}`, { cards: index, lines, stale, unreadable });
46
59
  }));
47
60
  register("models_card", {
48
61
  title: "Read one model card",
@@ -4,8 +4,11 @@ import { registrarFor, withSession, textResult, errorResult, doorFailureText } f
4
4
  export function registerNoteFolderTools(server, deps) {
5
5
  const register = registrarFor(server);
6
6
  register("notes_folders", {
7
- title: "List note folders", description: "The folder tree with caller-visible note counts.",
8
- inputSchema: { tree: z.boolean().optional() },
7
+ // `tree` was declared here and never read: the door answers one shape,
8
+ // flat folders with their paths, and a knob that changes nothing is a
9
+ // description that lies (BLI-10432).
10
+ title: "List note folders", description: "Every note folder you may see, as a flat list of paths with the note count in each, plus how many notes sit at the root.",
11
+ inputSchema: {},
9
12
  }, async () => withSession(deps, async (session) => {
10
13
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/notes/folders");
11
14
  if (!response.ok)
@@ -14,7 +17,7 @@ export function registerNoteFolderTools(server, deps) {
14
17
  }));
15
18
  register("notes_mkdir", {
16
19
  title: "Create a note folder", description: "Create a path, including missing ancestors. Existing paths are reused.",
17
- inputSchema: { path: z.string().min(1).max(2000) },
20
+ inputSchema: { path: z.string().min(1).max(2000).describe("A slash-separated folder path, e.g. Clients/Blue Pearl.") },
18
21
  }, async (args) => withSession(deps, async (session) => {
19
22
  const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/notes/folders", { path: args.path });
20
23
  if (!response.ok)
@@ -23,7 +26,7 @@ export function registerNoteFolderTools(server, deps) {
23
26
  }));
24
27
  register("notes_rmdir", {
25
28
  title: "Delete an empty note folder", description: "Delete a folder by path. Refuses any folder with notes or children.",
26
- inputSchema: { path: z.string().min(1).max(2000) },
29
+ inputSchema: { path: z.string().min(1).max(2000).describe("The folder's slash-separated path, as notes_folders lists it.") },
27
30
  }, async (args) => withSession(deps, async (session) => {
28
31
  const listed = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/notes/folders");
29
32
  if (!listed.ok)
@@ -40,7 +43,10 @@ export function registerNoteFolderTools(server, deps) {
40
43
  }));
41
44
  register("notes_rename", {
42
45
  title: "Rename a note folder", description: "Rename a folder by path, updating every descendant path.",
43
- inputSchema: { path: z.string().min(1).max(2000), name: z.string().min(1).max(120) },
46
+ inputSchema: {
47
+ path: z.string().min(1).max(2000).describe("The folder's slash-separated path, as notes_folders lists it."),
48
+ name: z.string().min(1).max(120).describe("The folder's new last segment. Its descendants move with it."),
49
+ },
44
50
  }, async (args) => withSession(deps, async (session) => {
45
51
  const listed = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/notes/folders");
46
52
  if (!listed.ok)
@@ -50,7 +50,7 @@ export function registerNotesAccountTools(server, deps) {
50
50
  description: "Reads the meetings this notetaker has recorded since the last pass and files them as meeting notes. "
51
51
  + "Answers with the run's own counts and reason label, which is exactly what the ledger row says. "
52
52
  + "The half-hourly cron does this unasked; this is the button for when you do not want to wait.",
53
- inputSchema: { account_id: z.string().uuid() },
53
+ inputSchema: { account_id: z.string().uuid().describe("A notetaker account id (notes_accounts).") },
54
54
  }, async (args) => withSession(deps, async (session) => {
55
55
  const response = await callAgentDoor(session, deps.fetchImpl, "POST", `/api/notes/accounts/${encodeURIComponent(String(args.account_id))}/sync`);
56
56
  if (!response.ok)
@@ -64,7 +64,7 @@ export function registerNotesAccountTools(server, deps) {
64
64
  title: "Detach a notetaker",
65
65
  description: "Removes a notetaker account and its stored key from Tower. The account itself is untouched at the provider, "
66
66
  + "and the meetings it already filed stay where they are. Requires confirm: true.",
67
- inputSchema: { account_id: z.string().uuid(), confirm: CONFIRM_INPUT },
67
+ inputSchema: { account_id: z.string().uuid().describe("A notetaker account id (notes_accounts)."), confirm: CONFIRM_INPUT },
68
68
  }, async (args) => withSession(deps, async (session) => {
69
69
  const refusal = unconfirmed(args, "This would detach that notetaker and forget its stored key (the account itself is untouched at the provider, and the meetings it filed stay).");
70
70
  if (refusal)
@@ -18,7 +18,7 @@ import { registerNoteFolderTools } from "./note-folder-tools.js";
18
18
  */
19
19
  import { z } from "zod";
20
20
  import { callAgentDoor } from "./agent-door.js";
21
- import { doorFailureText, errorResult, registrarFor, textResult, queryString, withSession, } from "./tool-result.js";
21
+ import { DEFAULT_LIST_LIMIT, doorFailureText, errorResult, registrarFor, textResult, queryString, withSession, } from "./tool-result.js";
22
22
  /**
23
23
  * The door's own words for "this answer is narrower than the browser's", or
24
24
  * "" when it was not narrowed. Never rephrased here.
@@ -48,7 +48,7 @@ const LIBRARY_FILTERS = {
48
48
  kind: z.string().min(1).max(100).optional().describe("Only this meeting kind."),
49
49
  since: z.string().min(1).max(20).optional().describe("YYYY-MM-DD — meetings on or after this date."),
50
50
  until: z.string().min(1).max(20).optional().describe("YYYY-MM-DD — meetings on or before this date."),
51
- limit: z.number().int().min(1).max(500).optional(),
51
+ limit: z.number().int().min(1).max(500).optional().describe("At most this many notes."),
52
52
  };
53
53
  export function registerNotesTools(server, deps) {
54
54
  registerNoteFolderTools(server, deps);
@@ -56,10 +56,17 @@ export function registerNotesTools(server, deps) {
56
56
  register("notes_list", {
57
57
  title: "List Tower meeting notes",
58
58
  description: "The meeting-notes library you may read, grouped by shelf: id, date, kind, participants and file name. "
59
+ + `Newest ${DEFAULT_LIST_LIMIT} unless you pass limit (up to 500); the answer says when more exist. `
59
60
  + "Never a note's text — call notes_show for one. Says when the answer is narrower than a browser's.",
60
61
  inputSchema: LIBRARY_FILTERS,
61
62
  }, async (args) => withSession(deps, async (session) => {
62
- const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/notes/library${queryString(libraryQuery(args))}`);
63
+ // A page by default: the whole library was 136 notes and 58 KB on
64
+ // 2026-09-26, mostly participant lists (BLI-10432). The door reports
65
+ // `more` itself, so nothing here guesses.
66
+ const params = libraryQuery(args);
67
+ if (args.limit === undefined)
68
+ params.set("limit", String(DEFAULT_LIST_LIMIT));
69
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/notes/library${queryString(params)}`);
63
70
  if (!response.ok)
64
71
  return errorResult(doorFailureText("notes_list", response));
65
72
  const body = response.body;
@@ -100,7 +107,7 @@ export function registerNotesTools(server, deps) {
100
107
  title: "Your own Tower notes shelf",
101
108
  description: "The notes THIS machine's owner put in, with the per-note counts (statements, open to the team, kept back) "
102
109
  + "when the server knows them. Says so when it does not, rather than printing zeros.",
103
- inputSchema: { limit: z.number().int().min(1).max(500).optional() },
110
+ inputSchema: { limit: z.number().int().min(1).max(500).optional().describe("At most this many notes.") },
104
111
  }, async (args) => withSession(deps, async (session) => {
105
112
  const params = new URLSearchParams();
106
113
  if (typeof args.limit === "number")