@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.
package/dist/ops-tools.js CHANGED
@@ -40,6 +40,34 @@ export const UNHEALTHY_VERDICTS = new Set([
40
40
  // BLI-3762: a job that wrote less than it owed is not healthy. Amber counts.
41
41
  "degraded",
42
42
  ]);
43
+ /**
44
+ * A pipeline row as the board needs it. The door's row also carries its cron
45
+ * string, config file, host, artifact and a paragraph-long caveat per job,
46
+ * which the text half never printed: 34 KB for 26 jobs (BLI-10432).
47
+ */
48
+ function pipelineRow(row) {
49
+ return {
50
+ id: row.id ?? null,
51
+ label: row.label ?? null,
52
+ verdict: row.verdict ?? null,
53
+ latestIso: row.latestIso ?? null,
54
+ ageHours: row.ageHours ?? null,
55
+ expectedIntervalHours: row.expectedIntervalHours ?? null,
56
+ staleAfterHours: row.staleAfterHours ?? null,
57
+ detail: row.detail ?? null,
58
+ };
59
+ }
60
+ /**
61
+ * The fleet and coverage blocks without their per-device and per-person rows
62
+ * unless `people: true` asked for them. Those rows were 155 KB of the board's
63
+ * 175 KB structured half on 2026-09-26 (BLI-10432).
64
+ */
65
+ function summaryOnly(block, withPeople) {
66
+ if (withPeople || !block || typeof block !== "object" || Array.isArray(block))
67
+ return block;
68
+ const { devices: _devices, people: _people, ...rest } = block;
69
+ return rest;
70
+ }
43
71
  function numberOf(value) {
44
72
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
45
73
  }
