@bli-cockpit/mcp 0.1.116 → 0.1.118

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
@@ -109,7 +109,7 @@ about what an agent can reach.
109
109
 
110
110
  <!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->
111
111
 
112
- **169 of 174 Tower verbs have an MCP twin.**
112
+ **171 of 176 Tower verbs have an MCP twin.**
113
113
  Each tool goes through the SAME door its CLI verb calls, with the same
114
114
  collector device token — never a second route and never a service-role
115
115
  reader. `src/verb-census.test.ts` fails when a verb is in none of the
@@ -176,6 +176,8 @@ three tables below.
176
176
  | `cockpit glossary add` | `glossary_add` | `POST /api/glossary` |
177
177
  | `cockpit glossary list` | `glossary_list` | `GET /api/glossary` |
178
178
  | `cockpit glossary remove` | `glossary_remove` | `POST /api/glossary` |
179
+ | `cockpit image describe` | `image_describe` | `POST /api/ambient/images` |
180
+ | `cockpit image show` | `image_show` | `GET /api/ambient/images` |
179
181
  | `cockpit issue board` | `work_board` | `GET /api/work/board` |
180
182
  | `cockpit issue comment` | `work_comment_issue` | `POST /api/work/issues/[id]/comments` |
181
183
  | `cockpit issue create` | `work_create_issue` | `POST /api/work/issues` |
