@bli-cockpit/mcp 0.1.2 → 0.1.3

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
@@ -25,6 +25,97 @@ dead"). `src/verb-census.test.ts` is that rule as a test — it reads the CLI's
25
25
  own decision tables and fails when a verb is in neither the twin map, the
26
26
  terminal-only list, nor the written follow-up queue.
27
27
 
28
+ ## Every Tower verb, and the tool that does the same thing
29
+
30
+ The three tables below are GENERATED from `src/verb-census.ts` by
31
+ `npm run mcp:readme` — do not hand-edit them. They are the same three lists
32
+ `src/verb-census.test.ts` polices, so this page and that suite cannot disagree
33
+ about what an agent can reach.
34
+
35
+ <!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->
36
+
37
+ **39 of 57 Tower verbs have an MCP twin.**
38
+ Each tool goes through the SAME door its CLI verb calls, with the same
39
+ collector device token — never a second route and never a service-role
40
+ reader. `src/verb-census.test.ts` fails when a verb is in none of the
41
+ three tables below.
42
+
43
+ | CLI verb | MCP tool | Door |
44
+ | --- | --- | --- |
45
+ | `cockpit brief history` | `brief_history` | `GET /api/jarvis/brief?history=1` |
46
+ | `cockpit brief read` | `brief_read` | `GET /api/jarvis/brief` |
47
+ | `cockpit brief status` | `brief_status` | `GET /api/ops/brief-status` |
48
+ | `cockpit docs create` | `docs_create` | `POST /api/docs/documents` |
49
+ | `cockpit docs list` | `docs_list` | `GET /api/docs/documents` |
50
+ | `cockpit docs read` | `docs_read` | `GET /api/docs/documents/[id]` |
51
+ | `cockpit docs tree` | `docs_tree` | `GET /api/docs/tree` |
52
+ | `cockpit docs update` | `docs_update` | `PATCH /api/docs/documents/[id]` |
53
+ | `cockpit issue comment` | `work_comment_issue` | `POST /api/work/issues/[id]/comments` |
54
+ | `cockpit issue create` | `work_create_issue` | `POST /api/work/issues` |
55
+ | `cockpit issue history` | `work_issue_history` | `GET /api/work/issues/[id]/history` |
56
+ | `cockpit issue list` | `work_list_issues` | `GET /api/work/issues` |
57
+ | `cockpit issue move` | `work_move_issue` | `POST /api/work/issues/[id]/state` |
58
+ | `cockpit issue show` | `work_get_issue` | `GET /api/work/issues/[id]` |
59
+ | `cockpit issue update` | `work_update_issue` | `PATCH /api/work/issues/[id]` |
60
+ | `cockpit jarvis` | `jarvis_ask` | `POST /api/jarvis/cli` |
61
+ | `cockpit jarvis --trace` | `jarvis_trace` | `GET /api/ops/trace/[id]` |
62
+ | `cockpit model show` | `model_show` | `GET /api/settings/jarvis-model` |
63
+ | `cockpit msg channels` | `msg_channels` | `GET /api/msg/channels` |
64
+ | `cockpit msg create` | `msg_create_channel` | `POST /api/msg/channels` |
65
+ | `cockpit msg dm` | `msg_dm` | `POST /api/msg/channels (dm)` |
66
+ | `cockpit msg read` | `msg_read` | `GET /api/msg/channels/[id]/messages` |
67
+ | `cockpit msg send` | `msg_send` | `POST /api/msg/channels/[id]/messages` |
68
+ | `cockpit msg thread` | `msg_thread` | `GET /api/msg/channels/[id]/messages?thread_parent_id=` |
69
+ | `cockpit notes list` | `notes_list` | `GET /api/notes/library` |
70
+ | `cockpit notes shelf` | `notes_shelf` | `GET /api/notes/shelf` |
71
+ | `cockpit notes shelves` | `notes_shelves` | `GET /api/notes/library` |
72
+ | `cockpit notes show` | `notes_show` | `GET /api/notes/library/[id]` |
73
+ | `cockpit ops status` | `ops_status` | `GET /api/ops/status` |
74
+ | `cockpit project list` | `work_list_projects` | `GET /api/work/projects` |
75
+ | `cockpit scout board` | `scout_board` | `GET /api/cockpit/scout` |
76
+ | `cockpit search` | `tower_search` | `GET /api/search` |
77
+ | `cockpit settings list` | `settings_list` | `GET /api/settings/env-blobs` |
78
+ | `cockpit settings show` | `settings_show` | `GET /api/settings/* + /api/team/members` |
79
+ | `cockpit slack coverage` | `slack_coverage` | `GET /api/ops/slack/coverage` |
80
+ | `cockpit slack read` | `slack_read` | `POST /api/ops/slack/read` |
81
+ | `cockpit team device-list` | `team_device_list` | `GET /api/team/devices` |
82
+ | `cockpit team members` | `team_members` | `GET /api/team/members` |
83
+ | `cockpit workbook` | `workbook_read` | `GET /api/cockpit/workbook` |
84
+
85
+ ### Owed a twin
86
+
87
+ This table should only ever shrink.
88
+
89
+ | CLI verb | Who owes it, and why it is not built yet |
90
+ | --- | --- |
91
+ | `cockpit brief rewrite` | BLI-3756 batch 2 — spends a model call; wants its own gate on this surface |
92
+ | `cockpit correct` | BLI-3756 batch 2 — filing a correction against a claim has no tool |
93
+ | `cockpit model set` | BLI-3756 batch 2 — a shorthand over settings personal; follows settings |
94
+ | `cockpit notes move` | BLI-3756 batch 2 — needs a signed-in session server-side; reachable, just unwritten |
95
+ | `cockpit notes paste` | BLI-3756 batch 2 — the CLI takes the body on stdin; a tool would take it as an argument |
96
+ | `cockpit notes share` | BLI-3756 batch 2 — a deliberate act; wants an explicit confirmation on this surface |
97
+ | `cockpit notes unshare` | BLI-3756 batch 2 — same gate as notes share |
98
+ | `cockpit notes upload` | BLI-3756 batch 2 — takes local file paths, which an MCP server on the same machine can also read |
99
+ | `cockpit ops recompile` | BLI-3756 batch 2 — spends a model call; wants its own gate on this surface |
100
+ | `cockpit scout dismiss` | BLI-3756 batch 2 — super_admin server-side; a tool would relay, not decide |
101
+ | `cockpit scout start` | BLI-3756 batch 2 — super_admin server-side; a tool would relay, not decide |
102
+ | `cockpit scout undo` | BLI-3756 batch 2 — super_admin server-side; a tool would relay, not decide |
103
+ | `cockpit settings delete` | BLI-3756 batch 2 — destructive; wants an explicit confirmation on this surface |
104
+ | `cockpit settings set` | BLI-3756 batch 2 — mostly reachable, but env CONTENT is stdin-only by rule; a tool must keep that |
105
+ | `cockpit team device-revoke` | BLI-3756 batch 2 — destructive; wants an explicit confirmation on this surface |
106
+ | `cockpit team invite` | BLI-3756 batch 2 — super_admin; a tool would relay, not decide |
107
+ | `cockpit team role` | BLI-3756 batch 2 — super_admin; a tool would relay, not decide |
108
+
109
+ ### Terminal-only
110
+
111
+ A claim about the verb's nature, not a backlog.
112
+
113
+ | CLI verb | Why it can never have a twin |
114
+ | --- | --- |
115
+ | `cockpit brief edit` | opens the person's own $EDITOR on this machine and files what they changed; an agent has no editor to open (commands/editor.ts) |
116
+
117
+ <!-- END GENERATED verb census -->
118
+
28
119
  ## `jarvis_*` tools (BLI-3732)