@@ -51,14 +79,16 @@ export function registerOpsTools(server, deps) {
51
79
  register("ops_status", {
52
80
  title: "Read the Tower pipeline board",
53
81
  description: "Every scheduled job's verdict against its own expected interval, plus the collector fleet and collection "
54
- + "coverage. A job's `detail` is the server's sentence for why — relayed as it stands. Status only: nothing "
55
- + "here fires a job.",
82
+ + "coverage as one summary line each (`people: true` adds the per-device and per-person rows, about 150 KB). "
83
+ + "A job's `detail` is the server's sentence for why — relayed as it stands. The whole board can take 25 "
84
+ + "seconds to read. Status only: nothing here fires a job.",
56
85
  inputSchema: {
57
86
  job: z.string().min(1).max(100).optional().describe("One pipeline id instead of the whole board."),
58
87
  skips: z.boolean().optional().describe("Also return the open ingest-skip ledgers."),
59
88
  memory: z.boolean().optional().describe("Also return memory adoption and observed prompt-hook outcomes, delay bounds and cache reuse. No memory text."),
60
89
  turns: z.boolean().optional().describe("Also return 30-day JARVIS turn aggregates: latency, hops, tools, tokens and error classes."),
61
90
  tool_router: z.boolean().optional().describe("Also return the seven-day shadow tool-router agreement board."),
91
+ people: z.boolean().optional().describe("Also return every device and every person behind the fleet and coverage summaries. Large."),
62
92
  },
63
93
  }, async (args) => withSession(deps, async (session) => {
64
94
  const params = new URLSearchParams();
@@ -73,6 +103,8 @@ export function registerOpsTools(server, deps) {
73
103
  return errorResult(doorFailureText("ops_status", response));
74
104
  const body = response.body;
75
105
  const rows = (Array.isArray(body.pipelines) ? body.pipelines : []);
106
+ const fleetSummary = typeof asRecord(body.fleet).summary === "string" ? String(asRecord(body.fleet).summary) : "";
107
+ const coverageSummary = typeof asRecord(body.coverage).summary === "string" ? String(asRecord(body.coverage).summary) : "";
76
108
  const unhealthy = rows.filter((row) => UNHEALTHY_VERDICTS.has(row.verdict ?? ""));
77
109
  const lines = rows
78
110
  .map((row) => `${(row.verdict ?? "?").padEnd(14)} ${row.id ?? "?"} ${row.detail ?? row.label ?? ""}`.trimEnd())
@@ -134,6 +166,8 @@ export function registerOpsTools(server, deps) {
134
166
  return textResult(`${rows.length} pipeline(s), ${unhealthy.length} unhealthy`
135
167
  + `${unhealthy.length > 0 ? `: ${unhealthy.map((row) => row.id ?? "?").join(", ")}` : "."}`
136
168
  + `${lines ? `\n${lines}` : ""}`
169
+ + `${fleetSummary ? `\nFLEET ${fleetSummary}` : ""}`
170
+ + `${coverageSummary ? `\nCOVERAGE ${coverageSummary}` : ""}`
137
171
  + `${ladderLine ? `\n${ladderLine}` : ""}`
138
172
  + `${memoryLines ? `\n${memoryLines}` : ""}`
139
173
  + `${experienceLines ? `\n${experienceLines}` : ""}`
@@ -141,10 +175,10 @@ export function registerOpsTools(server, deps) {
141
175
  + `${toolRouterLine ? `\n${toolRouterLine}` : ""}`,
142
176
  // The API owns the wording and the timing boundary on every surface.
143
177
  {
144
- pipelines: rows,
178
+ pipelines: rows.map(pipelineRow),
145
179
  unhealthy: unhealthy.map((row) => row.id ?? "?"),
146
- ...(body.fleet ? { fleet: body.fleet } : {}),
147
- ...(body.coverage ? { coverage: body.coverage } : {}),
180
+ ...(body.fleet ? { fleet: summaryOnly(body.fleet, args.people === true) } : {}),
181
+ ...(body.coverage ? { coverage: summaryOnly(body.coverage, args.people === true) } : {}),
148
182
  ...(body.skips ? { skips: body.skips } : {}),
149
183
  ...(body.ladder ? { ladder: body.ladder } : {}),
150
184
  ...(memory?.ok ? { memory: memory.body.memory, hooks: memory.body.hooks, experience: memory.body.experience } : {}),
@@ -196,7 +230,7 @@ export function registerOpsTools(server, deps) {
196
230
  query: z.string().min(1).max(500).optional().describe("Text to look for."),
197
231
  since: z.string().min(1).max(30).optional().describe("YYYY-MM-DD or ISO-8601."),
198
232
  until: z.string().min(1).max(30).optional().describe("YYYY-MM-DD or ISO-8601."),
199
- limit: z.number().int().min(1).max(500).optional(),
233
+ limit: z.number().int().min(1).max(500).optional().describe("At most this many messages, newest first."),
200
234
  },
201
235
  }, async (args) => withSession(deps, async (session) => {
202
236
  const body = {};
package/dist/server.d.ts CHANGED
@@ -19,7 +19,13 @@ export interface ServerDeps {
19
19
  fetchImpl: FetchImpl;
20
20
  }
21
21
  export declare const PACKAGE_NAME = "@bli-cockpit/mcp";
22
- export declare const PACKAGE_VERSION = "0.1.4";
22
+ /**
23
+ * The version this server tells a client in `initialize`, read from the
24
+ * package's own `package.json` (which npm always ships). It was a literal
25
+ * `"0.1.4"` for fifty-six releases, so every client log named the wrong build
26
+ * (BLI-10432).
27
+ */
28
+ export declare const PACKAGE_VERSION: string;
23
29
  export declare const emitEventInput: {
24
30
  ticket_id: z.ZodString;
25
31
  event_type: z.ZodString;
@@ -31,7 +37,7 @@ export declare const emitEventInput: {
31
37
  }>>;
32
38
  source: z.ZodOptional<z.ZodString>;
33
39
  actor_role: z.ZodOptional<z.ZodString>;
34
- payload: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
40
+ payload: z.ZodOptional<z.ZodObject<{}, z.core.$loose>>;
35
41
  note_md: z.ZodOptional<z.ZodString>;
36
42
  correlation_id: z.ZodOptional<z.ZodString>;
37
43
  idempotency_key: z.ZodOptional<z.ZodString>;
package/dist/server.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * Pure factory — accepts injected fetch + base URL + token so it can be
5
5
  * unit-tested without binding to a real network or transport.
6
6
  */
7
+ import { createRequire } from "node:module";
7
8
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
9
  import { z } from "zod";
9
10
  import { registerBriefTools } from "./brief-tools.js";
@@ -30,7 +31,23 @@ import { registerWorkTools } from "./work-tools.js";
30
31
  import { registerCareersTools } from "./careers-tools.js";
31
32
  import { registerUsageTools } from "./usage-tools.js";
32
33
  export const PACKAGE_NAME = "@bli-cockpit/mcp";
33
- export const PACKAGE_VERSION = "0.1.4";
34
+ /**
35
+ * The version this server tells a client in `initialize`, read from the
36
+ * package's own `package.json` (which npm always ships). It was a literal
37
+ * `"0.1.4"` for fifty-six releases, so every client log named the wrong build
38
+ * (BLI-10432).
39
+ */
40
+ export const PACKAGE_VERSION = readPackageVersion();
41
+ function readPackageVersion() {
42
+ try {
43
+ const manifest = createRequire(import.meta.url)("../package.json");
44
+ return typeof manifest.version === "string" ? manifest.version : "unknown";
45
+ }
46
+ catch (error) {
47
+ process.stderr.write(`[bli-cockpit-mcp] package.json unreadable; serving version "unknown" (${error instanceof Error ? error.message.split("\n")[0] : String(error)})\n`);
48
+ return "unknown";
49
+ }
50
+ }
34
51
  // ---- input schemas (Zod raw shapes) -----------------------------------------
35
52
  export const emitEventInput = {
36
53
  ticket_id: z
@@ -44,7 +61,9 @@ export const emitEventInput = {
44
61
  event_version: z
45
62
  .number()
46
63
  .int()
47
- .positive()
64
+ // `.min(1)`, not `.positive()`: the latter publishes `exclusiveMinimum`,
65
+ // which strict clients reject (BLI-10432).
66
+ .min(1)
48
67
  .optional()
49
68
  .describe("Event schema version. Defaults to 1 in the API."),
50
69
  kind: z
@@ -62,7 +81,9 @@ export const emitEventInput = {
62
81
  .optional()
63
82
  .describe("Actor role for the event. Defaults to operator."),
64
83
  payload: z
65
- .record(z.string(), z.unknown())
84
+ // `looseObject`, not `record`: a record publishes `propertyNames`, a
85
+ // keyword strict clients reject (BLI-10432). Same values accepted.
86
+ .looseObject({})
66
87
  .optional()
67
88
  .describe("Event payload object. Defaults to {}."),
68
89
  note_md: z
@@ -335,7 +356,9 @@ function errorContent(err) {
335
356
  content: [
336
357
  {
337
358
  type: "text",
338
- text: code ? `[${code}] ${message}` : message,
359
+ // `ApiError`'s own message already opens with `[code]`; prefixing it
360
+ // again printed "[BLI-E003] [BLI-E003] …" (BLI-10432).
361
+ text: code && !message.startsWith(`[${code}]`) ? `[${code}] ${message}` : message,
339
362
  },
340
363
  ],
341
364
  };
@@ -43,6 +43,9 @@ function sectionPayload(response, door) {
43
43
  return { visible: false, reason: "admin_only" };
44
44
  return { visible: false, reason: doorFailureText(door, response) };
45
45
  }
46
+ function asRecord(value) {
47
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
48
+ }
46
49
  function sectionWord(response) {
47
50
  if (response.ok)
48
51
  return "ok";
@@ -55,7 +58,8 @@ async function oneSection(deps, session, tool, path) {
55
58
  const response = await readSection(deps, session, path);
56
59
  if (!response.ok)
57
60
  return errorResult(doorFailureText(tool, response));
58
- return textResult(JSON.stringify(response.body, null, 2), response.body);
61
+ // Compact JSON: indentation was a third of what a model paid for (BLI-10432).
62
+ return textResult(JSON.stringify(response.body), response.body);
59
63
  }
60
64
  export function registerSettingsTools(server, deps) {
61
65
  const register = registrarFor(server);
@@ -91,7 +95,7 @@ export function registerSettingsTools(server, deps) {
91
95
  .map((key) => `${key.padEnd(10)} ${sectionWord(byKey[key])}`)
92
96
  .join("\n");
93
97
  const payload = Object.fromEntries(keys.map((key) => [key, sectionPayload(byKey[key], `settings ${key}`)]));
94
- return textResult(`TOWER SETTINGS\n${lines}\n\n${JSON.stringify(payload, null, 2)}`, payload);
98
+ return textResult(`TOWER SETTINGS\n${lines}\n\n${JSON.stringify(payload)}`, payload);
95
99
  }));
96
100
  register("settings_list", {
97
101
  title: "List Tower env files",
@@ -112,17 +116,36 @@ export function registerSettingsTools(server, deps) {
112
116
  }));
113
117
  register("team_members", {
114
118
  title: "Read the Tower team directory",
115
- description: "Who is on this team, their roles, and any invitations still open.",
119
+ description: "Who is on this team: every person on the roster or with an account, their standing (account or roster "
120
+ + "only), Tower role and team role, and the user id team_role takes.",
116
121
  inputSchema: {},
117
122
  }, async () => withSession(deps, async (session) => {
118
123
  const response = await readSection(deps, session, "/api/team/members");
119
124
  if (!response.ok)
120
125
  return errorResult(doorFailureText("team_members", response));
121
- const members = (Array.isArray(response.body.members) ? response.body.members : []);
122
- const lines = members
123
- .map((member) => `${String(member.role ?? "?").padEnd(12)} ${String(member.email ?? member.userId ?? "?")}`)
126
+ // The door answers `people`, one row per person with an optional
127
+ // `account`. This read `members`, a key the door no longer sends, and
128
+ // answered "0 member(s)." over a twelve-person team (BLI-10432).
129
+ const people = (Array.isArray(response.body.people) ? response.body.people : []);
130
+ const team = asRecord(response.body.currentTeam);
131
+ const lines = people
132
+ .map((person) => {
133
+ const account = asRecord(person.account);
134
+ const name = String(person.name ?? person.email ?? person.key ?? "?");
135
+ const email = typeof person.email === "string" && person.email !== name ? ` <${person.email}>` : "";
136
+ const roles = typeof account.role === "string"
137
+ ? ` role ${account.role}${typeof account.teamRole === "string" ? `, team ${account.teamRole}` : ""}`
138
+ : "";
139
+ const id = typeof account.userId === "string" ? ` id ${account.userId}` : "";
140
+ return `${name}${email} ${String(person.standing ?? "?")}${person.active === false ? " (inactive)" : ""}${roles}${id}`;
141
+ })
124
142
  .join("\n");
125
- return textResult(`${members.length} member(s).${lines ? `\n${lines}` : ""}`, response.body);
143
+ const heading = typeof team.name === "string" ? `${team.name}: ` : "";
144
+ const rosterError = typeof response.body.rosterError === "string"
145
+ ? ` The roster could not be read (${response.body.rosterError}), so people without an account may be missing.`
146
+ : "";
147
+ return textResult(`${heading}${people.length} person(s).${rosterError}${response.body.canManage === true ? "" : " Read-only: only an admin can invite or change roles."}`
148
+ + `${lines ? `\n${lines}` : ""}`, response.body);
126
149
  }));
127
150
  register("team_device_list", {
128
151
  title: "List the collector devices Tower knows",
@@ -134,9 +157,13 @@ export function registerSettingsTools(server, deps) {
134
157
  if (!response.ok)
135
158
  return errorResult(doorFailureText("team_device_list", response));
136
159
  const devices = (Array.isArray(response.body.devices) ? response.body.devices : []);
160
+ // The door's row names its owner and trust state, and carries no
161
+ // `cli_version`: every line used to print "?" there (BLI-10432). These
162
+ // are the fields `cockpit team device list` prints.
137
163
  const lines = devices
138
164
  .map((device) => `${String(device.device_id ?? "?")} ${String(device.device_name ?? "?")} `
139
- + `${String(device.cli_version ?? "?")} last seen ${String(device.last_seen_at ?? "never")}`)
165
+ + `${String(device.trust_state ?? "unknown")} owner ${String(device.owner_label ?? "?")} `
166
+ + `last seen ${String(device.last_seen_at ?? "never")}${typeof device.summary === "string" ? ` ${device.summary}` : ""}`)
140
167
  .join("\n");
141
168
  return textResult(`${devices.length} device(s).${lines ? `\n${lines}` : ""}`, response.body);
142
169
  }));
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Every `bli-tower` tool, sorted by what calling it DOES (BLI-10432).
3
+ *
4
+ * `npm run mcp:smoke` calls every READ and every DRY-RUN against production
5
+ * and never a WRITE; `tool-catalog.test.ts` fails when a registered tool is in
6
+ * none of the three lists, so a new tool joins the smoke (or is declared a
7
+ * write) the day it is registered, not the day somebody notices.
8
+ *
9
+ * Deliberately dependency-free: `scripts/smoke.ts` imports this file straight
10
+ * from `src/` under Node's type stripping, so it may import nothing.
11
+ */
12
+ /** Tools that change nothing. The smoke calls every one. */
13
+ export declare const READ_TOOLS: readonly ["get_ticket_timeline", "get_active_tickets", "docs_list", "docs_tree", "docs_read", "msg_channels", "msg_read", "msg_thread", "work_list_issues", "work_get_issue", "work_issue_history", "work_list_projects", "careers_list", "careers_show", "careers_resume_url", "careers_takehome_show", "careers_settings_show", "usage_people", "usage_sessions", "mail_accounts", "mail_inbox", "mail_read", "mail_search", "mail_attachment", "cal_today", "cal_week", "cal_next", "cal_find", "cal_calendars", "jarvis_ask", "jarvis_trace", "jarvis_check", "models_list", "models_card", "models_compare", "brief_read", "brief_history", "brief_status", "notes_folders", "notes_list", "notes_show", "notes_shelf", "notes_shelves", "notes_place", "notes_accounts", "ops_status", "slack_coverage", "slack_read", "memory_profile", "memory_consolidation", "settings_show", "settings_list", "model_show", "team_members", "team_device_list", "scout_board", "workbook_read", "tower_search"];
14
+ /**
15
+ * Tools that write, but answer a dry run that writes nothing. The smoke calls
16
+ * each one ONLY with its dry-run flag set (`scripts/smoke.ts` says which).
17
+ */
18
+ export declare const DRY_RUN_TOOLS: readonly ["ops_recompile", "memory_requeue"];
19
+ /**
20
+ * Tools that change real data: send mail, post messages, file issues, move
21
+ * candidates, share calendars. Never called by the smoke; covered by the unit
22
+ * suites (`write-tools.test.ts` and each family's own).
23
+ */
24
+ export declare const WRITE_TOOLS: readonly ["emit_event", "docs_create", "docs_update", "msg_create_channel", "msg_dm", "msg_send", "work_create_issue", "work_update_issue", "work_move_issue", "work_comment_issue", "careers_rescreen", "careers_send_takehome", "careers_invite", "careers_grade", "careers_decide", "careers_takehome_set", "careers_settings_set", "mail_send", "mail_sync", "mail_detach", "cal_create", "cal_share", "cal_sync", "cal_detach", "jarvis_dispatch", "notes_mkdir", "notes_rmdir", "notes_rename", "notes_upload", "notes_paste", "notes_share", "notes_unshare", "notes_move", "notes_sync", "notes_detach", "memory_experience", "scout_start", "scout_dismiss", "scout_undo", "brief_rewrite", "brief_correct", "brief_done", "brief_undone", "settings_set", "settings_delete", "model_set", "team_invite", "team_role", "team_device_revoke"];
25
+ /** Reads that spend a model turn. The smoke's `--no-model` leaves these out. */
26
+ export declare const MODEL_SPENDING_TOOLS: readonly ["jarvis_ask", "jarvis_check"];
27
+ /**
28
+ * Reads whose answer IS one whole item (a document, a transcript, a thread),
29
+ * so its size is the item's and not a list the caller failed to narrow. The
30
+ * smoke does not hold these to the per-half budget.
31
+ */
32
+ export declare const SINGLE_ITEM_TOOLS: readonly ["docs_read", "notes_show", "mail_read", "workbook_read", "jarvis_trace", "models_card", "careers_show", "memory_profile"];
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Every `bli-tower` tool, sorted by what calling it DOES (BLI-10432).
3
+ *
4
+ * `npm run mcp:smoke` calls every READ and every DRY-RUN against production
5
+ * and never a WRITE; `tool-catalog.test.ts` fails when a registered tool is in
6
+ * none of the three lists, so a new tool joins the smoke (or is declared a
7
+ * write) the day it is registered, not the day somebody notices.
8
+ *
9
+ * Deliberately dependency-free: `scripts/smoke.ts` imports this file straight
10
+ * from `src/` under Node's type stripping, so it may import nothing.
11
+ */
12
+ /** Tools that change nothing. The smoke calls every one. */
13
+ export const READ_TOOLS = [
14
+ "get_ticket_timeline",
15
+ "get_active_tickets",
16
+ "docs_list",
17
+ "docs_tree",
18
+ "docs_read",
19
+ "msg_channels",
20
+ "msg_read",
21
+ "msg_thread",
22
+ "work_list_issues",
23
+ "work_get_issue",
24
+ "work_issue_history",
25
+ "work_list_projects",
26
+ "careers_list",
27
+ "careers_show",
28
+ "careers_resume_url",
29
+ "careers_takehome_show",
30
+ "careers_settings_show",
31
+ "usage_people",
32
+ "usage_sessions",
33
+ "mail_accounts",
34
+ "mail_inbox",
35
+ "mail_read",
36
+ "mail_search",
37
+ "mail_attachment",
38
+ "cal_today",
39
+ "cal_week",
40
+ "cal_next",
41
+ "cal_find",
42
+ "cal_calendars",
43
+ "jarvis_ask",
44
+ "jarvis_trace",
45
+ "jarvis_check",
46
+ "models_list",
47
+ "models_card",
48
+ "models_compare",
49
+ "brief_read",
50
+ "brief_history",
51
+ "brief_status",
52
+ "notes_folders",
53
+ "notes_list",
54
+ "notes_show",
55
+ "notes_shelf",
56
+ "notes_shelves",
57
+ "notes_place",
58
+ "notes_accounts",
59
+ "ops_status",
60
+ "slack_coverage",
61
+ "slack_read",
62
+ "memory_profile",
63
+ "memory_consolidation",
64
+ "settings_show",
65
+ "settings_list",
66
+ "model_show",
67
+ "team_members",
68
+ "team_device_list",
69
+ "scout_board",
70
+ "workbook_read",
71
+ "tower_search",
72
+ ];
73
+ /**
74
+ * Tools that write, but answer a dry run that writes nothing. The smoke calls
75
+ * each one ONLY with its dry-run flag set (`scripts/smoke.ts` says which).
76
+ */
77
+ export const DRY_RUN_TOOLS = ["ops_recompile", "memory_requeue"];
78
+ /**
79
+ * Tools that change real data: send mail, post messages, file issues, move
80
+ * candidates, share calendars. Never called by the smoke; covered by the unit
81
+ * suites (`write-tools.test.ts` and each family's own).
82
+ */
83
+ export const WRITE_TOOLS = [
84
+ "emit_event",
85
+ "docs_create",
86
+ "docs_update",
87
+ "msg_create_channel",
88
+ "msg_dm",
89
+ "msg_send",
90
+ "work_create_issue",
91
+ "work_update_issue",
92
+ "work_move_issue",
93
+ "work_comment_issue",
94
+ "careers_rescreen",
95
+ "careers_send_takehome",
96
+ "careers_invite",
97
+ "careers_grade",
98
+ "careers_decide",
99
+ "careers_takehome_set",
100
+ "careers_settings_set",
101
+ "mail_send",
102
+ "mail_sync",
103
+ "mail_detach",
104
+ "cal_create",
105
+ "cal_share",
106
+ "cal_sync",
107
+ "cal_detach",
108
+ "jarvis_dispatch",
109
+ "notes_mkdir",
110
+ "notes_rmdir",
111
+ "notes_rename",
112
+ "notes_upload",
113
+ "notes_paste",
114
+ "notes_share",
115
+ "notes_unshare",
116
+ "notes_move",
117
+ "notes_sync",
118
+ "notes_detach",
119
+ "memory_experience",
120
+ "scout_start",
121
+ "scout_dismiss",
122
+ "scout_undo",
123
+ "brief_rewrite",
124
+ "brief_correct",
125
+ "brief_done",
126
+ "brief_undone",
127
+ "settings_set",
128
+ "settings_delete",
129
+ "model_set",
130
+ "team_invite",
131
+ "team_role",
132
+ "team_device_revoke",
133
+ ];
134
+ /** Reads that spend a model turn. The smoke's `--no-model` leaves these out. */
135
+ export const MODEL_SPENDING_TOOLS = ["jarvis_ask", "jarvis_check"];
136
+ /**
137
+ * Reads whose answer IS one whole item (a document, a transcript, a thread),
138
+ * so its size is the item's and not a list the caller failed to narrow. The
139
+ * smoke does not hold these to the per-half budget.
140
+ */
141
+ export const SINGLE_ITEM_TOOLS = [
142
+ "docs_read",
143
+ "notes_show",
144
+ "mail_read",
145
+ "workbook_read",
146
+ "jarvis_trace",
147
+ "models_card",
148
+ "careers_show",
149
+ "memory_profile",
150
+ ];
@@ -36,6 +36,37 @@ export interface DoorSession {
36
36
  deviceToken: string;
37
37
  }
38
38
  export declare function textResult(text: string, structured?: Record<string, unknown>): ToolResult;
39
+ /**
40
+ * A result whose TEXT half carries the data, not just a headline (BLI-10432).
41
+ *
42
+ * MCP clients do not agree on which half of a tool result the model reads.
43
+ * Claude Code, claude.ai and Claude Desktop hand the model `content` and
44
+ * render `structuredContent` for the person (or drop it); Codex hands the
45
+ * model `structuredContent` and drops `content`. So a tool that answered
46
+ * `"5 document(s)."` in text and the rows in `structuredContent` was blind on
47
+ * every Claude surface: the model was told five documents exist and never
48
+ * given one id. Every read is written so EACH half stands alone. Use this when
49
+ * there is no terser rendering worth writing by hand: the headline, then the
50
+ * structured half as one line of compact JSON.
51
+ */
52
+ export declare function dataResult(headline: string, structured: Record<string, unknown>): ToolResult;
53
+ /**
54
+ * The per-half byte budget a DEFAULT read is held to (BLI-10432): about 8k
55
+ * tokens. A list over it names how to narrow instead of shipping everything;
56
+ * a single-item read (one document, one transcript) may exceed it because the
57
+ * item IS what was asked for. `scripts/smoke.ts` measures every read against
58
+ * it and the per-call log below warns when a result crosses it.
59
+ */
60
+ export declare const RESULT_BUDGET_BYTES: number;
61
+ /**
62
+ * The page a list returns when the caller named no `limit` (BLI-10432).
63
+ * `work_list_issues` with no limit returned 1,000 issues WITH descriptions,
64
+ * 4.2 MB, which no agent can afford; a list now answers a page and says when
65
+ * more exist.
66
+ */
67
+ export declare const DEFAULT_LIST_LIMIT = 50;
68
+ /** "…, and more exist — pass a larger limit (up to N) or narrow the filter." */
69
+ export declare function moreNote(more: boolean, max: number): string;
39
70
  export declare function errorResult(text: string): ToolResult;
40
71
  /**
41
72
  * The session is loaded fresh per call (a cheap file read) rather than once at
@@ -63,7 +94,16 @@ export type RegisterTool = (name: string, config: {
63
94
  description: string;
64
95
  inputSchema: Record<string, unknown>;
65
96
  }, handler: (args: Record<string, unknown>) => Promise<ToolResult>) => unknown;
66
- /** The one cast, so no tool family repeats it. */
97
+ /**
98
+ * The one cast, so no tool family repeats it — and the one place every
99
+ * agent-door call is logged (BLI-10432): tool, outcome, milliseconds and the
100
+ * byte size of each half. Metadata only, never an argument or a body. A result
101
+ * over `RESULT_BUDGET_BYTES` logs as a warning so an oversized read names
102
+ * itself in the client's MCP log instead of silently eating a context window.
103
+ * `BLI_MCP_QUIET_TOOL_LOG=1` silences the answered and refused lines (the unit
104
+ * suites set it, since they exercise refusals on purpose); a handler that
105
+ * threw always prints.
106
+ */
67
107
  export declare function registrarFor(server: {
68
108
  registerTool: (...args: never[]) => unknown;
69
109
  }): RegisterTool;
@@ -93,5 +133,16 @@ export declare function queryString(params: URLSearchParams): string;
93
133
  * `server.test.ts` walks every registered tool's shape for exactly this.
94
134
  */
95
135
  export declare const CONFIRM_INPUT: z.ZodOptional<z.ZodBoolean>;
136
+ /**
137
+ * An email address, in a schema every client can compile (BLI-10432).
138
+ *
139
+ * `z.string().email()` publishes zod's own address pattern, which opens with
140
+ * two negative lookaheads (`^(?!\.)(?!.*\.\.)…`). Lookaround is not RE2, and
141
+ * RE2 is what OpenAI's strict tool schemas and Google's function declarations
142
+ * compile patterns with, so one `mail_send` in the tool list was enough for a
143
+ * strict client to refuse the whole server. This pattern is lookaround-free;
144
+ * the mail and calendar doors validate the address again on the server.
145
+ */
146
+ export declare const EMAIL_INPUT: z.ZodString;
96
147
  /** `null` when the caller confirmed; the refusal itself when they did not. */
97
148
  export declare function unconfirmed(args: Record<string, unknown>, sentence: string): ToolResult | null;
@@ -18,6 +18,41 @@ import { loadAgentDoorSession } from "./agent-door-session.js";
18
18
  export function textResult(text, structured) {
19
19
  return { content: [{ type: "text", text }], ...(structured ? { structuredContent: structured } : {}) };
20
20
  }
21
+ /**
22
+ * A result whose TEXT half carries the data, not just a headline (BLI-10432).
23
+ *
24
+ * MCP clients do not agree on which half of a tool result the model reads.
25
+ * Claude Code, claude.ai and Claude Desktop hand the model `content` and
26
+ * render `structuredContent` for the person (or drop it); Codex hands the
27
+ * model `structuredContent` and drops `content`. So a tool that answered
28
+ * `"5 document(s)."` in text and the rows in `structuredContent` was blind on
29
+ * every Claude surface: the model was told five documents exist and never
30
+ * given one id. Every read is written so EACH half stands alone. Use this when
31
+ * there is no terser rendering worth writing by hand: the headline, then the
32
+ * structured half as one line of compact JSON.
33
+ */
34
+ export function dataResult(headline, structured) {
35
+ return textResult(`${headline}\n${JSON.stringify(structured)}`, structured);
36
+ }
37
+ /**
38
+ * The per-half byte budget a DEFAULT read is held to (BLI-10432): about 8k
39
+ * tokens. A list over it names how to narrow instead of shipping everything;
40
+ * a single-item read (one document, one transcript) may exceed it because the
41
+ * item IS what was asked for. `scripts/smoke.ts` measures every read against
42
+ * it and the per-call log below warns when a result crosses it.
43
+ */
44
+ export const RESULT_BUDGET_BYTES = 32 * 1024;
45
+ /**
46
+ * The page a list returns when the caller named no `limit` (BLI-10432).
47
+ * `work_list_issues` with no limit returned 1,000 issues WITH descriptions,
48
+ * 4.2 MB, which no agent can afford; a list now answers a page and says when
49
+ * more exist.
50
+ */
51
+ export const DEFAULT_LIST_LIMIT = 50;
52
+ /** "…, and more exist — pass a larger limit (up to N) or narrow the filter." */
53
+ export function moreNote(more, max) {
54
+ return more ? ` More exist — pass a larger limit (up to ${max}) or narrow the filter.` : "";
55
+ }
21
56
  export function errorResult(text) {
22
57
  return { isError: true, content: [{ type: "text", text }] };
23
58
  }
@@ -69,9 +104,49 @@ function doorSentence(body, status) {
69
104
  const said = [headline ?? message, ...lines].filter(Boolean);
70
105
  return said.length > 0 ? said.join(" ") : `Tower answered ${status}.`;
71
106
  }
72
- /** The one cast, so no tool family repeats it. */
107
+ /**
108
+ * The one cast, so no tool family repeats it — and the one place every
109
+ * agent-door call is logged (BLI-10432): tool, outcome, milliseconds and the
110
+ * byte size of each half. Metadata only, never an argument or a body. A result
111
+ * over `RESULT_BUDGET_BYTES` logs as a warning so an oversized read names
112
+ * itself in the client's MCP log instead of silently eating a context window.
113
+ * `BLI_MCP_QUIET_TOOL_LOG=1` silences the answered and refused lines (the unit
114
+ * suites set it, since they exercise refusals on purpose); a handler that
115
+ * threw always prints.
116
+ */
73
117
  export function registrarFor(server) {
74
- return server.registerTool.bind(server);
118
+ const register = server.registerTool.bind(server);
119
+ return (name, config, handler) => register(name, config, async (args) => {
120
+ const started = Date.now();
121
+ let result;
122
+ try {
123
+ result = await handler(args);
124
+ }
125
+ catch (error) {
126
+ // A handler that throws would surface as a bare protocol error with no
127
+ // reason label; name it, log it, and answer by name instead.
128
+ const reason = error instanceof Error ? error.message.split("\n")[0] ?? "unknown" : String(error);
129
+ logToolCall("error", { tool: name, outcome: "threw", ms: Date.now() - started, reason: reason.slice(0, 200) });
130
+ return errorResult(`${name} failed inside the bli-tower server (tool_threw): ${reason}`);
131
+ }
132
+ const textBytes = Buffer.byteLength(result.content.map((part) => part.text).join(""));
133
+ const structuredBytes = result.structuredContent ? Buffer.byteLength(JSON.stringify(result.structuredContent)) : 0;
134
+ const oversized = Math.max(textBytes, structuredBytes) > RESULT_BUDGET_BYTES;
135
+ logToolCall(result.isError || oversized ? "warn" : "info", {
136
+ tool: name,
137
+ outcome: result.isError ? "refused" : oversized ? "answered_oversized" : "answered",
138
+ ms: Date.now() - started,
139
+ text_bytes: textBytes,
140
+ structured_bytes: structuredBytes,
141
+ });
142
+ return result;
143
+ });
144
+ }
145
+ function logToolCall(level, fields) {
146
+ if (level !== "error" && process.env.BLI_MCP_QUIET_TOOL_LOG === "1")
147
+ return;
148
+ // stdout is the MCP transport; every log line goes to stderr.
149
+ process.stderr.write(`[bli-cockpit-mcp tool] ${level} ${JSON.stringify(fields)}\n`);
75
150
  }
76
151
  /** `?a=1&b=2`, or "" when nothing was asked for. Never a bare `?`. */
77
152
  export function queryString(params) {
@@ -108,6 +183,21 @@ export const CONFIRM_INPUT = z
108
183
  .boolean()
109
184
  .optional()
110
185
  .describe("Must be true. This act is deliberate and is refused without it — the same rule as the CLI's --yes.");
186
+ /**
187
+ * An email address, in a schema every client can compile (BLI-10432).
188
+ *
189
+ * `z.string().email()` publishes zod's own address pattern, which opens with
190
+ * two negative lookaheads (`^(?!\.)(?!.*\.\.)…`). Lookaround is not RE2, and
191
+ * RE2 is what OpenAI's strict tool schemas and Google's function declarations
192
+ * compile patterns with, so one `mail_send` in the tool list was enough for a
193
+ * strict client to refuse the whole server. This pattern is lookaround-free;
194
+ * the mail and calendar doors validate the address again on the server.
195
+ */
196
+ export const EMAIL_INPUT = z
197
+ .string()
198
+ .min(3)
199
+ .max(320)
200
+ .regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/u, "an email address");
111
201
  /** `null` when the caller confirmed; the refusal itself when they did not. */
112
202
  export function unconfirmed(args, sentence) {
113
203
  if (args.confirm === true)