@bli-cockpit/mcp 0.1.30 → 0.1.32

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
- **83 of 86 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,10 +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)` |
100
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]` |
101
108
  | `cockpit notes share` | `notes_share` | `POST /api/notes/share` |
102
109
  | `cockpit notes shelf` | `notes_shelf` | `GET /api/notes/shelf` |
103
110
  | `cockpit notes shelves` | `notes_shelves` | `GET /api/notes/library` |
@@ -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;
package/dist/server.js CHANGED
@@ -23,6 +23,7 @@ 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";
26
27
  import { registerUsageTools } from "./usage-tools.js";
27
28
  export const PACKAGE_NAME = "@bli-cockpit/mcp";
28
29
  export const PACKAGE_VERSION = "0.1.4";
@@ -391,6 +392,7 @@ export function createServer(deps) {
391
392
  // the docs/msg tools above, for the same reason: a coding session should
392
393
  // file and move a Tower issue the way it files and moves a Linear one.
393
394
  registerWorkTools(server, { fetchImpl: deps.fetchImpl });
395
+ registerCareersTools(server, deps);
394
396
  registerUsageTools(server, { fetchImpl: deps.fetchImpl });
395
397
  // BLI-3708: the mailboxes a person attached. Reads, a send and a sync over
396
398
  // the same /api/mail/** doors `cockpit mail` calls; `add-imap` deliberately
@@ -5,7 +5,7 @@ export function registerUsageTools(server, deps) {
5
5
  registrarFor(server)("usage_people", {
6
6
  title: "Read usage by person",
7
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() },
8
+ inputSchema: { since: z.string().optional().describe("<n>d (1 to 365), <n>h (1 to 8760), or an ISO timestamp. Default 30d; invalid values return invalid_usage_window."), until: z.string().optional(), include_automated: z.boolean().optional() },
9
9
  }, async (args) => withSession(deps, async (session) => {
10
10
  const query = new URLSearchParams({ since: String(args.since ?? "30d") });
11
11
  if (args.until)
@@ -209,6 +209,9 @@ 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" },
212
215
  "usage people": { tool: "usage_people", door: "GET /api/usage/people" },
213
216
  "docs list": { tool: "docs_list", door: "GET /api/docs/documents" },
214
217
  "docs tree": { tool: "docs_tree", door: "GET /api/docs/tree" },
@@ -251,6 +254,10 @@ export const MCP_TWINS = {
251
254
  "brief status": { tool: "brief_status", door: "GET /api/ops/brief-status" },
252
255
  "brief rewrite": { tool: "brief_rewrite", door: "POST /api/jarvis/recompile" },
253
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]" },
254
261
  "notes list": { tool: "notes_list", door: "GET /api/notes/library" },
255
262
  "notes show": { tool: "notes_show", door: "GET /api/notes/library/[id]" },
256
263
  "notes shelf": { tool: "notes_shelf", door: "GET /api/notes/shelf" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "private": false,
5
5
  "description": "bli-tower — an MCP server over BLI Cockpit's agent doors: JARVIS (jarvis_*), documents (docs_*), channels (msg_*), issues (work_*), the daily page (brief_*), meeting notes (notes_*), the ops board (ops_status/slack_*), settings/team/model, Scout and the workbook, plus the legacy event-stream tools (emit_event, get_ticket_timeline, get_active_tickets).",
6
6
  "type": "module",