29
120
 
30
121
  JARVIS itself — the same assistant Tower web chat, the Slack DM and
@@ -74,16 +165,50 @@ terminal, because nothing on the wire can tell a person-relayed code from one
74
165
  the model lifted out of the previous answer; `src/jarvis-tools.ts` says so in
75
166
  full at the top of the file.
76
167
 
168
+ ## The read tools (BLI-3756 batch 1)
169
+
170
+ Every Tower READ the CLI already had. Doors and tool names are in the
171
+ generated table above; what is worth knowing before you call one:
172
+
173
+ - **`brief_read` / `brief_history` / `brief_status`** — the TODAY page as
174
+ finished text, the days a person has a page for, and why a brief was or was
175
+ not delivered. `subject` changes WHOSE page is asked for, never who is
176
+ asking. A delivery reason other than `delivered` (`not_monday`,
177
+ `no_slack_identity`, …) is a real answer and is NOT returned as an error.
178
+ - **`notes_list` / `notes_show` / `notes_shelf` / `notes_shelves`** — the
179
+ meeting-notes library. A device token has no signed-in browser session, so
180
+ these reads are routinely NARROWER than a person's; every one of them
181
+ appends the door's own `scope`/`degradedBecause` reason rather than letting
182
+ an agent conclude a note was never taken.
183
+ - **`ops_status` / `slack_coverage` / `slack_read`** — the pipeline board
184
+ (each job judged against its own expected interval, server-side), Slack
185
+ collection health, and the collected messages themselves. `slack_read` has a
186
+ narrower audience than `slack_coverage`, and says so on a 403.
187
+ - **`settings_show` / `settings_list` / `model_show` / `team_members` /
188
+ `team_device_list`** — settings as this caller may see them. A section you
189
+ may not see comes back as `admin only`, not as an error; `settings_list`
190
+ returns env-file names, sizes and stamps and can never return a value.
191
+ - **`scout_board` / `workbook_read`** — the Scout board (with the page's own
192
+ standing sentences, and a truncated read saying so) and the per-project
193
+ document library, rendered by the same walker the page uses.
194
+
195
+ Writes — `brief rewrite`, `correct`, `notes move|share|unshare|paste|upload`,
196
+ `ops recompile`, `scout start|dismiss|undo`, `settings set|delete`,
197
+ `team invite|role|device-revoke`, `model set` — are batch 2, each with the
198
+ reason it waited in the generated "Owed a twin" table.
199
+
77
200
  ## `docs_*` / `msg_*` tools (BLI-3706)
