@bli-cockpit/mcp 0.1.59 → 0.1.63

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.
@@ -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")
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,11 +4,13 @@
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";
10
11
  import { registerBriefWriteTools } from "./brief-write-tools.js";
11
12
  import { registerDocsMsgTools } from "./docs-msg-tools.js";
13
+ import { registerDocsNotionTools } from "./docs-notion-tools.js";
12
14
  import { registerJarvisTools } from "./jarvis-tools.js";
13
15
  import { registerCalTools } from "./cal-tools.js";
14
16
  import { registerMailTools } from "./mail-tools.js";
@@ -30,7 +32,23 @@ import { registerWorkTools } from "./work-tools.js";
30
32
  import { registerCareersTools } from "./careers-tools.js";
31
33
  import { registerUsageTools } from "./usage-tools.js";
32
34
  export const PACKAGE_NAME = "@bli-cockpit/mcp";
33
- export const PACKAGE_VERSION = "0.1.4";
35
+ /**
36
+ * The version this server tells a client in `initialize`, read from the
37
+ * package's own `package.json` (which npm always ships). It was a literal
38
+ * `"0.1.4"` for fifty-six releases, so every client log named the wrong build
39
+ * (BLI-10432).
40
+ */
41
+ export const PACKAGE_VERSION = readPackageVersion();
42
+ function readPackageVersion() {
43
+ try {
44
+ const manifest = createRequire(import.meta.url)("../package.json");
45
+ return typeof manifest.version === "string" ? manifest.version : "unknown";
46
+ }
47
+ catch (error) {
48
+ process.stderr.write(`[bli-cockpit-mcp] package.json unreadable; serving version "unknown" (${error instanceof Error ? error.message.split("\n")[0] : String(error)})\n`);
49
+ return "unknown";
50
+ }
51
+ }
34
52
  // ---- input schemas (Zod raw shapes) -----------------------------------------
35
53
  export const emitEventInput = {
36
54
  ticket_id: z
@@ -44,7 +62,9 @@ export const emitEventInput = {
44
62
  event_version: z
45
63
  .number()
46
64
  .int()
47
- .positive()
65
+ // `.min(1)`, not `.positive()`: the latter publishes `exclusiveMinimum`,
66
+ // which strict clients reject (BLI-10432).
67
+ .min(1)
48
68
  .optional()
49
69
  .describe("Event schema version. Defaults to 1 in the API."),
50
70
  kind: z
@@ -62,7 +82,9 @@ export const emitEventInput = {
62
82
  .optional()
63
83
  .describe("Actor role for the event. Defaults to operator."),
64
84
  payload: z
65
- .record(z.string(), z.unknown())
85
+ // `looseObject`, not `record`: a record publishes `propertyNames`, a
86
+ // keyword strict clients reject (BLI-10432). Same values accepted.
87
+ .looseObject({})
66
88
  .optional()
67
89
  .describe("Event payload object. Defaults to {}."),
68
90
  note_md: z
@@ -335,7 +357,9 @@ function errorContent(err) {
335
357
  content: [
336
358
  {
337
359
  type: "text",
338
- text: code ? `[${code}] ${message}` : message,
360
+ // `ApiError`'s own message already opens with `[code]`; prefixing it
361
+ // again printed "[BLI-E003] [BLI-E003] …" (BLI-10432).
362
+ text: code && !message.startsWith(`[${code}]`) ? `[${code}] ${message}` : message,
339
363
  },
340
364
  ],
341
365
  };
@@ -392,6 +416,8 @@ export function createServer(deps) {
392
416
  // no `cockpit login` pairing yet still serves the three event tools; only a
393
417
  // docs/msg call on that machine fails, by name.
394
418
  registerDocsMsgTools(server, { fetchImpl: deps.fetchImpl });
419
+ // BLI-10430: docs_link, the Notion half of the docs tools, on the same path.
420
+ registerDocsNotionTools(server, { fetchImpl: deps.fetchImpl });
395
421
  // BLI-3716: `work_*` — the issue tracker, on the same device-token path as
396
422
  // the docs/msg tools above, for the same reason: a coding session should
397
423
  // file and move a Tower issue the way it files and moves a Linear one.
@@ -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", "docs_sync_status", "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", "docs_link", "msg_create_channel", "msg_dm", "msg_send", "work_create_issue", "work_update_issue", "work_move_issue", "work_comment_issue", "careers_rescreen", "careers_send_takehome", "careers_invite", "careers_grade", "careers_decide", "careers_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"];