@bli-cockpit/mcp 0.1.29 → 0.1.31

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/README.md CHANGED
@@ -34,7 +34,7 @@ about what an agent can reach.
34
34
 
35
35
  <!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->
36
36
 
37
- **81 of 84 Tower verbs have an MCP twin.**
37
+ **90 of 93 Tower verbs have an MCP twin.**
38
38
  Each tool goes through the SAME door its CLI verb calls, with the same
39
39
  collector device token — never a second route and never a service-role
40
40
  reader. `src/verb-census.test.ts` fails when a verb is in none of the
@@ -55,6 +55,9 @@ three tables below.
55
55
  | `cockpit cal sync` | `cal_sync` | `POST /api/cal/calendars/[id]/sync` |
56
56
  | `cockpit cal today` | `cal_today` | `GET /api/cal/today` |
57
57
  | `cockpit cal week` | `cal_week` | `GET /api/cal/week` |
58
+ | `cockpit careers list` | `careers_list` | `GET /api/careers/applications` |
59
+ | `cockpit careers rescreen` | `careers_rescreen` | `POST /api/careers/applications/[id]/rescreen` |
60
+ | `cockpit careers show` | `careers_show` | `GET /api/careers/applications/[id]` |
58
61
  | `cockpit correct` | `brief_correct` | `POST /api/jarvis/corrections` |
59
62
  | `cockpit docs create` | `docs_create` | `POST /api/docs/documents` |
60
63
  | `cockpit docs list` | `docs_list` | `GET /api/docs/documents` |
@@ -94,9 +97,14 @@ three tables below.
94
97
  | `cockpit msg read` | `msg_read` | `GET /api/msg/channels/[id]/messages` |
95
98
  | `cockpit msg send` | `msg_send` | `POST /api/msg/channels/[id]/messages` |
96
99
  | `cockpit msg thread` | `msg_thread` | `GET /api/msg/channels/[id]/messages?thread_parent_id=` |
100
+ | `cockpit notes folders` | `notes_folders` | `GET /api/notes/folders` |
97
101
  | `cockpit notes list` | `notes_list` | `GET /api/notes/library` |
102
+ | `cockpit notes mkdir` | `notes_mkdir` | `POST /api/notes/folders` |
98
103
  | `cockpit notes move` | `notes_move` | `POST /api/notes/move` |
99
104
  | `cockpit notes paste` | `notes_paste` | `POST /api/notes/upload (text)` |
105
+ | `cockpit notes place` | `notes_place` | `POST /api/notes/place` |
106
+ | `cockpit notes rename` | `notes_rename` | `PATCH /api/notes/folders/[id]` |
107
+ | `cockpit notes rmdir` | `notes_rmdir` | `DELETE /api/notes/folders/[id]` |
100
108
  | `cockpit notes share` | `notes_share` | `POST /api/notes/share` |
101
109
  | `cockpit notes shelf` | `notes_shelf` | `GET /api/notes/shelf` |
102
110
  | `cockpit notes shelves` | `notes_shelves` | `GET /api/notes/library` |
@@ -111,17 +119,18 @@ three tables below.
111
119
  | `cockpit scout start` | `scout_start` | `POST /api/cockpit/scout (start)` |
112
120
  | `cockpit scout undo` | `scout_undo` | `POST /api/cockpit/scout (undo_dismiss)` |
113
121
  | `cockpit search` | `tower_search` | `GET /api/search` |
114
- | `cockpit settings delete` | `settings_delete` | `DELETE /api/settings/env-blobs` |
115
- | `cockpit settings list` | `settings_list` | `GET /api/settings/env-blobs` |
116
- | `cockpit settings set` | `settings_set` | `POST /api/settings/{jarvis-model,switches,model-routing,env-blobs} + PUT /api/settings/cli-floor` |
117
- | `cockpit settings show` | `settings_show` | `GET /api/settings/* + /api/team/members` |
122
+ | `cockpit settings env delete` | `settings_delete` | `DELETE /api/settings/env-blobs` |
123
+ | `cockpit settings env list` | `settings_list` | `GET /api/settings/env-blobs` |
124
+ | `cockpit settings switches set` | `settings_set` | `POST /api/settings/{jarvis-model,switches,model-routing,env-blobs} + PUT /api/settings/cli-floor` |
125
+ | `cockpit settings` | `settings_show` | `GET /api/settings/* + /api/team/members` |
118
126
  | `cockpit slack coverage` | `slack_coverage` | `GET /api/ops/slack/coverage` |