78
201
 
79
202
  | Tool | Wraps | Notes |
80
203
  | --- | --- | --- |
81
204
  | `docs_list` | `GET /api/docs/documents` | Every document you may read: id, slug, title, visibility. Never a body. |
205
+ | `docs_tree` | `GET /api/docs/tree` | The library as its nesting rather than a flat list. Never a body. |
82
206
  | `docs_read` | `GET /api/docs/documents/[id]` | One document's title and full body, by id or slug (resolved against `docs_list` first). |
83
207
  | `docs_create` | `POST /api/docs/documents` | Creates a document. Omitting `body_markdown` creates an empty one. |
84
208
  | `docs_update` | `PATCH /api/docs/documents/[id]` | Updates title/body/visibility/parent (a parent change IS a move). |
85
209
  | `msg_channels` | `GET /api/msg/channels` | Every channel you are a member of. |
86
- | `msg_read` | `GET /api/msg/channels/[id]/messages` | Recent messages of one channel, or one thread's replies. `channel` is an id or a `#name`. |
210
+ | `msg_read` | `GET /api/msg/channels/[id]/messages` | Recent messages of one channel, newest first. `channel` is an id or a `#name`. |
211
+ | `msg_thread` | `GET /api/msg/channels/[id]/messages?thread_parent_id=` | One thread's replies, oldest first, so it reads as a conversation. |
87
212
  | `msg_send` | `POST /api/msg/channels/[id]/messages` | Posts a message. |
88
213
 
89
214
  **Auth: this machine's collector device token, not `BLI_OPERATOR_TOKEN`.**
