@bli-cockpit/mcp 0.1.2 → 0.1.4

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.
Files changed (41) hide show
  1. package/README.md +168 -2
  2. package/dist/agent-door.d.ts +8 -1
  3. package/dist/agent-door.js +7 -2
  4. package/dist/brief-tools.d.ts +26 -0
  5. package/dist/brief-tools.js +160 -0
  6. package/dist/brief-write-tools.d.ts +37 -0
  7. package/dist/brief-write-tools.js +178 -0
  8. package/dist/docs-msg-tools.d.ts +13 -7
  9. package/dist/docs-msg-tools.js +141 -25
  10. package/dist/jarvis-answer-envelope.d.ts +13 -1
  11. package/dist/jarvis-answer-envelope.js +2 -0
  12. package/dist/jarvis-door.js +16 -1
  13. package/dist/jarvis-tools.d.ts +18 -10
  14. package/dist/jarvis-tools.js +28 -17
  15. package/dist/notes-tools.d.ts +33 -0
  16. package/dist/notes-tools.js +143 -0
  17. package/dist/notes-write-tools.d.ts +34 -0
  18. package/dist/notes-write-tools.js +211 -0
  19. package/dist/ops-tools.d.ts +23 -0
  20. package/dist/ops-tools.js +212 -0
  21. package/dist/pages-tools.d.ts +28 -0
  22. package/dist/pages-tools.js +190 -0
  23. package/dist/readme-census.d.ts +18 -0
  24. package/dist/readme-census.js +79 -0
  25. package/dist/search-tool.d.ts +54 -0
  26. package/dist/search-tool.js +134 -0
  27. package/dist/server.d.ts +1 -1
  28. package/dist/server.js +34 -1
  29. package/dist/settings-tools.d.ts +29 -0
  30. package/dist/settings-tools.js +151 -0
  31. package/dist/settings-write-tools.d.ts +47 -0
  32. package/dist/settings-write-tools.js +183 -0
  33. package/dist/team-write-tools.d.ts +39 -0
  34. package/dist/team-write-tools.js +141 -0
  35. package/dist/tool-result.d.ts +86 -0
  36. package/dist/tool-result.js +105 -0
  37. package/dist/verb-census.d.ts +22 -0
  38. package/dist/verb-census.js +108 -6
  39. package/dist/work-tools.d.ts +2 -7
  40. package/dist/work-tools.js +35 -25
  41. package/package.json +5 -4