119
127
  | `cockpit slack read` | `slack_read` | `POST /api/ops/slack/read` |
120
- | `cockpit team device-list` | `team_device_list` | `GET /api/team/devices` |
121
- | `cockpit team device-revoke` | `team_device_revoke` | `POST /api/ambient/devices/[deviceId]/revoke` |
128
+ | `cockpit team device list` | `team_device_list` | `GET /api/team/devices` |
129
+ | `cockpit team device revoke` | `team_device_revoke` | `POST /api/ambient/devices/[deviceId]/revoke` |
122
130
  | `cockpit team invite` | `team_invite` | `POST /api/team/invite` |
123
131
  | `cockpit team members` | `team_members` | `GET /api/team/members` |
124
132
  | `cockpit team role` | `team_role` | `PATCH /api/team/members/[userId]/role` |
133
+ | `cockpit usage people` | `usage_people` | `GET /api/usage/people` |
125
134
  | `cockpit workbook` | `workbook_read` | `GET /api/cockpit/workbook` |
126
135
 
127
136
  ### Owed a twin
@@ -0,0 +1,4 @@
1
+ import { type ToolDeps } from './tool-result.js';
2
+ export declare function registerCareersTools(server: {
3
+ registerTool: (...args: never[]) => unknown;
4
+ }, deps: ToolDeps): void;
@@ -0,0 +1,19 @@
1
+ import { z } from 'zod';
2
+ import { callAgentDoor } from './agent-door.js';
3
+ import { doorFailureText, errorResult, registrarFor, textResult, withSession } from './tool-result.js';
4
+ export function registerCareersTools(server, deps) {
5
+ const register = registrarFor(server);
6
+ register('careers_list', { title: 'List applications', description: 'Super-admin only. Newest applications, bounded to 100 with total and has_more.', inputSchema: { role: z.string().optional(), min_score: z.number().min(0).max(100).optional(), since: z.string().optional() } }, async (args) => withSession(deps, async (session) => {
7
+ const query = new URLSearchParams();
8
+ for (const key of ['role', 'min_score', 'since'])
9
+ if (args[key] !== undefined)
10
+ query.set(key, String(args[key]));
11
+ const answer = await callAgentDoor(session, deps.fetchImpl, 'GET', `/api/careers/applications?${query}`);
12
+ return answer.ok ? textResult('Applications', answer.body) : errorResult(doorFailureText('careers_list', answer));
13
+ }));
14
+ for (const action of ['show', 'rescreen'])
15
+ register(`careers_${action}`, { title: `${action} application`, description: `Super-admin only. ${action} one application.`, inputSchema: { id: z.string().uuid() } }, async (args) => withSession(deps, async (session) => {
16
+ const answer = await callAgentDoor(session, deps.fetchImpl, action === 'show' ? 'GET' : 'POST', `/api/careers/applications/${encodeURIComponent(String(args.id))}${action === 'rescreen' ? '/rescreen' : ''}`, undefined, 60_000);
17
+ return answer.ok ? textResult('Application', answer.body) : errorResult(doorFailureText(`careers_${action}`, answer));
18
+ }));
19
+ }
@@ -0,0 +1,4 @@
1
+ import { type ToolDeps } from "./tool-result.js";
2
+ export declare function registerNoteFolderTools(server: {
3
+ registerTool: (...args: never[]) => unknown;
4
+ }, deps: ToolDeps): void;
@@ -0,0 +1,57 @@
1
+ import { z } from "zod";
2
+ import { callAgentDoor } from "./agent-door.js";
3
+ import { registrarFor, withSession, textResult, errorResult, doorFailureText } from "./tool-result.js";
4
+ export function registerNoteFolderTools(server, deps) {
5
+ const register = registrarFor(server);
6
+ register("notes_folders", {
7
+ title: "List note folders", description: "The folder tree with caller-visible note counts.",
8
+ inputSchema: { tree: z.boolean().optional() },
9
+ }, async () => withSession(deps, async (session) => {
10
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/notes/folders");
11
+ if (!response.ok)
12
+ return errorResult(doorFailureText("notes_folders", response));
13
+ return textResult(JSON.stringify(response.body), response.body);
14
+ }));
15
+ register("notes_mkdir", {
16
+ 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) },
18
+ }, async (args) => withSession(deps, async (session) => {
19
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/notes/folders", { path: args.path });
20
+ if (!response.ok)
21
+ return errorResult(doorFailureText("notes_mkdir", response));
22
+ return textResult(JSON.stringify(response.body), response.body);
23
+ }));
24
+ register("notes_rmdir", {
25
+ 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) },
27
+ }, async (args) => withSession(deps, async (session) => {
28
+ const listed = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/notes/folders");
29
+ if (!listed.ok)
30
+ return errorResult(doorFailureText("notes_rmdir", listed));
31
+ const body = listed.body;
32
+ const path = String(args.path).split("/").map(part => part.trim()).join("/");
33
+ const folder = body.folders.find(folder => folder.path === path);
34
+ if (!folder)
35
+ return errorResult("That folder could not be found.");
36
+ const response = await callAgentDoor(session, deps.fetchImpl, "DELETE", `/api/notes/folders/${encodeURIComponent(folder.id)}`);
37
+ if (!response.ok)
38
+ return errorResult(doorFailureText("notes_rmdir", response));
39
+ return textResult(JSON.stringify(response.body), response.body);
40
+ }));
41
+ register("notes_rename", {
42
+ 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) },
44
+ }, async (args) => withSession(deps, async (session) => {
45
+ const listed = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/notes/folders");
46
+ if (!listed.ok)
47
+ return errorResult(doorFailureText("notes_rename", listed));
48
+ const body = listed.body;
49
+ const folder = body.folders.find(folder => folder.path === String(args.path).split("/").map(part => part.trim()).join("/"));
50
+ if (!folder)
51
+ return errorResult("That folder could not be found.");
52
+ const response = await callAgentDoor(session, deps.fetchImpl, "PATCH", `/api/notes/folders/${encodeURIComponent(folder.id)}`, { name: args.name });
53
+ if (!response.ok)
54
+ return errorResult(doorFailureText("notes_rename", response));
55
+ return textResult(JSON.stringify(response.body), response.body);
56
+ }));
57
+ }
@@ -1,20 +1,3 @@
1
- /**
2
- * `notes_*` MCP tools (BLI-3756) — the meeting-notes library, read by an
3
- * agent, over the same `/api/notes/**` doors `cockpit notes` calls with the
4
- * same device token.
5
- *
6
- * **The narrowing is always said out loud.** `lib/notes/api-doors.ts` gives a
7
- * caller with no signed-in browser session a NARROWER read and names it in the
8
- * answer's own `scope`/`degradedBecause` — a device token is exactly such a
9
- * caller, so an agent reading this library is routinely seeing less than a
10
- * person would in a browser. Every tool here appends that reason to its text.
11
- * Swallowing it would let an agent conclude a note was never taken when in
12
- * fact it simply was not this caller's to read, which is a fact invented out
13
- * of a permission.
14
- *
15
- * Reads only. `notes move|share|unshare|paste|upload` are batch 2 — sharing is
16
- * a deliberate act and the paste door takes a body on stdin by rule.
17
- */
18
1
  import { type ToolDeps } from "./tool-result.js";