@@ -0,0 +1,26 @@
1
+ /**
2
+ * `brief_*` MCP tools (BLI-3756) — the TODAY page, read by an agent, over the
3
+ * same doors `cockpit brief` calls with the same device token.
4
+ *
5
+ * Nothing here renders a page. `GET /api/jarvis/brief` hands back finished
6
+ * text (the serializer's terminal flavour, the same one the CLI prints), and
7
+ * `GET /api/ops/brief-status` hands back the delivery reason computed by the
8
+ * code the Slack DM cron delivers with. A second renderer in this package
9
+ * would let the agent and the browser drift into describing two different
10
+ * days.
11
+ *
12
+ * `subject` changes WHOSE page is asked for, never who is asking — the same
13
+ * rule `cockpit brief --for` and `cockpit jarvis --as` follow. The server owns
14
+ * whether this caller may see that person at all, and says so in its own words.
15
+ *
16
+ * One judgement call worth naming: `brief_status` answers `not_monday` or
17
+ * `no_slack_identity` as a normal RESULT, not as an `isError`. The CLI exits 1
18
+ * on anything but `delivered` because a shell script needs a branch; a tool
19
+ * that reported "error" for a successful read of a true fact would teach an
20
+ * agent that the surface is broken.
21
+ */
22
+ import { type ToolDeps } from "./tool-result.js";
23
+ export type BriefDeps = ToolDeps;
24
+ export declare function registerBriefTools(server: {
25
+ registerTool: (...args: never[]) => unknown;
26
+ }, deps: BriefDeps): void;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `brief_*` MCP tools (BLI-3756) — the TODAY page, read by an agent, over the
3
+ * same doors `cockpit brief` calls with the same device token.
4
+ *
5
+ * Nothing here renders a page. `GET /api/jarvis/brief` hands back finished
6
+ * text (the serializer's terminal flavour, the same one the CLI prints), and
7
+ * `GET /api/ops/brief-status` hands back the delivery reason computed by the
8
+ * code the Slack DM cron delivers with. A second renderer in this package
9
+ * would let the agent and the browser drift into describing two different
10
+ * days.
11
+ *
12
+ * `subject` changes WHOSE page is asked for, never who is asking — the same
13
+ * rule `cockpit brief --for` and `cockpit jarvis --as` follow. The server owns
14
+ * whether this caller may see that person at all, and says so in its own words.
15
+ *
16
+ * One judgement call worth naming: `brief_status` answers `not_monday` or
17
+ * `no_slack_identity` as a normal RESULT, not as an `isError`. The CLI exits 1
18
+ * on anything but `delivered` because a shell script needs a branch; a tool
19
+ * that reported "error" for a successful read of a true fact would teach an
20
+ * agent that the surface is broken.
21
+ */
22
+ import { z } from "zod";
23
+ import { callAgentDoor } from "./agent-door.js";
24
+ import { doorFailureText, errorResult, registrarFor, textResult, queryString, withSession, } from "./tool-result.js";
25
+ const SUBJECT = z
26
+ .string()
27
+ .min(1)
28
+ .max(200)
29
+ .optional()
30
+ .describe("Whose page — a name, email or person id. Omit for your own. Changes the SUBJECT, never who is asking.");
31
+ export function registerBriefTools(server, deps) {
32
+ const register = registrarFor(server);
33
+ register("brief_read", {
34
+ title: "Read a Tower daily page",
35
+ description: "The TODAY page as text — the same page the website and `cockpit brief` show. Optionally one pinned version, "
36
+ + "one of the subject's own past days, the TLDR only, the claim ids you would file a correction against, or "
37
+ + "what changed since the day before.",
38
+ inputSchema: {
39
+ subject: SUBJECT,
40
+ date: z
41
+ .string()
42
+ .min(1)
43
+ .max(20)
44
+ .optional()
45
+ .describe('YYYY-MM-DD, "today" or "yesterday" — sent verbatim; the server owns where a day starts.'),
46
+ version: z.string().min(1).max(100).optional().describe("One exact stored page id instead of the newest."),
47
+ tldr: z.boolean().optional().describe("Just the TLDR."),
48
+ versions: z.boolean().optional().describe("Also list the earlier stored versions."),
49
+ claims: z.boolean().optional().describe("Also list claim ids — what `cockpit correct --claim` takes."),
50
+ delta: z.boolean().optional().describe("Also say what changed since the previous day."),
51
+ against: z.string().min(1).max(20).optional().describe("Compare against this date instead of the previous day."),
52
+ },
53
+ }, async (args) => withSession(deps, async (session) => {
54
+ const params = new URLSearchParams();
55
+ if (args.subject)
56
+ params.set("p", String(args.subject));
57
+ if (args.version)
58
+ params.set("v", String(args.version));
59
+ if (args.date)
60
+ params.set("d", String(args.date));
61
+ if (args.tldr)
62
+ params.set("tldr", "1");
63
+ if (args.versions)
64
+ params.set("versions", "1");
65
+ if (args.claims)
66
+ params.set("claims", "1");
67
+ if (args.delta) {
68
+ params.set("delta", "1");
69
+ if (args.against)
70
+ params.set("against", String(args.against));
71
+ }
72
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/jarvis/brief${queryString(params)}`);
73
+ if (!response.ok)
74
+ return errorResult(doorFailureText("brief_read", response));
75
+ const body = response.body;
76
+ if (!body.ok || typeof body.text !== "string") {
77
+ // The route's own plain-words notice ("Nothing has been written for
78
+ // you yet.") is an answer, not a stack trace — relayed as it stands.
79
+ return errorResult(`Tower returned no page for brief_read (${body.error ?? "no_page"}): `
80
+ + (body.reply ?? "Tower answered without a page."));
81
+ }
82
+ const whose = body.page?.displayName ? `${body.page.displayName}'s page` : "This page";
83
+ const day = body.day?.date ? ` · ${body.day.date}${body.day.zone ? ` ${body.day.zone}` : ""}` : "";
84
+ const pinned = body.page?.olderVersionLabel ? ` · version from ${body.page.olderVersionLabel}` : "";
85
+ // A missing delta is SAID, never left as an empty space: an agent that
86
+ // asked what changed and got silence would report that nothing did.
87
+ const deltaText = args.delta
88
+ ? `\n\n--- what changed ---\n${body.delta?.headline ?? `No delta (${body.deltaReason ?? "no reason given"}).`}`
89
+ : "";
90
+ const claimText = body.claims?.length
91
+ ? `\n\n--- claim ids ---\n${body.claims.map((claim) => `[${claim.claimId}] ${claim.text ?? ""}`).join("\n")}`
92
+ : "";
93
+ const versionText = body.versions
94
+ ? body.versions.length === 0
95
+ ? `\n\n(earlier versions could not be listed: ${body.versionsReason ?? "no reason given"})`
96
+ : `\n\n--- versions ---\n${body.versions.map((v) => `${v.version}/${v.of} ${v.pageId} ${v.asOf}`).join("\n")}`
97
+ : "";
98
+ return textResult(`${whose}${pinned}${day}\n\n${body.text}${deltaText}${claimText}${versionText}`, {
99
+ text: body.text,
100
+ page: body.page ?? null,
101
+ day: body.day ?? null,
102
+ visibleBecause: body.visibleBecause ?? null,
103
+ ...(body.claims ? { claims: body.claims } : {}),
104
+ ...(body.versions ? { versions: body.versions, versionsReason: body.versionsReason ?? null } : {}),
105
+ ...(args.delta ? { delta: body.delta ?? null, deltaReason: body.deltaReason ?? null } : {}),
106
+ });
107
+ }));
108
+ register("brief_history", {
109
+ title: "List a subject's daily pages",
110
+ description: "Which days a person has a page for, newest first — dates and page ids, no page text. Pass a page id to "
111
+ + "brief_read's `version` to open one.",
112
+ inputSchema: {
113
+ subject: SUBJECT,
114
+ days: z.number().int().min(1).max(365).optional().describe("How many days back to list."),
115
+ },
116
+ }, async (args) => withSession(deps, async (session) => {
117
+ const params = new URLSearchParams({ history: "1" });
118
+ if (args.subject)
119
+ params.set("p", String(args.subject));
120
+ if (typeof args.days === "number")
121
+ params.set("days", String(args.days));
122
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/jarvis/brief${queryString(params)}`);
123
+ if (!response.ok)
124
+ return errorResult(doorFailureText("brief_history", response));
125
+ const body = response.body;
126
+ const history = body.history ?? [];
127
+ const lines = history
128
+ .map((row) => `${row.date ?? "?"} ${row.pageId ?? "?"} ${row.versionCount ?? 1}v ${row.heading ?? ""}`.trimEnd())
129
+ .join("\n");
130
+ return textResult(`${history.length} day(s)${body.historyTruncated ? ", and more beyond the window" : ""}.${lines ? `\n${lines}` : ""}`, { history, historyTruncated: body.historyTruncated ?? false });
131
+ }));
132
+ register("brief_status", {
133
+ title: "Why a Tower brief was or was not delivered",
134
+ description: "The delivery reason for one person's brief, from the closed set the Slack DM cron itself computes "
135
+ + "(`delivered`, `not_monday`, `no_slack_identity`, …). A reason other than `delivered` is a real answer, "
136
+ + "not a failure. `render` also returns the TLDR the DM would have carried.",
137
+ inputSchema: {
138
+ subject: SUBJECT,
139
+ render: z.boolean().optional().describe("Also return the text the DM would have carried."),
140
+ },
141
+ }, async (args) => withSession(deps, async (session) => {
142
+ const params = new URLSearchParams();
143
+ if (args.subject)
144
+ params.set("who", String(args.subject));
145
+ if (args.render)
146
+ params.set("render", "1");
147
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/ops/brief-status${queryString(params)}`);
148
+ if (!response.ok)
149
+ return errorResult(doorFailureText("brief_status", response));
150
+ const body = response.body;
151
+ const status = body.status ?? {};
152
+ const reason = status.reason ?? "unknown";
153
+ const detail = status.detail ? `\n${status.detail}` : "";
154
+ const slack = status.slack?.hasIdentity === false ? "\nNo Slack identity is on file for them." : "";
155
+ const readError = status.readError ? `\n(one source could not be read: ${status.readError})` : "";
156
+ const rendered = body.render?.text ? `\n\n--- the DM would have said ---\n${body.render.text}` : "";
157
+ return textResult(`${status.displayName ?? "This person"} — ${reason}${detail}${slack}${readError}`
158
+ + `\n${status.owningCadence ?? "?"} owns this morning${status.timeZone ? ` (${status.timeZone})` : ""}${rendered}`, { status, ...(body.render ? { render: body.render } : {}) });
159
+ }));
160
+ }
@@ -9,13 +9,19 @@
9
9
  * on a machine with no collector pairing at all, so a missing session must