@@ -0,0 +1,4 @@
1
+ import { type ToolDeps } from "./tool-result.js";
2
+ export declare function registerImageTools(server: {
3
+ registerTool: (...args: never[]) => unknown;
4
+ }, deps: ToolDeps): void;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Image descriptions over MCP (BLI-16977): the two twins of `cockpit image describe|show`.
3
+ *
4
+ * Plain words: Tower swaps the base64 pictures inside collected transcripts for a short written
5
+ * description kept against the image's sha256. `image_describe` sends one image through that same
6
+ * door by hand; `image_show` looks a stored description up by the sha256 or the 16-character
7
+ * prefix a transcript marker carries. Both ride the ONE `/api/ambient/images` door (POST describes,
8
+ * GET reads) on this machine's device token, never a second route.
9
+ *
10
+ * `image_describe` takes an absolute `path` to a local image file OR `content_base64` (the bytes
11
+ * themselves). It refuses, by name, an image over the door's size limit and bytes that are not a
12
+ * PNG, JPEG, GIF or WebP (judged by their first bytes, never the name or a declared type), and it
13
+ * sends nothing in either case. A hosted server has no caller's disk, so `path` is refused there.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import { readFile, stat } from "node:fs/promises";
17
+ import path from "node:path";
18
+ import { z } from "zod";
19
+ import { IMAGE_DESCRIBE_MAX_BYTES, IMAGE_MEDIA_TYPES, ImageHashQuerySchema, formatImageMarker, imageSizeLabel, sniffImageMediaType, } from "@bli-cockpit/telemetry-core";
20
+ import { callAgentDoor } from "./agent-door.js";
21
+ import { inRemoteAgentDoorScope } from "./agent-door-session.js";
22
+ import { dataResult, doorFailureText, errorResult, queryString, registrarFor, withSession, } from "./tool-result.js";
23
+ const DOOR_PATH = "/api/ambient/images";
24
+ const refuse = (reason, sentence) => ({ ok: false, result: errorResult(`image_describe refused (${reason}): ${sentence} Nothing was sent.`) });
25
+ async function pickBytes(args) {
26
+ const filePath = typeof args.path === "string" ? args.path : undefined;
27
+ const base64 = typeof args.content_base64 === "string" ? args.content_base64 : undefined;
28
+ if ((filePath === undefined) === (base64 === undefined))
29
+ return refuse("give_path_or_content", "Give exactly one of `path` (an absolute path to an image file) or `content_base64`.");
30
+ if (base64 !== undefined) {
31
+ const bytes = Buffer.from(base64, "base64");
32
+ if (bytes.length === 0)
33
+ return refuse("invalid_base64", "`content_base64` decoded to nothing.");
34
+ return { ok: true, bytes };
35
+ }
36
+ if (inRemoteAgentDoorScope())
37
+ return refuse("path_not_available_here", "This server is hosted and has no access to your files; send `content_base64` instead.");
38
+ if (!path.isAbsolute(filePath))
39
+ return refuse("path_not_absolute", "`path` must be an absolute path.");
40
+ try {
41
+ const info = await stat(filePath);
42
+ if (!info.isFile())
43
+ return refuse("not_a_file", "`path` is not a file.");
44
+ if (info.size > IMAGE_DESCRIBE_MAX_BYTES)
45
+ return refuse("image_too_large", `That image is ${imageSizeLabel(info.size)}; the describe door takes at most ${imageSizeLabel(IMAGE_DESCRIBE_MAX_BYTES)}.`);
46
+ return { ok: true, bytes: await readFile(filePath) };
47
+ }
48
+ catch {
49
+ return refuse("file_unreadable", "Could not read the file at `path`.");
50
+ }
51
+ }
52
+ export function registerImageTools(server, deps) {
53
+ const register = registrarFor(server);
54
+ register("image_describe", {
55
+ title: "Describe an image",
56
+ description: "Sends ONE image to Tower's describe door and returns its short written description, the way the collector does for every picture in a transcript. Give an absolute `path` to a local "
57
+ + "PNG, JPEG, GIF or WebP file (up to 3 MB), or `content_base64` with the bytes. An image Tower already described answers from storage (status known). The answer carries the description, "
58
+ + "the sha256 and the `marker` a transcript would hold in place of the picture. Refuses by name, without sending, an image that is too large or not an image. `cockpit image describe`, same door.",
59
+ inputSchema: {
60
+ path: z.string().min(1).max(4096).optional().describe("Absolute path to a local image file. Not available on a hosted server."),
61
+ content_base64: z.string().min(8).max(Math.ceil((IMAGE_DESCRIBE_MAX_BYTES * 4) / 3) + 16).optional().describe("The image bytes, base64. Use instead of `path`."),
62
+ media_type: z.enum(IMAGE_MEDIA_TYPES).optional().describe("With content_base64: the type you believe it is. Checked against the bytes; a mismatch is refused."),
63
+ session_id: z.string().min(1).max(200).optional().describe("The agent session the image came from, an id only."),
64
+ },
65
+ }, async (args) => {
66
+ const picked = await pickBytes(args);
67
+ if (!picked.ok)
68
+ return picked.result;
69
+ const bytes = picked.bytes;
70
+ if (bytes.length > IMAGE_DESCRIBE_MAX_BYTES)
71
+ return refuse("image_too_large", `That image is ${imageSizeLabel(bytes.length)}; the describe door takes at most ${imageSizeLabel(IMAGE_DESCRIBE_MAX_BYTES)}.`).result;
72
+ const mediaType = sniffImageMediaType(bytes);
73
+ if (!mediaType)
74
+ return refuse("not_an_image", "Those bytes are not a PNG, JPEG, GIF or WebP image (judged by their first bytes, not the name).").result;
75
+ if (typeof args.media_type === "string" && args.media_type !== mediaType)
76
+ return refuse("media_type_mismatch", `You said ${args.media_type} but the bytes are ${mediaType}.`).result;
77
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
78
+ const request = {
79
+ schema_version: "image-describe.v1",
80
+ sha256,
81
+ media_type: mediaType,
82
+ byte_size: bytes.length,
83
+ content_base64: Buffer.from(bytes).toString("base64"),
84
+ source: "manual",
85
+ ...(typeof args.session_id === "string" ? { session_id: args.session_id } : {}),
86
+ };
87
+ const marker = formatImageMarker({ sha256, mediaType, byteSize: bytes.length });
88
+ return withSession(deps, async (session) => {
89
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", DOOR_PATH, request);
90
+ if (!response.ok)
91
+ return errorResult(doorFailureText("image_describe", response));
92
+ const description = response.body.description;
93
+ const said = response.body.status === "known" ? "Tower already had a description for this image." : "Described.";
94
+ return dataResult(`${said} ${typeof description?.description === "string" ? description.description : ""}`.trim(), { ...response.body, marker });
95
+ });
96
+ });
97
+ register("image_show", {
98
+ title: "Read an image description",
99
+ description: "Looks up the stored description of an image by its sha256, or by the first 12 to 64 characters of it (a transcript's `[image sha256:ab12cd34ef567890… png 412KB]` marker carries 16). "
100
+ + "Answers the descriptions found; an empty list means nothing is stored for that image yet. Read-only. `cockpit image show`, same door.",
101
+ inputSchema: {
102
+ sha256: ImageHashQuerySchema.describe("The image's sha256 or a prefix of it: 12 to 64 lowercase hex characters."),
103
+ },
104
+ }, async (args) => {
105
+ const query = new URLSearchParams({ sha256: String(args.sha256) });
106
+ return withSession(deps, async (session) => {
107
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `${DOOR_PATH}${queryString(query)}`, undefined);
108
+ if (!response.ok)
109
+ return errorResult(doorFailureText("image_show", response));
110
+ const found = Array.isArray(response.body.descriptions) ? response.body.descriptions.length : 0;
111
+ return dataResult(found === 0 ? "No description stored for that image yet." : `${found} description(s) found.`, response.body);
112
+ });
113
+ });
114
+ }
@@ -4,6 +4,8 @@
4
4
  * BLI Memory ships its own MCP server, so its store verbs are twinned against