19
2
  export type NotesDeps = ToolDeps;
20
3
  interface ScopedBody {
@@ -1,3 +1,4 @@
1
+ import { registerNoteFolderTools } from "./note-folder-tools.js";
1
2
  /**
2
3
  * `notes_*` MCP tools (BLI-3756) — the meeting-notes library, read by an
3
4
  * agent, over the same `/api/notes/**` doors `cockpit notes` calls with the
@@ -50,6 +51,7 @@ const LIBRARY_FILTERS = {
50
51
  limit: z.number().int().min(1).max(500).optional(),
51
52
  };
52
53
  export function registerNotesTools(server, deps) {
54
+ registerNoteFolderTools(server, deps);
53
55
  const register = registrarFor(server);
54
56
  register("notes_list", {
55
57
  title: "List Tower meeting notes",
@@ -92,7 +94,7 @@ export function registerNotesTools(server, deps) {
92
94
  }
93
95
  const room = note.participants.length > 0 ? `\nIn the room: ${note.participants.join(", ")}` : "";
94
96
  return textResult(`${note.title}\n${note.meetingDate} · ${note.shelf} · ${note.fileName} · ${note.lineCount} lines`
95
- + `${room}\n${note.visibility}\n\n${note.content}${narrowingNote(body)}`, { scope: body.scope ?? null, note });
97
+ + `${room}\n${note.visibility}${body.audio ? "\nTranscription: " + body.audio.transcription_status : ""}\n\n${note.content}${narrowingNote(body)}`, { scope: body.scope ?? null, note, audio: body.audio ?? null });
96
98
  }));
97
99
  register("notes_shelf", {
98
100
  title: "Your own Tower notes shelf",
@@ -77,6 +77,9 @@ async function readNoteFile(filePath) {
77
77
  }
78
78
  if (size === 0)
79
79
  return refuse("file_empty");
80
+ if (/\.(mp3|m4a|wav|webm|ogg)$/i.test(fileName) && size > 4_000_000) {
81
+ return { ok: false, refusal: "file_too_big", sentence: "file_too_large: Audio is limited to 4 MB (4,000,000 bytes)." };
82
+ }
80
83
  if (size > NOTE_FILE_MAX_BYTES)
81
84
  return refuse("file_too_big");
82
85
  try {
@@ -102,18 +105,19 @@ async function postForm(deps, session, tool, form) {
102
105
  // `stored: false` is the door saying it did not keep the note. That is a
103
106
  // refusal with a reason, not a success with a caveat.
104
107
  return body.stored === true
105
- ? textResult(text, { ok: true, noteId: body.noteId ?? null, scope: body.scope ?? null })
108
+ ? textResult(text, { ok: true, noteId: body.noteId ?? null, scope: body.scope ?? null, transcription: body.transcription ?? null })
106
109
  : errorResult(text);
107
110
  }
108
111
  export function registerNotesWriteTools(server, deps) {
109
112
  const register = registrarFor(server);
110
113
  register("notes_upload", {
111
114
  title: "Put a local file in as a Tower meeting note",
112
- description: "Uploads a transcript or note file from THIS machine's disk the same door `cockpit notes upload` uses. "
115
+ description: "Uploads a transcript, note, or MP3/M4A/WAV/WebM/OGG audio file from this machine. Audio is limited to 4 MB and returns a note id plus transcription status immediately. Poll notes_show for completion. "
113
116
  + "A name that looks like a key or credential file is refused without being opened; so is a missing, empty or "
114
117
  + "over-20-MB file. Tower reads the whole note with one model call, so a large file can take a couple of "
115
118
  + "minutes and your client may give up first — the upload keeps going; notes_list will show it.",
116
119
  inputSchema: {
120
+ folder: z.string().min(1).max(2000).optional().describe("Folder path; creates missing ancestors. / means Root."),
117
121
  path: z.string().min(1).max(4096).describe("An absolute path on this machine."),
118
122
  exclude: z
119
123
  .string()
@@ -128,9 +132,13 @@ export function registerNotesWriteTools(server, deps) {
128
132
  if (!read.ok)
129
133
  return errorResult(`Refused (${read.refusal}): ${read.sentence} Nothing was sent.`);
130
134
  const form = new FormData();
131
- form.set("file", new File([new Uint8Array(read.bytes)], read.fileName));
135
+ const audioTypes = { mp3: "audio/mpeg", m4a: "audio/mp4", wav: "audio/wav", webm: "audio/webm", ogg: "audio/ogg" };
136
+ const mediaType = audioTypes[path.extname(read.fileName).slice(1).toLowerCase()] ?? "";
137
+ form.set("file", new File([new Uint8Array(read.bytes)], read.fileName, { type: mediaType }));
132
138
  if (args.exclude)
133
139
  form.set("exclusions", String(args.exclude));
140
+ if (args.folder)
141
+ form.set("folder_path", String(args.folder));
134
142
  return postForm(deps, session, "notes_upload", form);
135
143
  }));
136
144
  register("notes_paste", {
@@ -139,6 +147,7 @@ export function registerNotesWriteTools(server, deps) {
139
147
  + "`cockpit notes paste` posts to. The CLI takes the body on stdin; here it is an argument, so the text "
140
148
  + "travels in the request and is never echoed back to you.",
141
149
  inputSchema: {
150
+ folder: z.string().min(1).max(2000).optional().describe("Folder path; creates missing ancestors."),
142
151
  text: z.string().min(1).max(PASTE_MAX_CHARS).describe("The note itself."),
143
152
  name: z.string().min(1).max(300).optional().describe("What to call it. Tower names it by date if you do not."),
144
153
  exclude: z.string().min(1).max(2000).optional().describe("Anything to leave out, in plain words."),
@@ -154,6 +163,8 @@ export function registerNotesWriteTools(server, deps) {
154
163
  form.set("name", String(args.name));
155
164
  if (args.exclude)
156
165
  form.set("exclusions", String(args.exclude));
166
+ if (args.folder)
167
+ form.set("folder_path", String(args.folder));
157
168
  return postForm(deps, session, "notes_paste", form);
158
169
  }));
159
170
  register("notes_share", {
@@ -182,15 +193,19 @@ export function registerNotesWriteTools(server, deps) {
182
193
  + "own move box sends. A shelf is free text; notes_shelves lists the ones in use.",
183
194
  inputSchema: {
184
195
  note_id: z.string().min(1).max(200).describe("A note id."),
196
+ folder: z.string().min(1).max(2000).optional().describe("Destination folder path; / means Root. Leaves the shelf unchanged."),
185
197
  to: z.string().min(1).max(200).optional().describe("The shelf to move it to."),
186
198
  clear: z.boolean().optional().describe("Take it off every shelf instead."),
187
199
  },
188
200
  }, async (args) => withSession(deps, async (session) => {
189
201
  const clearing = args.clear === true;
190
- if (!clearing && !args.to) {
202
+ if (!clearing && !args.to && !args.folder) {
191
203
  return errorResult("Refused (no_destination): say which shelf with `to`, or pass `clear: true`. Nothing was moved.");
192
204
  }
193
- const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/notes/move", { note_id: String(args.note_id ?? ""), category: clearing ? "" : String(args.to) }, WRITE_DEADLINE_MS);
205
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/notes/move", { note_id: String(args.note_id ?? ""),
206
+ ...(args.folder ? { folder_path: String(args.folder) } : {}),
207
+ ...(clearing || args.to ? { category: clearing ? "" : String(args.to) } : {}),
208
+ }, WRITE_DEADLINE_MS);
194
209
  if (!response.ok)
195
210
  return errorResult(doorFailureText("notes_move", response));
196
211
  const body = response.body;
@@ -199,6 +214,31 @@ export function registerNotesWriteTools(server, deps) {
199
214
  ? textResult(text, { ok: true, shelf: body.shelf ?? null })
200
215
  : errorResult(text);
201
216
  }));
217
+ register("notes_place", {
218
+ title: "Say which shelf a Tower meeting note belongs on",
219
+ description: "Answers where one note files itself and WHY, from the meeting series it is part of, who was in the room "
220
+ + "and its own name — no model call. Read-only unless `apply: true`, which files it there. It never "
221
+ + "overrules a shelf a person typed: a note that already has one comes back as `already_shelved`.",
222
+ inputSchema: {
223
+ note_id: z.string().min(1).max(200).describe("A note id."),
224
+ apply: z.boolean().optional().describe("Actually put it there. Default: only say where."),
225
+ },
226
+ }, async (args) => withSession(deps, async (session) => {
227
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/notes/place", { note_id: String(args.note_id ?? ""), apply: args.apply === true }, WRITE_DEADLINE_MS);
228
+ if (!response.ok)
229
+ return errorResult(doorFailureText("notes_place", response));
230
+ const body = response.body;
231
+ const text = [body.headline ?? "Tower answered without a sentence.", ...(body.lines ?? [])].join("\n");
232
+ return body.ok === true
233
+ ? textResult(text, {
234
+ ok: true,
235
+ shelf: body.shelf ?? null,
236
+ suggested_shelf: body.suggestedShelf ?? null,
237
+ decided_by: body.decidedBy ?? null,
238
+ applied: body.applied === true,
239
+ })
240
+ : errorResult(text);
241
+ }));
202
242
  }
203
243
  /** Share and unshare are one door and one body; only the boolean differs. */
204
244
  async function shareCall(deps, session, args, share) {
@@ -11,12 +11,17 @@
11
11
  * and returns a string. No file writing here — that is the script's job, so
12
12
  * this can be asserted without touching a disk.
13
13
  */
14
- import { AWAITING_TWIN, MCP_TWINS, TERMINAL_ONLY, towerVerbs } from "./verb-census.js";
14
+ import { AWAITING_TWIN, MCP_TWINS, TERMINAL_ONLY, towerVerbs, typedSpelling } from "./verb-census.js";
15
15
  export const CENSUS_BEGIN = "<!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->";
16
16
  export const CENSUS_END = "<!-- END GENERATED verb census -->";
17
- /** `cockpit docs read` → `` `cockpit docs read` ``; `jarvis --trace` keeps its flag. */
17
+ /**
18
+ * `cockpit docs read` → `` `cockpit docs read` ``; `jarvis --trace` keeps its
19
+ * flag. A census key whose grammar differs from the typed command is rendered
20
+ * as the command (`settings show` → `cockpit settings`), because this column is
21
+ * headed "CLI verb" and a reader takes it literally (BLI-4048).
22
+ */
18
23
  function spell(verb) {
19
- return `\`cockpit ${verb}\``;
24
+ return `\`cockpit ${typedSpelling(verb)}\``;
20
25
  }
21
26
  export function renderCensusMarkdown() {
22
27
  // Every verb the collector's own tables name, plus the one hand-entered mode
package/dist/server.js CHANGED
@@ -23,6 +23,8 @@ import { registerSettingsTools } from "./settings-tools.js";
23
23
  import { registerSettingsWriteTools } from "./settings-write-tools.js";
24
24
  import { registerTeamWriteTools } from "./team-write-tools.js";
25
25
  import { registerWorkTools } from "./work-tools.js";
26
+ import { registerCareersTools } from "./careers-tools.js";
27
+ import { registerUsageTools } from "./usage-tools.js";
26
28
  export const PACKAGE_NAME = "@bli-cockpit/mcp";
27
29
  export const PACKAGE_VERSION = "0.1.4";
28
30
  // ---- input schemas (Zod raw shapes) -----------------------------------------
@@ -390,6 +392,8 @@ export function createServer(deps) {
390
392
  // the docs/msg tools above, for the same reason: a coding session should
391
393
  // file and move a Tower issue the way it files and moves a Linear one.
392
394
  registerWorkTools(server, { fetchImpl: deps.fetchImpl });
395
+ registerCareersTools(server, deps);
396
+ registerUsageTools(server, { fetchImpl: deps.fetchImpl });
393
397
  // BLI-3708: the mailboxes a person attached. Reads, a send and a sync over
394
398
  // the same /api/mail/** doors `cockpit mail` calls; `add-imap` deliberately
395
399
  // has no twin (see mail-tools.ts's header — a credential is not a tool
@@ -0,0 +1,4 @@
1
+ import { type ToolDeps } from "./tool-result.js";
2
+ export declare function registerUsageTools(server: {
3
+ registerTool: (...args: never[]) => unknown;
4
+ }, deps: ToolDeps): void;
@@ -0,0 +1,21 @@
1
+ import { z } from "zod";
2
+ import { callAgentDoor } from "./agent-door.js";
3
+ import { doorFailureText, errorResult, registrarFor, textResult, withSession } from "./tool-result.js";
4
+ export function registerUsageTools(server, deps) {
5
+ registrarFor(server)("usage_people", {
6
+ title: "Read usage by person",
7
+ description: "Per-person Claude Code and Codex token usage, list-price equivalent, and extraction coverage.",
8
+ inputSchema: { since: z.string().optional().describe("30d or an ISO timestamp."), until: z.string().optional(), include_automated: z.boolean().optional() },
9
+ }, async (args) => withSession(deps, async (session) => {
10
+ const query = new URLSearchParams({ since: String(args.since ?? "30d") });
11
+ if (args.until)
12
+ query.set("until", String(args.until));
13
+ if (args.include_automated)
14
+ query.set("include_automated", "1");
15
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/usage/people?${query}`);
16
+ if (!response.ok)
17
+ return errorResult(doorFailureText("usage_people", response));
18
+ const coverage = response.body.coverage;
19
+ return textResult(`${coverage?.sessions_extracted ?? 0} of ${coverage?.sessions_observed ?? 0} sessions extracted.`, response.body);
20
+ }));
21
+ }
@@ -67,6 +67,30 @@ export interface McpTwin {
67
67
  }
68
68
  /** A CLI verb and the MCP tool that does the same thing, over the same door. */
69
69
  export declare const MCP_TWINS: Record<string, McpTwin>;
70
+ /**
71
+ * Census key → what a person actually types. (BLI-4048)
72
+ *
73
+ * The keys above are derived MECHANICALLY from each noun's `action` union, and
74
+ * for two nouns that union is not the grammar. `settings`'s positional is a
75
+ * SECTION (`personal|switches|models|env|cli-floor`) and the `action` is what
76
+ * the parser decides afterwards; `team`'s `device-list` is one action word
77
+ * spelled as two typed words, `team device list`. So `cockpit settings show`
78
+ * and `cockpit team device-list` were rendered into the README's "CLI verb"
79
+ * column and into `cli-caller-identity.md`, and the CLI refused all six.
80
+ *
81
+ * This is the display layer and nothing else: the keys, the twin rule and the
82
+ * suite are untouched. Every spelling here is proven typable —
83
+ * `scripts/doc-doors.test.mjs` runs the rendered table through the collector's
84
+ * own parser, so a wrong entry fails the build rather than misleading a
85
+ * stranger. Where one tool serves several sections (`settings_set` writes any
86
+ * of them) the spelling names ONE of them and the door column names the rest.
87
+ *
88
+ * A verb whose key is already typable is absent from here on purpose; the
89
+ * shorter this map is, the closer the census keys are to the product.
90
+ */
91
+ export declare const TYPED_SPELLING: Record<string, string>;
92
+ /** How a census key is written when a person is being told to type it. */
93
+ export declare function typedSpelling(verb: string): string;
70
94
  /** Verbs that can never have an MCP twin, and why. A claim, not a backlog. */
71
95
  export declare const TERMINAL_ONLY: Record<string, string>;
72
96
  /**
@@ -209,6 +209,10 @@ export function memoryMcpToolNames() {
209
209
  }
210
210
  /** A CLI verb and the MCP tool that does the same thing, over the same door. */
211
211
  export const MCP_TWINS = {
212
+ "careers list": { tool: "careers_list", door: "GET /api/careers/applications" },
213
+ "careers show": { tool: "careers_show", door: "GET /api/careers/applications/[id]" },
214
+ "careers rescreen": { tool: "careers_rescreen", door: "POST /api/careers/applications/[id]/rescreen" },
215
+ "usage people": { tool: "usage_people", door: "GET /api/usage/people" },
212
216
  "docs list": { tool: "docs_list", door: "GET /api/docs/documents" },
213
217
  "docs tree": { tool: "docs_tree", door: "GET /api/docs/tree" },
214
218
  "docs read": { tool: "docs_read", door: "GET /api/docs/documents/[id]" },
@@ -250,6 +254,10 @@ export const MCP_TWINS = {
250
254
  "brief status": { tool: "brief_status", door: "GET /api/ops/brief-status" },
251
255
  "brief rewrite": { tool: "brief_rewrite", door: "POST /api/jarvis/recompile" },
252
256
  correct: { tool: "brief_correct", door: "POST /api/jarvis/corrections" },
257
+ "notes folders": { tool: "notes_folders", door: "GET /api/notes/folders" },
258
+ "notes mkdir": { tool: "notes_mkdir", door: "POST /api/notes/folders" },
259
+ "notes rename": { tool: "notes_rename", door: "PATCH /api/notes/folders/[id]" },
260
+ "notes rmdir": { tool: "notes_rmdir", door: "DELETE /api/notes/folders/[id]" },
253
261
  "notes list": { tool: "notes_list", door: "GET /api/notes/library" },
254
262
  "notes show": { tool: "notes_show", door: "GET /api/notes/library/[id]" },
255
263
  "notes shelf": { tool: "notes_shelf", door: "GET /api/notes/shelf" },
@@ -259,6 +267,7 @@ export const MCP_TWINS = {
259
267
  "notes share": { tool: "notes_share", door: "POST /api/notes/share" },
260
268
  "notes unshare": { tool: "notes_unshare", door: "POST /api/notes/share (share=false)" },
261
269
  "notes move": { tool: "notes_move", door: "POST /api/notes/move" },
270
+ "notes place": { tool: "notes_place", door: "POST /api/notes/place" },
262
271
  "memory log": { tool: "memory_experience", door: "POST /api/memory/experience" },
263
272
  // BLI-4047: the memory STORE at a terminal. The twins live on the second
264
273
  // server, `bli-memory`, over the same four doors these CLI verbs call.
@@ -302,6 +311,39 @@ export const MCP_TWINS = {
302
311
  // header of this file).
303
312
  "jarvis --trace": { tool: "jarvis_trace", door: "GET /api/ops/trace/[id]" },
304
313
  };
314
+ /**
315
+ * Census key → what a person actually types. (BLI-4048)
316
+ *
317
+ * The keys above are derived MECHANICALLY from each noun's `action` union, and
318
+ * for two nouns that union is not the grammar. `settings`'s positional is a
319
+ * SECTION (`personal|switches|models|env|cli-floor`) and the `action` is what
320
+ * the parser decides afterwards; `team`'s `device-list` is one action word
321
+ * spelled as two typed words, `team device list`. So `cockpit settings show`
322
+ * and `cockpit team device-list` were rendered into the README's "CLI verb"
323
+ * column and into `cli-caller-identity.md`, and the CLI refused all six.
324
+ *
325
+ * This is the display layer and nothing else: the keys, the twin rule and the
326
+ * suite are untouched. Every spelling here is proven typable —
327
+ * `scripts/doc-doors.test.mjs` runs the rendered table through the collector's
328
+ * own parser, so a wrong entry fails the build rather than misleading a
329
+ * stranger. Where one tool serves several sections (`settings_set` writes any
330
+ * of them) the spelling names ONE of them and the door column names the rest.
331
+ *
332
+ * A verb whose key is already typable is absent from here on purpose; the
333
+ * shorter this map is, the closer the census keys are to the product.
334
+ */
335
+ export const TYPED_SPELLING = {
336
+ "settings show": "settings",
337
+ "settings list": "settings env list",
338
+ "settings set": "settings switches set",
339
+ "settings delete": "settings env delete",
340
+ "team device-list": "team device list",
341
+ "team device-revoke": "team device revoke",
342
+ };
343
+ /** How a census key is written when a person is being told to type it. */
344
+ export function typedSpelling(verb) {
345
+ return TYPED_SPELLING[verb] ?? verb;
346
+ }
305
347
  /** Verbs that can never have an MCP twin, and why. A claim, not a backlog. */
306
348
  export const TERMINAL_ONLY = {
307
349
  "mail add-imap": "carries a Google app password. The CLI reads it from STDIN precisely so it never lands in an argument list; an MCP tool argument travels through a model's context window and whatever transcript store sits behind it, so attaching a mailbox stays a thing a person does at a terminal (BLI-3708)",
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "private": false,
5
- "description": "bli-tower \u2014 an MCP server over BLI Cockpit's agent doors: JARVIS (jarvis_*), documents (docs_*), channels (msg_*), issues (work_*), the daily page (brief_*), meeting notes (notes_*), the ops board (ops_status/slack_*), settings/team/model, Scout and the workbook, plus the legacy event-stream tools (emit_event, get_ticket_timeline, get_active_tickets).",
5
+ "description": "bli-tower an MCP server over BLI Cockpit's agent doors: JARVIS (jarvis_*), documents (docs_*), channels (msg_*), issues (work_*), the daily page (brief_*), meeting notes (notes_*), the ops board (ops_status/slack_*), settings/team/model, Scout and the workbook, plus the legacy event-stream tools (emit_event, get_ticket_timeline, get_active_tickets).",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "bli-cockpit-mcp": "./dist/index.js"
@@ -30,7 +30,7 @@
30
30
  "readme": "npm run build && node scripts/write-readme-census.mjs"
31
31
  },
32
32
  "dependencies": {
33
- "@bli-cockpit/telemetry-core": "0.1.42",
33
+ "@bli-cockpit/telemetry-core": "0.1.43",
34
34
  "@modelcontextprotocol/sdk": "^1.29.0",
35
35
  "zod": "^4.3.6"
36
36
  },