10
10
  * fail the ONE call that needed it, never the whole process.
11
11
  */
12
- import { loadAgentDoorSession } from "./agent-door-session.js";
13
- import { type FetchImpl } from "./agent-door.js";
14
- export interface DocsMsgDeps {
15
- fetchImpl: FetchImpl;
16
- /** Injectable for tests; defaults to reading `~/.config/bli-cockpit/session.json`. */
17
- loadSession?: typeof loadAgentDoorSession;
18
- }
12
+ import { type ToolDeps } from "./tool-result.js";
13
+ export type DocsMsgDeps = ToolDeps;
14
+ /**
15
+ * `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
16
+ * The list itself is metadata only — asking for a whole library used to cost
17
+ * every document's prose, so an agent that wants one folder or one phrase
18
+ * says so here and the database does the filtering.
19
+ */
20
+ export declare function docsListPath(args: {
21
+ parent_id?: unknown;
22
+ query?: unknown;
23
+ limit?: unknown;
24
+ }): string;
19
25
  export declare function registerDocsMsgTools(server: {
20
26
  registerTool: (...args: never[]) => unknown;
21
27
  }, deps: DocsMsgDeps): void;
@@ -10,28 +10,37 @@
10
10
  * fail the ONE call that needed it, never the whole process.
11
11
  */