5
5
  * tools on THAT server, over the same `/api/memory/*` doors the CLI verbs call.
6
6
  * Split out of `verb-census.ts` to keep that file under the readability band.
7
+ * The image door's two twins (BLI-16977) sit here for the same reason.
7
8
  */
8
9
  import type { McpTwin } from "./verb-census.js";
9
10
  export declare const MEMORY_STORE_TWINS: Record<string, McpTwin>;
11
+ export declare const IMAGE_TWINS: Record<string, McpTwin>;
@@ -4,6 +4,7 @@
4
4
  * BLI Memory ships its own MCP server, so its store verbs are twinned against
5
5
  * tools on THAT server, over the same `/api/memory/*` doors the CLI verbs call.
6
6
  * Split out of `verb-census.ts` to keep that file under the readability band.
7
+ * The image door's two twins (BLI-16977) sit here for the same reason.
7
8
  */
8
9
  export const MEMORY_STORE_TWINS = {
9
10
  "memory search": { tool: "search_memory", door: "POST /api/memory/search", server: "bli-memory" },
@@ -15,3 +16,7 @@ export const MEMORY_STORE_TWINS = {
15
16
  "memory private": { tool: "set_memory_visibility", door: "POST /api/memory/visibility", server: "bli-memory" },
16
17
  "memory public": { tool: "set_memory_visibility", door: "POST /api/memory/visibility", server: "bli-memory" },
17
18
  };
19
+ export const IMAGE_TWINS = {
20
+ "image describe": { tool: "image_describe", door: "POST /api/ambient/images" },
21
+ "image show": { tool: "image_show", door: "GET /api/ambient/images" },
22
+ };
package/dist/server.js CHANGED
@@ -43,6 +43,7 @@ import { registerUsageTools } from "./usage-tools.js";
43
43
  import { registerLogsTools } from "./logs-tools.js";
44
44
  import { registerBrainTools } from "./brain-tools.js";
45
45
  import { registerApprovalsTools } from "./approvals-tools.js";
46
+ import { registerImageTools } from "./image-tools.js";
46
47
  import { registerSourcesTools } from "./sources-tools.js";
47
48
  import { TOWER_SERVER_ICONS } from "./server-icons.js";
48
49
  import { installToolRegistration } from "./tool-registration.js";
@@ -525,5 +526,7 @@ export function createServer(deps, options = {}) {
525
526
  registerBrainTools(server, { fetchImpl: deps.fetchImpl });
526
527
  // BLI-16945: the approvals inbox, over the same /api/approvals doors the page and `cockpit approvals` call.
527
528
  registerApprovalsTools(server, { fetchImpl: deps.fetchImpl });
529
+ // BLI-16977: image descriptions, over the same /api/ambient/images door `cockpit image` calls.
530
+ registerImageTools(server, { fetchImpl: deps.fetchImpl });
528
531
  return server;
529
532
  }
@@ -10,7 +10,7 @@
10
10
  * from `src/` under Node's type stripping, so it may import nothing.
11
11
  */
12
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_board", "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", "rename_pending", "github_pr", "setup_read", "speed_read", "speed_seen", "usage_people", "usage_sessions", "mail_accounts", "mail_inbox", "mail_read", "mail_search", "mail_attachment", "mail_sync_status", "cal_today", "cal_week", "cal_next", "cal_find", "cal_calendars", "cal_sync_status", "jarvis_ask", "jarvis_trace", "jarvis_check", "models_list", "models_card", "models_compare", "brief_read", "brief_history", "brief_status", "brief_missed", "notes_folders", "notes_list", "glossary_list", "notes_show", "notes_shelf", "notes_shelves", "notes_place", "notes_accounts", "notes_recordings", "notes_recording", "notes_sync_status", "notes_granola_status", "notes_bot_upcoming", "ops_status", "slack_coverage", "slack_connect", "slack_disconnect", "slack_read", "memory_map", "memory_rules", "memory_knowledge", "memory_profile", "memory_consolidation", "memory_chain", "settings_show", "settings_list", "model_show", "team_members", "team_github_login_add", "team_github_login_remove", "team_github_login_list", "team_device_list", "scout_board", "workbook_read", "tower_search", "sources_check", "logs_digest", "brain_status", "approvals_list", "approvals_show", "approvals_rules"];
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_board", "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", "rename_pending", "github_pr", "setup_read", "speed_read", "speed_seen", "usage_people", "usage_sessions", "mail_accounts", "mail_inbox", "mail_read", "mail_search", "mail_attachment", "mail_sync_status", "cal_today", "cal_week", "cal_next", "cal_find", "cal_calendars", "cal_sync_status", "jarvis_ask", "jarvis_trace", "jarvis_check", "models_list", "models_card", "models_compare", "brief_read", "brief_history", "brief_status", "brief_missed", "notes_folders", "notes_list", "glossary_list", "notes_show", "notes_shelf", "notes_shelves", "notes_place", "notes_accounts", "notes_recordings", "notes_recording", "notes_sync_status", "notes_granola_status", "notes_bot_upcoming", "ops_status", "slack_coverage", "slack_connect", "slack_disconnect", "slack_read", "memory_map", "memory_rules", "memory_knowledge", "memory_profile", "memory_consolidation", "memory_chain", "settings_show", "settings_list", "model_show", "team_members", "team_github_login_add", "team_github_login_remove", "team_github_login_list", "team_device_list", "scout_board", "workbook_read", "tower_search", "sources_check", "logs_digest", "brain_status", "approvals_list", "approvals_show", "approvals_rules", "image_show"];
14
14
  /**
15
15
  * Tools that write, but answer a dry run that writes nothing. The smoke calls
16
16
  * each one ONLY with its dry-run flag set (`scripts/smoke.ts` says which).
@@ -21,7 +21,7 @@ export declare const DRY_RUN_TOOLS: readonly ["ops_recompile", "memory_requeue"]
21
21
  * candidates, share calendars. Never called by the smoke; covered by the unit
22
22
  * suites (`write-tools.test.ts` and each family's own).
23
23
  */
24
- export declare const WRITE_TOOLS: readonly ["approvals_create", "approvals_approve", "approvals_edit", "approvals_reject", "approvals_rules_create", "approvals_rules_revoke", "logs_key_mint", "brain_retry", "emit_event", "docs_create", "docs_update", "docs_delete", "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_followup", "careers_interview", "careers_takehome_set", "careers_settings_set", "rename_resolve", "github_merge", "github_retitle", "github_rerun", "mail_send", "mail_draft", "mail_rename", "mail_enable", "mail_disable", "mail_default", "mail_remove", "cal_change", "mail_sync", "mail_detach", "cal_create", "cal_share", "cal_sync", "cal_detach", "cal_delete", "jarvis_dispatch", "glossary_add", "glossary_remove", "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_grant", "team_revoke", "team_device_revoke", "team_device_doctor"];
24
+ export declare const WRITE_TOOLS: readonly ["image_describe", "approvals_create", "approvals_approve", "approvals_edit", "approvals_reject", "approvals_rules_create", "approvals_rules_revoke", "logs_key_mint", "brain_retry", "emit_event", "docs_create", "docs_update", "docs_delete", "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_followup", "careers_interview", "careers_takehome_set", "careers_settings_set", "rename_resolve", "github_merge", "github_retitle", "github_rerun", "mail_send", "mail_draft", "mail_rename", "mail_enable", "mail_disable", "mail_default", "mail_remove", "cal_change", "mail_sync", "mail_detach", "cal_create", "cal_share", "cal_sync", "cal_detach", "cal_delete", "jarvis_dispatch", "glossary_add", "glossary_remove", "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_grant", "team_revoke", "team_device_revoke", "team_device_doctor"];
25
25
  /** Reads that spend a model turn. The smoke's `--no-model` leaves these out. */
26
26
  export declare const MODEL_SPENDING_TOOLS: readonly ["jarvis_ask", "jarvis_check"];
27
27
  /**
@@ -105,6 +105,8 @@ export const READ_TOOLS = [
105
105
  "approvals_list",
106
106
  "approvals_show",
107
107
  "approvals_rules",
108
+ // BLI-16977: a stored image description, by sha256. Reads only.
109
+ "image_show",
108
110
  ];
109
111
  /**
110
112
  * Tools that write, but answer a dry run that writes nothing. The smoke calls
@@ -117,6 +119,8 @@ export const DRY_RUN_TOOLS = ["ops_recompile", "memory_requeue"];
117
119
  * suites (`write-tools.test.ts` and each family's own).
118
120
  */
119
121
  export const WRITE_TOOLS = [
122
+ // BLI-16977: sends one image to the describe door (a vision-model call, stored against its sha256). Never smoked.
123
+ "image_describe",
120
124
  // BLI-16945: approve runs the guarded act; a rule sends without asking. Never smoked.
121
125
  "approvals_create",
122
126
  "approvals_approve",
@@ -29,7 +29,7 @@
29
29
  import { readFileSync, readdirSync } from "node:fs";
30
30
  import { dirname, join, resolve } from "node:path";
31
31
  import { fileURLToPath } from "node:url";
32
- import { MEMORY_STORE_TWINS } from "./memory-twins.js";
32
+ import { IMAGE_TWINS, MEMORY_STORE_TWINS } from "./memory-twins.js";
33
33
  const HERE = dirname(fileURLToPath(import.meta.url));
34
34
  export const COLLECTOR_COMMANDS_DIR = resolve(HERE, "../../cockpit-local-collector/src/commands");
35
35
  function read(file) {
@@ -350,8 +350,8 @@ export const MCP_TWINS = {
350
350
  "notes granola-status": { tool: "notes_granola_status", door: "GET /api/notes/granola-status" },
351
351
  "memory log": { tool: "memory_experience", door: "POST /api/memory/experience" },
352
352
  // BLI-4047, BLI-12927: the memory STORE at a terminal. Its twins live on the
353
- // second server, `bli-memory` (see `memory-twins.ts`).
354
- ...MEMORY_STORE_TWINS,
353
+ // second server, `bli-memory` (see `memory-twins.ts`, which also holds the image twins).
354
+ ...MEMORY_STORE_TWINS, ...IMAGE_TWINS,
355
355
  // BLI-4533: the compiled one-page profile. Its twin is on `bli-tower`, not
356
356
  // `bli-memory`: that server's four tools are the vendor-compatible set the
357
357
  // Claude Code plugin expects, and this page is ours.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.116",
3
+ "version": "0.1.118",
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",
@@ -37,7 +37,7 @@
37
37
  "readme": "npm run build && node scripts/write-readme-census.mjs"
38
38
  },
39
39
  "dependencies": {
40
- "@bli-cockpit/telemetry-core": "0.1.70",
40
+ "@bli-cockpit/telemetry-core": "0.1.72",
41
41
  "@modelcontextprotocol/sdk": "^1.29.0",
42
42
  "zod": "^4.3.6"
43
43
  },