@@ -0,0 +1,151 @@
1
+ /**
2
+ * `settings_*` / `team_*` / `model_show` MCP tools (BLI-3756) — the settings
3
+ * surface read by an agent, over the same `/api/settings/*` and `/api/team/*`
4
+ * doors `cockpit settings`, `cockpit team` and `cockpit model` call with the
5
+ * same device token.
6
+ *
7
+ * One file because the dashboard has one owner for all of it (`lib/settings/`
8
+ * behind `settings-gate.ts`, which the team directory and the model shorthand
9
+ * both sit behind), and because the reads share the rule below.
10
+ *
11
+ * **"Admin only" is not an error.** A 403 on a section is the system working;
12
+ * `settings_show` renders it as one line saying so and keeps every other
13
+ * section, exactly as the CLI does. Returning `isError` for the whole call
14
+ * would teach an agent that the surface is broken when in fact it is simply
15
+ * not this caller's to see.
16
+ *
17
+ * **A secret never comes back.** `settings_list` lists env-blob NAMES, sizes
18
+ * and stamps — the server cannot return a value and nothing here asks for one.
19
+ * Writing content is stdin-only by rule and stays in batch 2.
20
+ */
21
+ import { z } from "zod";
22
+ import { callAgentDoor } from "./agent-door.js";
23
+ import { doorFailureText, errorResult, registrarFor, textResult, withSession, } from "./tool-result.js";
24
+ /** The sections `cockpit settings [section]` reads, plus the all-at-once view. */
25
+ export const SETTINGS_SECTIONS = ["overview", "personal", "switches", "models", "cli-floor"];
26
+ /** Which door each section is behind. The overview reads all of them at once. */
27
+ const SECTION_DOORS = {
28
+ personal: "/api/settings/personal",
29
+ switches: "/api/settings/switches",
30
+ models: "/api/settings/model-routing",
31
+ "cli-floor": "/api/settings/cli-floor",
32
+ env: "/api/settings/env-blobs",
33
+ team: "/api/team/members",
34
+ };
35
+ /**
36
+ * A section's answer, or the reason it is absent. `admin_only` is deliberately
37
+ * its own label and not an error — see the header.
38
+ */
39
+ function sectionPayload(response, door) {
40
+ if (response.ok)
41
+ return response.body;
42
+ if (response.status === 403)
43
+ return { visible: false, reason: "admin_only" };
44
+ return { visible: false, reason: doorFailureText(door, response) };
45
+ }
46
+ function sectionWord(response) {
47
+ if (response.ok)
48
+ return "ok";
49
+ return response.status === 403 ? "admin only" : "unavailable";
50
+ }
51
+ async function readSection(deps, session, path) {
52
+ return callAgentDoor(session, deps.fetchImpl, "GET", path);
53
+ }
54
+ async function oneSection(deps, session, tool, path) {
55
+ const response = await readSection(deps, session, path);
56
+ if (!response.ok)
57
+ return errorResult(doorFailureText(tool, response));
58
+ return textResult(JSON.stringify(response.body, null, 2), response.body);
59
+ }
60
+ export function registerSettingsTools(server, deps) {
61
+ const register = registrarFor(server);
62
+ register("settings_show", {
63
+ title: "Read Tower settings",
64
+ description: "Settings as this caller may see them. `overview` (the default) reads all six sections at once and names any "
65
+ + "you may not see as \"admin only\" rather than failing; a named section reads just that one. Never a secret's "
66
+ + "value — settings_list lists env files by name only.",
67
+ inputSchema: {
68
+ section: z.enum(SETTINGS_SECTIONS).optional().describe("Defaults to overview — everything at once."),
69
+ },
70
+ }, async (args) => withSession(deps, async (session) => {
71
+ const section = String(args.section ?? "overview");
72
+ if (section !== "overview") {
73
+ const path = SECTION_DOORS[section];
74
+ if (!path)
75
+ return errorResult(`settings_show has no section "${section}".`);
76
+ return oneSection(deps, session, "settings_show", path);
77
+ }
78
+ // The six reads go out together: they are independent, and six
79
+ // sequential round trips would read as a hang.
80
+ const keys = ["personal", "team", "switches", "models", "env", "cli-floor"];
81
+ const responses = await Promise.all(keys.map((key) => readSection(deps, session, SECTION_DOORS[key])));
82
+ const byKey = Object.fromEntries(keys.map((key, index) => [key, responses[index]]));
83
+ // The one read every signed-in person is entitled to. If THAT is
84
+ // refused for any reason other than permission, nothing else here is
85
+ // going to work either, so say so once and stop.
86
+ const personal = byKey.personal;
87
+ if (!personal.ok && personal.status !== 403) {
88
+ return errorResult(doorFailureText("settings_show", personal));
89
+ }
90
+ const lines = keys
91
+ .map((key) => `${key.padEnd(10)} ${sectionWord(byKey[key])}`)
92
+ .join("\n");
93
+ const payload = Object.fromEntries(keys.map((key) => [key, sectionPayload(byKey[key], `settings ${key}`)]));
94
+ return textResult(`TOWER SETTINGS\n${lines}\n\n${JSON.stringify(payload, null, 2)}`, payload);
95
+ }));
96
+ register("settings_list", {
97
+ title: "List Tower env files",
98
+ description: "The env files Tower holds: name, size and when each was last written. The server cannot return a value and "
99
+ + "this tool has no way to ask for one — content is write-only, on stdin, through the CLI.",
100
+ inputSchema: {},
101
+ }, async () => withSession(deps, async (session) => oneSection(deps, session, "settings_list", SECTION_DOORS.env)));
102
+ register("model_show", {
103
+ title: "Which model is answering",
104
+ description: "The chat and brief models in force for this account — the short way to read `settings personal`, because "
105
+ + "\"which model am I talking to?\" is the question people actually ask.",
106
+ inputSchema: {},
107
+ }, async () => withSession(deps, async (session) => {
108
+ const response = await readSection(deps, session, "/api/settings/jarvis-model");
109
+ if (!response.ok)
110
+ return errorResult(doorFailureText("model_show", response));
111
+ return textResult(`chat model: ${modelKeyOf(response.body.chatModel)}\nbrief model: ${modelKeyOf(response.body.briefModel)}`, response.body);
112
+ }));
113
+ register("team_members", {
114
+ title: "Read the Tower team directory",
115
+ description: "Who is on this team, their roles, and any invitations still open.",
116
+ inputSchema: {},
117
+ }, async () => withSession(deps, async (session) => {
118
+ const response = await readSection(deps, session, "/api/team/members");
119
+ if (!response.ok)
120
+ return errorResult(doorFailureText("team_members", response));
121
+ const members = (Array.isArray(response.body.members) ? response.body.members : []);
122
+ const lines = members
123
+ .map((member) => `${String(member.role ?? "?").padEnd(12)} ${String(member.email ?? member.userId ?? "?")}`)
124
+ .join("\n");
125
+ return textResult(`${members.length} member(s).${lines ? `\n${lines}` : ""}`, response.body);
126
+ }));
127
+ register("team_device_list", {
128
+ title: "List the collector devices Tower knows",
129
+ description: "Every paired collector device: id, name, owner, version and when it last checked in. Read only — revoking "
130
+ + "one is irreversible for that token and stays a deliberate act.",
131
+ inputSchema: {},
132
+ }, async () => withSession(deps, async (session) => {
133
+ const response = await readSection(deps, session, "/api/team/devices");
134
+ if (!response.ok)
135
+ return errorResult(doorFailureText("team_device_list", response));
136
+ const devices = (Array.isArray(response.body.devices) ? response.body.devices : []);
137
+ const lines = devices
138
+ .map((device) => `${String(device.device_id ?? "?")} ${String(device.device_name ?? "?")} `
139
+ + `${String(device.cli_version ?? "?")} last seen ${String(device.last_seen_at ?? "never")}`)
140
+ .join("\n");
141
+ return textResult(`${devices.length} device(s).${lines ? `\n${lines}` : ""}`, response.body);
142
+ }));
143
+ }
144
+ /** `provider:model`, or `unset`. The same rendering `cockpit model show` prints. */
145
+ export function modelKeyOf(value) {
146
+ const pair = value && typeof value === "object" ? value : {};
147
+ if (typeof pair.provider === "string" && typeof pair.model === "string") {
148
+ return `${pair.provider}:${pair.model}`;
149
+ }
150
+ return "unset";
151
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `settings_set` / `settings_delete` / `model_set` MCP tools (BLI-3756 batch
3
+ * 2) — changing a setting from an agent, over the same `/api/settings/*` doors
4
+ * `cockpit settings` and `cockpit model` call with the same device token.
5
+ *
6
+ * Reads live in `settings-tools.ts`. Nothing here decides policy: every
7
+ * allowlist, every super_admin check, the belt pre-flight on a chat model and
8
+ * every audit line lives on the server, so this surface cannot drift into
9
+ * permitting something the browser refuses. What it owns is the shape of the
10
+ * ask and the words of a refusal.
11
+ *
12
+ * **The env content rule, restated for this surface.** In the terminal an env
13
+ * blob's content is stdin-only *by rule* — there is no flag that takes a
14
+ * value, so it never reaches an argument vector, a shell history or a process
15
+ * list. A tool call has none of those: the arguments travel inside the MCP
16
+ * transport the same way a request body does. So `settings_set` takes the
17
+ * content as an argument, and the rule that survives is the one that mattered
18
+ * — **it is never echoed**: not in the answer, not in the structured payload,
19
+ * not in a log line. The server cannot return a value either, and
20
+ * `settings_list` lists names, sizes and stamps only.
21
+ *
22
+ * **A delete is deliberate.** `confirm: true` or nothing happens, in the CLI's
23
+ * own `confirmation_required` words.
24
+ */
25
+ import { type ToolDeps } from "./tool-result.js";
26
+ export type SettingsWriteDeps = ToolDeps;
27
+ /** The sections `cockpit settings <section> set` can write. */
28
+ export declare const SETTINGS_WRITE_SECTIONS: readonly ["personal", "switches", "models", "cli-floor", "env"];
29
+ /**
30
+ * `provider:model` → the pair every model door takes. The same parse
31
+ * `commands/tower-command.ts` does, and the same refusal: a key without a
32
+ * separator is a typo, and forwarding it would make the SERVER answer a
33
+ * question about a model nobody named.
34
+ */
35
+ export declare function parseModelKey(key: string): {
36
+ ok: true;
37
+ value: {
38
+ provider: string;
39
+ model: string;
40
+ };
41
+ } | {
42
+ ok: false;
43
+ sentence: string;
44
+ };
45
+ export declare function registerSettingsWriteTools(server: {
46
+ registerTool: (...args: never[]) => unknown;
47
+ }, deps: SettingsWriteDeps): void;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `settings_set` / `settings_delete` / `model_set` MCP tools (BLI-3756 batch
3
+ * 2) — changing a setting from an agent, over the same `/api/settings/*` doors
4
+ * `cockpit settings` and `cockpit model` call with the same device token.
5
+ *
6
+ * Reads live in `settings-tools.ts`. Nothing here decides policy: every
7
+ * allowlist, every super_admin check, the belt pre-flight on a chat model and
8
+ * every audit line lives on the server, so this surface cannot drift into
9
+ * permitting something the browser refuses. What it owns is the shape of the
10
+ * ask and the words of a refusal.
11
+ *
12
+ * **The env content rule, restated for this surface.** In the terminal an env
13
+ * blob's content is stdin-only *by rule* — there is no flag that takes a
14
+ * value, so it never reaches an argument vector, a shell history or a process
15
+ * list. A tool call has none of those: the arguments travel inside the MCP
16
+ * transport the same way a request body does. So `settings_set` takes the
17
+ * content as an argument, and the rule that survives is the one that mattered
18
+ * — **it is never echoed**: not in the answer, not in the structured payload,
19
+ * not in a log line. The server cannot return a value either, and
20
+ * `settings_list` lists names, sizes and stamps only.
21
+ *
22
+ * **A delete is deliberate.** `confirm: true` or nothing happens, in the CLI's
23
+ * own `confirmation_required` words.
24
+ */
25
+ import { z } from "zod";
26
+ import { callAgentDoor } from "./agent-door.js";
27
+ import { CONFIRM_INPUT, doorFailureText, errorResult, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
28
+ import { modelKeyOf } from "./settings-tools.js";
29
+ /** The sections `cockpit settings <section> set` can write. */
30
+ export const SETTINGS_WRITE_SECTIONS = ["personal", "switches", "models", "cli-floor", "env"];
31
+ const WRITE_DEADLINE_MS = 60_000;
32
+ /**
33
+ * `provider:model` → the pair every model door takes. The same parse
34
+ * `commands/tower-command.ts` does, and the same refusal: a key without a
35
+ * separator is a typo, and forwarding it would make the SERVER answer a
36
+ * question about a model nobody named.
37
+ */
38
+ export function parseModelKey(key) {
39
+ const separator = key.indexOf(":");
40
+ if (separator <= 0 || separator === key.length - 1) {
41
+ return {
42
+ ok: false,
43
+ sentence: `\`${key}\` is not a model key. Use provider:model, e.g. openai:gpt-5.6-terra.`,
44
+ };
45
+ }
46
+ return { ok: true, value: { provider: key.slice(0, separator), model: key.slice(separator + 1) } };
47
+ }
48
+ async function write(deps, session, tool, method, path, body, say) {
49
+ const response = await callAgentDoor(session, deps.fetchImpl, method, path, body, WRITE_DEADLINE_MS);
50
+ if (!response.ok)
51
+ return errorResult(doorFailureText(tool, response));
52
+ return textResult(say(response.body), response.body);
53
+ }
54
+ export function registerSettingsWriteTools(server, deps) {
55
+ const register = registrarFor(server);
56
+ register("settings_set", {
57
+ title: "Change one Tower setting",
58
+ description: "Writes one settings section — `cockpit settings <section> set`, same doors. `personal`: your own chat/brief "
59
+ + "model (provider:model). `switches`: an access or feature switch, admin only. `models`: the org-wide map, "
60
+ + "admin only, and the candidate is sent the REAL tool belt before it is saved — a provider that refuses the "
61
+ + "belt refuses the switch. `cli-floor`: the fleet forced-update floor, which only ever goes up. `env`: store "
62
+ + "an env file's content; the content is never echoed back, and no door can read one out again.",
63
+ inputSchema: {
64
+ section: z.enum(SETTINGS_WRITE_SECTIONS).describe("Which section to write."),
65
+ chat_model: z.string().min(3).max(200).optional().describe("personal/models: provider:model."),
66
+ brief_model: z.string().min(3).max(200).optional().describe("personal: provider:model."),
67
+ memory_model: z.string().min(1).max(200).optional().describe("models: the memory compiler's model id."),
68
+ key: z.string().min(1).max(200).optional().describe("switches: which switch."),
69
+ value: z.string().min(1).max(200).optional().describe("switches: its new value."),
70
+ version: z.string().min(1).max(50).optional().describe("cli-floor: the version every collector is pulled to."),
71
+ project: z.string().min(1).max(200).optional().describe("env: which project the file belongs to."),
72
+ // Deliberately no example filename: `scripts/assert-public-package-pack.mjs`
73
+ // bans an env-file literal in a published tarball, and this file ships.
74
+ file_name: z.string().min(1).max(200).optional().describe("env: the file's name, as settings_list shows it."),
75
+ content: z
76
+ .string()
77
+ .min(1)
78
+ .max(1_000_000)
79
+ .optional()
80
+ .describe("env: the file's contents. Sent to Tower and never echoed back — not here, not in a log."),
81
+ },
82
+ }, async (args) => withSession(deps, async (session) => {
83
+ const section = String(args.section ?? "");
84
+ switch (section) {
85
+ case "personal": {
86
+ const body = {};
87
+ for (const [field, key] of [["chat_model", "chatModel"], ["brief_model", "briefModel"]]) {
88
+ if (!args[field])
89
+ continue;
90
+ const parsed = parseModelKey(String(args[field]));
91
+ if (!parsed.ok)
92
+ return errorResult(`Refused (bad_model_key): ${parsed.sentence}`);
93
+ body[key] = parsed.value;
94
+ }
95
+ if (Object.keys(body).length === 0) {
96
+ return errorResult("Refused (nothing_to_set): name chat_model or brief_model. Nothing was changed.");
97
+ }
98
+ return write(deps, session, "settings_set", "POST", "/api/settings/jarvis-model", body, (answer) => `Saved your model choices. chat: ${modelKeyOf(answer.chatModel)} · brief: ${modelKeyOf(answer.briefModel)}`);
99
+ }
100
+ case "switches": {
101
+ if (!args.key || !args.value) {
102
+ return errorResult("Refused (nothing_to_set): a switch needs both `key` and `value`. Nothing was changed.");
103
+ }
104
+ return write(deps, session, "settings_set", "POST", "/api/settings/switches", { key: String(args.key), value: String(args.value) }, (answer) => `Switch ${String(answer.key)} is now ${String(answer.value)}.`);
105
+ }
106
+ case "models": {
107
+ const body = {};
108
+ if (args.chat_model) {
109
+ const parsed = parseModelKey(String(args.chat_model));
110
+ if (!parsed.ok)
111
+ return errorResult(`Refused (bad_model_key): ${parsed.sentence}`);
112
+ body.chatModel = parsed.value;
113
+ }
114
+ if (args.memory_model)
115
+ body.memoryModel = String(args.memory_model);
116
+ if (Object.keys(body).length === 0) {
117
+ return errorResult("Refused (nothing_to_set): name chat_model or memory_model. Nothing was changed.");
118
+ }
119
+ return write(deps, session, "settings_set", "POST", "/api/settings/model-routing", body, (answer) => {
120
+ const saved = Array.isArray(answer.saved) ? answer.saved.join(", ") : "";
121
+ // BLI-3481: the save succeeded but the belt pre-flight could not
122
+ // reach the provider, so nobody has checked that the model just
123
+ // chosen will accept Tower's tool belt. A bare "Saved." there
124
+ // would be a silent success.
125
+ const warning = typeof answer.warning === "string" && answer.warning ? `\n${answer.warning}` : "";
126
+ return `Saved: ${saved || "nothing"}.${warning}`;
127
+ });
128
+ }
129
+ case "cli-floor": {
130
+ if (!args.version) {
131
+ return errorResult("Refused (nothing_to_set): the floor needs a `version`. Nothing was changed.");
132
+ }
133
+ return write(deps, session, "settings_set", "PUT", "/api/settings/cli-floor", { version: String(args.version) }, (answer) => answer.changed === true
134
+ ? `Fleet CLI floor raised ${String(answer.previous ?? "none")}→${String(answer.version)}.`
135
+ : `Fleet CLI floor is already ${String(answer.version)}; nothing changed.`);
136
+ }
137
+ case "env": {
138
+ if (!args.project || !args.file_name || !args.content) {
139
+ return errorResult("Refused (nothing_to_set): an env file needs `project`, `file_name` and `content`. Nothing was stored.");
140
+ }
141
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/settings/env-blobs", { project: String(args.project), file_name: String(args.file_name), content: String(args.content) }, WRITE_DEADLINE_MS);
142
+ if (!response.ok)
143
+ return errorResult(doorFailureText("settings_set", response));
144
+ // Deliberately narrow: the door's own answer carries no content
145
+ // field, and this hands back the file's identity and nothing else
146
+ // — never the bytes that just went in.
147
+ return textResult(`Stored ${String(args.file_name)} for ${String(args.project)}.`, {
148
+ ok: true,
149
+ blob: response.body.blob ?? null,
150
+ });
151
+ }
152
+ default:
153
+ return errorResult(`settings_set has no section "${section}".`);
154
+ }
155
+ }));
156
+ register("settings_delete", {
157
+ title: "Delete a Tower env file",
158
+ description: "Removes one stored env file by id — `cockpit settings env delete`, same door. Irreversible, so it is refused "
159
+ + "without `confirm: true`. Ids come from settings_list.",
160
+ inputSchema: {
161
+ id: z.string().min(1).max(200).describe("The env file's id, as settings_list reports it."),
162
+ confirm: CONFIRM_INPUT,
163
+ },
164
+ }, async (args) => withSession(deps, async (session) => {
165
+ const refusal = unconfirmed(args, "Deleting an env file cannot be undone. Call it again with confirm: true.");
166
+ if (refusal)
167
+ return refusal;
168
+ return write(deps, session, "settings_delete", "DELETE", "/api/settings/env-blobs", { id: String(args.id ?? "") }, (answer) => `Deleted env file ${String(answer.deleted_id ?? args.id)}.`);
169
+ }));
170
+ register("model_set", {
171
+ title: "Choose which model answers you",
172
+ description: "Sets YOUR chat model — the short way to say settings_set personal, because \"which model am I talking to?\" "
173
+ + "is the question people actually ask. Takes a provider:model key; the server owns which ones are allowed.",
174
+ inputSchema: {
175
+ model: z.string().min(3).max(200).describe("provider:model, e.g. openai:gpt-5.6-terra."),
176
+ },
177
+ }, async (args) => withSession(deps, async (session) => {
178
+ const parsed = parseModelKey(String(args.model ?? ""));
179
+ if (!parsed.ok)
180
+ return errorResult(`Refused (bad_model_key): ${parsed.sentence}`);
181
+ return write(deps, session, "model_set", "POST", "/api/settings/jarvis-model", { chatModel: parsed.value }, (answer) => `Chat model is now ${modelKeyOf(answer.chatModel)}.`);
182
+ }));
183
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * `team_invite` / `team_role` / `team_device_revoke` MCP tools (BLI-3756 batch
3
+ * 2) — the three things an admin does to the team, over the same `/api/team/*`
4
+ * and `/api/ambient/devices/*` doors `cockpit team` calls with the same device
5
+ * token.
6
+ *
7
+ * Reads live in `settings-tools.ts` (`team_members`, `team_device_list`). All
8
+ * three of these RELAY a decision rather than making one: the roster union,
9
+ * the super_admin gate, the self-role-change refusal and the audit line all
10
+ * live server-side, so a tool can be wrong about wording and never about
11
+ * permission.
12
+ *
13
+ * Two shapes worth naming.
14
+ *
15
+ * **The invite needs a team, and the obvious one is the caller's own.** So
16
+ * `team_id` is optional and the fallback is a real read of
17
+ * `/api/team/members` rather than a guess — a caller with no team is told
18
+ * that, which is true and fixable, instead of getting a uuid validation error
19
+ * from a body we assembled badly.
20
+ *
21
+ * **A revoke names the device before it asks.** `cockpit team device revoke`
22
+ * looks the device up, prints what it is about to end, and only then acts —
23
+ * the same "name it, then confirm" shape `dispatchCodingTask` uses, because a
24
+ * revoke is irreversible for that token. Here the naming happens in the
25
+ * refusal a caller gets without `confirm: true`, so the confirmation is spent
26
+ * on a device that has already been identified rather than on a string.
27
+ */
28
+ import { type ToolDeps } from "./tool-result.js";
29
+ export type TeamWriteDeps = ToolDeps;
30
+ /**
31
+ * The same closed vocabulary `commands/team-device-reasons.ts` carries, which
32
+ * is itself a copy of the dashboard's `lib/settings/device-revoke-reasons.ts`
33
+ * (a CLI workspace cannot import app code, and this one cannot import the CLI).
34
+ * Three lists; keep them in step by eye if any changes.
35
+ */
36
+ export declare const DEVICE_REVOKE_REASONS: readonly ["stale_test_pairing", "machine_retired", "person_left", "re_pairing", "other"];
37
+ export declare function registerTeamWriteTools(server: {
38
+ registerTool: (...args: never[]) => unknown;
39
+ }, deps: TeamWriteDeps): void;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * `team_invite` / `team_role` / `team_device_revoke` MCP tools (BLI-3756 batch
3
+ * 2) — the three things an admin does to the team, over the same `/api/team/*`
4
+ * and `/api/ambient/devices/*` doors `cockpit team` calls with the same device
5
+ * token.
6
+ *
7
+ * Reads live in `settings-tools.ts` (`team_members`, `team_device_list`). All
8
+ * three of these RELAY a decision rather than making one: the roster union,
9
+ * the super_admin gate, the self-role-change refusal and the audit line all
10
+ * live server-side, so a tool can be wrong about wording and never about
11
+ * permission.
12
+ *
13
+ * Two shapes worth naming.
14
+ *
15
+ * **The invite needs a team, and the obvious one is the caller's own.** So
16
+ * `team_id` is optional and the fallback is a real read of
17
+ * `/api/team/members` rather than a guess — a caller with no team is told
18
+ * that, which is true and fixable, instead of getting a uuid validation error
19
+ * from a body we assembled badly.
20
+ *
21
+ * **A revoke names the device before it asks.** `cockpit team device revoke`
22
+ * looks the device up, prints what it is about to end, and only then acts —
23
+ * the same "name it, then confirm" shape `dispatchCodingTask` uses, because a
24
+ * revoke is irreversible for that token. Here the naming happens in the
25
+ * refusal a caller gets without `confirm: true`, so the confirmation is spent
26
+ * on a device that has already been identified rather than on a string.
27
+ */
28
+ import { z } from "zod";
29
+ import { callAgentDoor } from "./agent-door.js";
30
+ import { CONFIRM_INPUT, doorFailureText, errorResult, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
31
+ const WRITE_DEADLINE_MS = 60_000;
32
+ /**
33
+ * The same closed vocabulary `commands/team-device-reasons.ts` carries, which
34
+ * is itself a copy of the dashboard's `lib/settings/device-revoke-reasons.ts`
35
+ * (a CLI workspace cannot import app code, and this one cannot import the CLI).
36
+ * Three lists; keep them in step by eye if any changes.
37
+ */
38
+ export const DEVICE_REVOKE_REASONS = [
39
+ "stale_test_pairing",
40
+ "machine_retired",
41
+ "person_left",
42
+ "re_pairing",
43
+ "other",
44
+ ];
45
+ /** Exact device_id, or an unambiguous case-insensitive name — the CLI's rule. */
46
+ function findDevice(devices, ref) {
47
+ const needle = ref.trim();
48
+ if (!needle)
49
+ return null;
50
+ const byId = devices.find((device) => device.device_id === needle);
51
+ if (byId)
52
+ return byId;
53
+ const lower = needle.toLowerCase();
54
+ const byName = devices.filter((device) => String(device.device_name ?? "").toLowerCase() === lower);
55
+ return byName.length === 1 ? (byName[0] ?? null) : null;
56
+ }
57
+ async function listDevices(deps, session) {
58
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/team/devices");
59
+ if (!response.ok)
60
+ return { ok: false, text: doorFailureText("team_device_revoke", response) };
61
+ return { ok: true, devices: (Array.isArray(response.body.devices) ? response.body.devices : []) };
62
+ }
63
+ export function registerTeamWriteTools(server, deps) {
64
+ const register = registrarFor(server);
65
+ register("team_invite", {
66
+ title: "Invite somebody to Tower",
67
+ description: "Sends a sign-in link to an email address — `cockpit team invite`, same door. super_admin, decided on the "
68
+ + "server. The team defaults to yours; pass `team_id` to name another.",
69
+ inputSchema: {
70
+ email: z.string().min(3).max(320).describe("Who to invite."),
71
+ role: z.string().min(1).max(50).describe("The role to invite them as; the server owns the vocabulary."),
72
+ team_id: z.string().min(1).max(100).optional().describe("Defaults to your own team."),
73
+ },
74
+ }, async (args) => withSession(deps, async (session) => {
75
+ let teamId = args.team_id ? String(args.team_id) : "";
76
+ if (!teamId) {
77
+ const directory = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/team/members");
78
+ if (!directory.ok)
79
+ return errorResult(doorFailureText("team_invite", directory));
80
+ const currentTeam = (directory.body.currentTeam ?? {});
81
+ if (typeof currentTeam.id !== "string") {
82
+ return errorResult("Refused (no_current_team): you do not belong to a team, so there is nowhere to invite them. "
83
+ + "Pass team_id. Nobody was invited.");
84
+ }
85
+ teamId = currentTeam.id;
86
+ }
87
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/team/invite", { email: String(args.email ?? ""), role: String(args.role ?? ""), team_id: teamId }, WRITE_DEADLINE_MS);
88
+ if (!response.ok)
89
+ return errorResult(doorFailureText("team_invite", response));
90
+ return textResult(`Invited ${String(args.email)} as ${String(response.body.role ?? args.role)}. `
91
+ + "Tower emailed them a sign-in link.", response.body);
92
+ }));
93
+ register("team_role", {
94
+ title: "Change somebody's Tower role",
95
+ description: "Changes what one person may do — `cockpit team role`, same door. super_admin, and the server refuses a "
96
+ + "caller changing their OWN role. Deliberate: refused without `confirm: true`.",
97
+ inputSchema: {
98
+ user_id: z.string().min(1).max(100).describe("Whose role, as team_members reports it."),
99
+ role: z.string().min(1).max(50).describe("The role to give them; the server owns the vocabulary."),
100
+ confirm: CONFIRM_INPUT,
101
+ },
102
+ }, async (args) => withSession(deps, async (session) => {
103
+ const refusal = unconfirmed(args, `Changing ${String(args.user_id)} to ${String(args.role)} changes what they may do. `
104
+ + "Call it again with confirm: true.");
105
+ if (refusal)
106
+ return refusal;
107
+ const response = await callAgentDoor(session, deps.fetchImpl, "PATCH", `/api/team/members/${encodeURIComponent(String(args.user_id ?? ""))}/role`, { role: String(args.role ?? "") }, WRITE_DEADLINE_MS);
108
+ if (!response.ok)
109
+ return errorResult(doorFailureText("team_role", response));
110
+ return textResult(`${String(response.body.user_id ?? args.user_id)} is now ${String(response.body.role ?? args.role)}.`, response.body);
111
+ }));
112
+ register("team_device_revoke", {
113
+ title: "End a collector device's pairing",
114
+ description: "Revokes one paired machine's token — `cockpit team device revoke`, same door. Irreversible for that token "
115
+ + "and super_admin, decided on the server. The device is looked up FIRST and named back to you; without "
116
+ + "`confirm: true` that naming IS the answer and nothing is revoked.",
117
+ inputSchema: {
118
+ device: z.string().min(1).max(200).describe("A device id, or a device name that matches exactly one machine."),
119
+ reason: z.enum(DEVICE_REVOKE_REASONS).describe("Why, from the closed vocabulary the audit records."),
120
+ note: z.string().min(1).max(120).optional().describe("Optional free text, 120 characters, server-enforced."),
121
+ confirm: CONFIRM_INPUT,
122
+ },
123
+ }, async (args) => withSession(deps, async (session) => {
124
+ const listed = await listDevices(deps, session);
125
+ if (!listed.ok)
126
+ return errorResult(listed.text);
127
+ const device = findDevice(listed.devices, String(args.device ?? ""));
128
+ if (!device?.device_id) {
129
+ return errorResult(`Refused (device_not_found): no device matches "${String(args.device)}". `
130
+ + "Call team_device_list to see ids and names. Nothing was revoked.");
131
+ }
132
+ const named = `${String(device.device_name ?? device.device_id)} (${device.device_id})`;
133
+ const refusal = unconfirmed(args, `This would revoke ${named}, which cannot be undone. Call it again with confirm: true.`);
134
+ if (refusal)
135
+ return refusal;
136
+ const response = await callAgentDoor(session, deps.fetchImpl, "POST", `/api/ambient/devices/${encodeURIComponent(device.device_id)}/revoke`, { reason_label: String(args.reason ?? ""), ...(args.note ? { reason_note: String(args.note) } : {}) }, WRITE_DEADLINE_MS);
137
+ if (!response.ok)
138
+ return errorResult(doorFailureText("team_device_revoke", response));
139
+ return textResult(`Revoked ${named}.`, response.body);
140
+ }));
141
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * What every agent-door tool on this server needs, written once (BLI-3756).
3
+ *
4
+ * `docs-msg-tools.ts` and `work-tools.ts` each carried their own copy of these
5
+ * four helpers. Batch 1 of the MCP twin build added five more surfaces, and
6
+ * seven copies of "what does a refused door say?" is seven places for the
7
+ * answer to drift — so the copies became this module and nothing else decides
8
+ * how a tool names a refusal.
9
+ *
10
+ * The one rule worth stating out loud: **a refusal keeps the DOOR's own
11
+ * words.** The reason label and the sentence are both computed on the server,
12
+ * where the outcome is known, and relayed here verbatim. A second wording in
13
+ * this package would be a second thing to keep in step, and the agent reading
14
+ * it would have no way to tell which one was true.
15
+ */
16
+ import { z } from "zod";
17
+ import { loadAgentDoorSession } from "./agent-door-session.js";
18
+ import type { AgentDoorResponse, FetchImpl } from "./agent-door.js";
19
+ /** Everything a tool family needs to reach Tower. Injectable for the suites. */
20
+ export interface ToolDeps {
21
+ fetchImpl: FetchImpl;
22
+ /** Injectable for tests; defaults to reading `~/.config/bli-cockpit/session.json`. */
23
+ loadSession?: typeof loadAgentDoorSession;
24
+ }
25
+ /** What an MCP tool hands back. Narrower than the SDK's type, on purpose. */
26
+ export interface ToolResult {
27
+ content: Array<{
28
+ type: "text";
29
+ text: string;
30
+ }>;
31
+ structuredContent?: Record<string, unknown>;
32
+ isError?: boolean;
33
+ }
34
+ export interface DoorSession {
35
+ dashboardUrl: string;
36
+ deviceToken: string;
37
+ }
38
+ export declare function textResult(text: string, structured?: Record<string, unknown>): ToolResult;
39
+ export declare function errorResult(text: string): ToolResult;
40
+ /**
41
+ * The session is loaded fresh per call (a cheap file read) rather than once at
42
+ * server startup: this package still serves the legacy event-stream tools on a
43
+ * machine with no collector pairing at all, so a missing session must fail the
44
+ * ONE call that needed it, by name, and never the whole process.
45
+ */
46
+ export declare function withSession(deps: ToolDeps, run: (session: DoorSession) => Promise<ToolResult>): Promise<ToolResult>;
47
+ /**
48
+ * The door's own reason label and its own sentence.
49
+ *
50
+ * Two answer shapes are read because two exist on the server and both are
51
+ * deliberate: the ingest-style routes answer `{reason, message}`, while every
52
+ * `/api/notes/**` door answers in the shape the BROWSER renders — a `headline`
53
+ * plus `lines` — because the page and the terminal read the same body
54
+ * (`commands/notes-door.ts` says why). Reading both here rather than
55
+ * rephrasing either keeps one meaning per field.
56
+ */
57
+ export declare function doorFailureText(door: string, response: AgentDoorResponse): string;
58
+ /** The label a script should branch on. `unknown_error` only when the door named none. */
59
+ export declare function doorReason(body: Record<string, unknown>): string;
60
+ /** The `registerTool` signature this package's tools actually use. */
61
+ export type RegisterTool = (name: string, config: {
62
+ title: string;
63
+ description: string;
64
+ inputSchema: Record<string, unknown>;
65
+ }, handler: (args: Record<string, unknown>) => Promise<ToolResult>) => unknown;
66
+ /** The one cast, so no tool family repeats it. */
67
+ export declare function registrarFor(server: {
68
+ registerTool: (...args: never[]) => unknown;
69
+ }): RegisterTool;
70
+ /** `?a=1&b=2`, or "" when nothing was asked for. Never a bare `?`. */
71
+ export declare function queryString(params: URLSearchParams): string;
72
+ /**
73
+ * The `--yes` of this surface.
74
+ *
75
+ * `cockpit` refuses a share, a revoke, a role change or a delete that nobody
76
+ * confirmed: `--yes`, or a person at a keyboard, and nothing else
77
+ * (`commands/settings.ts` `confirmDestructive`). An MCP client has no
78
+ * keyboard, so the only half that survives is the explicit flag — and it must
79
+ * survive, or the surface a model reaches would be the one surface where a
80
+ * revoke needs no second thought. The reason label is the CLI's own,
81
+ * `confirmation_required`, so a script branching on it reads the same word
82
+ * from either door.
83
+ */
84
+ export declare const CONFIRM_INPUT: z.ZodOptional<z.ZodBoolean>;
85
+ /** `null` when the caller confirmed; the refusal itself when they did not. */
86
+ export declare function unconfirmed(args: Record<string, unknown>, sentence: string): ToolResult | null;