12
12
  import { z } from "zod";
13
- import { loadAgentDoorSession } from "./agent-door-session.js";
14
13
  import { callAgentDoor } from "./agent-door.js";
15
- function textResult(text, structured) {
16
- return { content: [{ type: "text", text }], ...(structured ? { structuredContent: structured } : {}) };
14
+ import { doorFailureText, errorResult, registrarFor, textResult, withSession, } from "./tool-result.js";
15
+ /** Indented lines, deepest last the same shape `cockpit docs tree` prints. */
16
+ function renderDocTree(nodes, depth) {
17
+ return nodes
18
+ .map((node) => {
19
+ const line = `${" ".repeat(depth)}${node.title} (${node.slug ?? node.id})`;
20
+ const children = node.children?.length ? `\n${renderDocTree(node.children, depth + 1)}` : "";
21
+ return `${line}${children}`;
22
+ })
23
+ .join("\n");
17
24
  }
18
- function errorResult(text) {
19
- return { isError: true, content: [{ type: "text", text }] };
25
+ function countTree(nodes) {
26
+ return nodes.reduce((count, node) => count + 1 + countTree(node.children ?? []), 0);
20
27
  }
21
- async function withSession(deps, run) {
22
- const loadSession = deps.loadSession ?? loadAgentDoorSession;
23
- const loaded = loadSession();
24
- if (!loaded.ok)
25
- return errorResult(`This machine is not paired with Tower (${loaded.reason}). ${loaded.message}`);
26
- return run(loaded.session);
27
- }
28
- function doorFailureText(door, response) {
29
- if (response.transportError) {
30
- return `Tower could not be reached for ${door} (${response.transportError}). Nothing was read or written.`;
31
- }
32
- const reason = typeof response.body.reason === "string" ? response.body.reason : typeof response.body.error === "string" ? response.body.error : "unknown_error";
33
- const message = typeof response.body.message === "string" ? response.body.message : `Tower answered ${response.status}.`;
34
- return `Tower refused ${door} (${reason}): ${message}`;
28
+ /**
29
+ * `docs_list`'s narrowing, as the door's own query parameters (BLI-3737).
30
+ * The list itself is metadata only — asking for a whole library used to cost
31
+ * every document's prose, so an agent that wants one folder or one phrase
32
+ * says so here and the database does the filtering.
33
+ */
34
+ export function docsListPath(args) {
35
+ const params = new URLSearchParams();
36
+ if (typeof args.parent_id === "string" && args.parent_id.length > 0)
37
+ params.set("parent_id", args.parent_id);
38
+ if (typeof args.query === "string" && args.query.trim().length > 0)
39
+ params.set("query", args.query);
40
+ if (typeof args.limit === "number" && Number.isInteger(args.limit))
41
+ params.set("limit", String(args.limit));
42
+ const search = params.toString();
43
+ return search.length > 0 ? `/api/docs/documents?${search}` : "/api/docs/documents";
35
44
  }
36
45
  async function resolveDocumentId(deps, session, ref) {
37
46
  const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
@@ -51,18 +60,37 @@ async function resolveChannelId(deps, session, ref) {
51
60
  return match ? { status: "ok", id: match.id } : { status: "not_found" };
52
61
  }
53
62
  export function registerDocsMsgTools(server, deps) {
54
- const register = server.registerTool.bind(server);
63
+ const register = registrarFor(server);
55
64
  register("docs_list", {
56
65
  title: "List Tower documents",
57
- description: "Every document you may read: id, slug, title, visibility, source. Never a document's body call docs_read for that.",
58
- inputSchema: {},
59
- }, async () => withSession(deps, async (session) => {
60
- const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/documents");
66
+ description: "Every document you may read: id, slug, title, visibility, source, parent_id and body_chars (how long the body is). "
67
+ + "Never a document's body — call docs_read for that. Narrow before reading: parent_id (a document id, or \"root\" for the "
68
+ + "top of the tree), query (a case-insensitive substring matched in the database over title AND body, never fuzzy), limit (1-1000).",
69
+ inputSchema: {
70
+ parent_id: z.string().min(1).max(64).optional().describe('A document id to list under, or "root" for the top of the tree.'),
71
+ query: z.string().min(1).max(200).optional().describe("Keep only documents whose title or body contains this text."),
72
+ limit: z.number().int().min(1).max(1000).optional().describe("At most this many documents."),
73
+ },
74
+ }, async (args) => withSession(deps, async (session) => {
75
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", docsListPath(args));
61
76
  if (!response.ok)
62
77
  return errorResult(doorFailureText("docs_list", response));
63
78
  const documents = response.body.documents ?? [];
64
79
  return textResult(`${documents.length} document(s).`, { documents });
65
80
  }));
81
+ register("docs_tree", {
82
+ title: "Read the Tower document tree",
83
+ description: "The document library as its nesting, not as a flat list: every document you may read with its children "
84
+ + "under it, id, slug and title. Never a body — call docs_read for one.",
85
+ inputSchema: {},
86
+ }, async () => withSession(deps, async (session) => {
87
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/docs/tree");
88
+ if (!response.ok)
89
+ return errorResult(doorFailureText("docs_tree", response));
90
+ const tree = (Array.isArray(response.body.tree) ? response.body.tree : []);
91
+ const rendered = renderDocTree(tree, 0);
92
+ return textResult(`${countTree(tree)} document(s).${rendered ? `\n${rendered}` : ""}`, { tree });
93
+ }));
66
94
  register("docs_read", {
67
95
  title: "Read a Tower document",
68
96
  description: "One document's title and full body, by its id or slug (docs_list returns both).",
@@ -161,6 +189,84 @@ export function registerDocsMsgTools(server, deps) {
161
189
  const messages = response.body.messages ?? [];
162
190
  return textResult(`${messages.length} message(s) in ${ref}.`, { channelId: resolved.id, messages });
163
191
  }));
192
+ register("msg_thread", {
193
+ title: "Read a Tower message thread",
194
+ description: "The replies under one message, oldest first. `thread_parent_id` is the id of the message the thread hangs "
195
+ + "off — msg_read returns it. `channel` is a channel id, or its name with or without a leading #.",
196
+ inputSchema: {
197
+ channel: z.string().min(1).max(200),
198
+ thread_parent_id: z.string().uuid().describe("The id of the message the replies hang off."),
199
+ },
200
+ }, async (args) => withSession(deps, async (session) => {
201
+ const ref = String(args.channel ?? "");
202
+ const resolved = await resolveChannelId(deps, session, ref);
203
+ if (resolved.status === "list_failed")
204
+ return errorResult(resolved.text);
205
+ if (resolved.status === "not_found")
206
+ return errorResult(`No channel matches "${ref}" — check msg_channels for the id or name.`);
207
+ const parent = String(args.thread_parent_id ?? "");
208
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/msg/channels/${encodeURIComponent(resolved.id)}/messages?thread_parent_id=${encodeURIComponent(parent)}`);
209
+ if (!response.ok)
210
+ return errorResult(doorFailureText("msg_thread", response));
211
+ const messages = (Array.isArray(response.body.messages) ? response.body.messages : []);
212
+ // Oldest first: a thread reads as a conversation, unlike the channel
213
+ // list, which the door returns newest-first.
214
+ const lines = [...messages]
215
+ .reverse()
216
+ .map((message) => `${String(message.created_at ?? "?")} ${String(message.agent_label ?? message.user_id ?? "?")} `
217
+ + `${String(message.content ?? "")}`)
218
+ .join("\n");
219
+ return textResult(`${messages.length} repl${messages.length === 1 ? "y" : "ies"} in ${ref}.${lines ? `\n${lines}` : ""}`, { channelId: resolved.id, threadParentId: parent, messages });
220
+ }));
221
+ register("msg_create_channel", {
222
+ title: "Create a Tower channel",
223
+ description: "Creates a channel and returns its id. `private` means membership decides who may read it. `member_emails` names who joins at birth BY EMAIL — an address Tower does not carry refuses the whole create, so no half-built channel is left behind.",
224
+ inputSchema: {
225
+ name: z.string().min(1).max(80).describe("The channel name. A leading # is accepted and dropped."),
226
+ description: z.string().max(500).optional(),
227
+ private: z.boolean().optional(),
228
+ member_emails: z.array(z.string().min(3).max(320)).max(200).optional(),
229
+ },
230
+ }, async (args) => withSession(deps, async (session) => {
231
+ const raw = String(args.name ?? "");
232
+ const name = raw.startsWith("#") ? raw.slice(1) : raw;
233
+ if (name.trim() === "")
234
+ return errorResult("A channel needs a name. Nothing was created.");
235
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/msg/channels", {
236
+ name,
237
+ is_private: args.private === true,
238
+ ...(args.description ? { description: args.description } : {}),
239
+ ...(Array.isArray(args.member_emails) ? { member_emails: args.member_emails } : {}),
240
+ });
241
+ if (!response.ok)
242
+ return errorResult(doorFailureText("msg_create_channel", response));
243
+ const channel = response.body.channel;
244
+ const membersAdded = (response.body.members_added ?? []);
245
+ const membersFailed = (response.body.members_failed ?? []);
246
+ // A member the channel could not take is named in the SENTENCE, not
247
+ // just in the structured half: an agent that only reads the text
248
+ // would otherwise report a clean create over a partial one.
249
+ const failedNote = membersFailed.length > 0
250
+ ? ` ${membersFailed.length} member(s) were not added (${membersFailed.map((f) => f.reason ?? "unknown").join(", ")}).`
251
+ : "";
252
+ return textResult(`Created #${channel?.name ?? name} (${channel?.id ?? "?"}) with ${membersAdded.length} member(s) added.${failedNote}`, { channel, members_added: membersAdded, members_failed: membersFailed });
253
+ }));
254
+ register("msg_dm", {
255
+ title: "Open a Tower direct message",
256
+ description: "Opens (or re-opens) the direct message with one person, by their email address. Idempotent: the same address always resolves to the same channel, and you never name yourself.",
257
+ inputSchema: { email: z.string().min(3).max(320).describe("The other person's email address.") },
258
+ }, async (args) => withSession(deps, async (session) => {
259
+ const email = String(args.email ?? "").trim();
260
+ if (email === "")
261
+ return errorResult("A direct message needs the person's email address.");
262
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/msg/channels", {
263
+ dm_participant_emails: [email],
264
+ });
265
+ if (!response.ok)
266
+ return errorResult(doorFailureText("msg_dm", response));
267
+ const channel = response.body.channel;
268
+ return textResult(`Direct message with ${email}: ${channel?.id ?? "?"}.`, { channel });
269
+ }));
164
270
  register("msg_send", {
165
271
  title: "Send a Tower message",
166
272
  description: "Posts a message to one channel. `channel` is a channel id, or its name with or without a leading #.",
@@ -0,0 +1,33 @@
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
+ import { type ToolDeps } from "./tool-result.js";
19
+ export type NotesDeps = ToolDeps;
20
+ interface ScopedBody {
21
+ scope?: string;
22
+ degradedBecause?: string | null;
23
+ degradedNote?: string;
24
+ }
25
+ /**
26
+ * The door's own words for "this answer is narrower than the browser's", or
27
+ * "" when it was not narrowed. Never rephrased here.
28
+ */
29
+ export declare function narrowingNote(body: ScopedBody): string;
30
+ export declare function registerNotesTools(server: {
31
+ registerTool: (...args: never[]) => unknown;
32
+ }, deps: NotesDeps): void;
33
+ export {};