@bli-cockpit/mcp 0.1.3 → 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.
@@ -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
+ }
@@ -13,6 +13,7 @@
13
13
  * this package would be a second thing to keep in step, and the agent reading
14
14
  * it would have no way to tell which one was true.
15
15
  */
16
+ import { z } from "zod";
16
17
  import { loadAgentDoorSession } from "./agent-door-session.js";
17
18
  import type { AgentDoorResponse, FetchImpl } from "./agent-door.js";
18
19
  /** Everything a tool family needs to reach Tower. Injectable for the suites. */
@@ -68,3 +69,18 @@ export declare function registrarFor(server: {
68
69
  }): RegisterTool;
69
70
  /** `?a=1&b=2`, or "" when nothing was asked for. Never a bare `?`. */
70
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;
@@ -13,6 +13,7 @@
13
13
  * this package would be a second thing to keep in step, and the agent reading
14
14
  * it would have no way to tell which one was true.
15
15
  */
16
+ import { z } from "zod";
16
17
  import { loadAgentDoorSession } from "./agent-door-session.js";
17
18
  export function textResult(text, structured) {
18
19
  return { content: [{ type: "text", text }], ...(structured ? { structuredContent: structured } : {}) };
@@ -77,3 +78,28 @@ export function queryString(params) {
77
78
  const rendered = params.toString();
78
79
  return rendered === "" ? "" : `?${rendered}`;
79
80
  }
81
+ // ─────────────────────────────────────────────────────────────────────────
82
+ // Deliberate acts (BLI-3756 batch 2)
83
+ // ─────────────────────────────────────────────────────────────────────────
84
+ /**
85
+ * The `--yes` of this surface.
86
+ *
87
+ * `cockpit` refuses a share, a revoke, a role change or a delete that nobody
88
+ * confirmed: `--yes`, or a person at a keyboard, and nothing else
89
+ * (`commands/settings.ts` `confirmDestructive`). An MCP client has no
90
+ * keyboard, so the only half that survives is the explicit flag — and it must
91
+ * survive, or the surface a model reaches would be the one surface where a
92
+ * revoke needs no second thought. The reason label is the CLI's own,
93
+ * `confirmation_required`, so a script branching on it reads the same word
94
+ * from either door.
95
+ */
96
+ export const CONFIRM_INPUT = z
97
+ .boolean()
98
+ .optional()
99
+ .describe("Must be true. This act is deliberate and is refused without it — the same rule as the CLI's --yes.");
100
+ /** `null` when the caller confirmed; the refusal itself when they did not. */
101
+ export function unconfirmed(args, sentence) {
102
+ if (args.confirm === true)
103
+ return null;
104
+ return errorResult(`Refused (confirmation_required): ${sentence} Nothing was changed.`);
105
+ }
@@ -49,5 +49,15 @@ export interface McpTwin {
49
49
  export declare const MCP_TWINS: Record<string, McpTwin>;
50
50
  /** Verbs that can never have an MCP twin, and why. A claim, not a backlog. */
51
51
  export declare const TERMINAL_ONLY: Record<string, string>;
52
- /** Verbs owed a twin, with who owes it. This list should only ever shrink. */
52
+ /**
53
+ * Verbs owed a twin, with who owes it. This list should only ever shrink.
54
+ *
55
+ * **It is empty, and that is the point** (BLI-3756, batch 2, 2026-09-05).
56
+ * Every `cockpit <noun> <verb>` that talks to Tower now has an MCP tool over
57
+ * the same door — reads in batch 1, writes in batch 2 — so BLI-3706's rule
58
+ * holds without an exception: a Tower surface an agent cannot reach is as good
59
+ * as dead, and there is no surface left in that state. A new entry here is a
60
+ * new debt, not a normal state; it needs a ticket in its reason and it must
61
+ * come back out.
62
+ */
53
63
  export declare const AWAITING_TWIN: Record<string, string>;
@@ -151,19 +151,36 @@ export const MCP_TWINS = {
151
151
  "brief read": { tool: "brief_read", door: "GET /api/jarvis/brief" },
152
152
  "brief history": { tool: "brief_history", door: "GET /api/jarvis/brief?history=1" },
153
153
  "brief status": { tool: "brief_status", door: "GET /api/ops/brief-status" },
154
+ "brief rewrite": { tool: "brief_rewrite", door: "POST /api/jarvis/recompile" },
155
+ correct: { tool: "brief_correct", door: "POST /api/jarvis/corrections" },
154
156
  "notes list": { tool: "notes_list", door: "GET /api/notes/library" },
155
157
  "notes show": { tool: "notes_show", door: "GET /api/notes/library/[id]" },
156
158
  "notes shelf": { tool: "notes_shelf", door: "GET /api/notes/shelf" },
157
159
  "notes shelves": { tool: "notes_shelves", door: "GET /api/notes/library" },
160
+ "notes upload": { tool: "notes_upload", door: "POST /api/notes/upload" },
161
+ "notes paste": { tool: "notes_paste", door: "POST /api/notes/upload (text)" },
162
+ "notes share": { tool: "notes_share", door: "POST /api/notes/share" },
163
+ "notes unshare": { tool: "notes_unshare", door: "POST /api/notes/share (share=false)" },
164
+ "notes move": { tool: "notes_move", door: "POST /api/notes/move" },
158
165
  "ops status": { tool: "ops_status", door: "GET /api/ops/status" },
166
+ "ops recompile": { tool: "ops_recompile", door: "POST /api/ops/recompile" },
159
167
  "slack coverage": { tool: "slack_coverage", door: "GET /api/ops/slack/coverage" },
160
168
  "slack read": { tool: "slack_read", door: "POST /api/ops/slack/read" },
161
169
  "settings show": { tool: "settings_show", door: "GET /api/settings/* + /api/team/members" },
162
170
  "settings list": { tool: "settings_list", door: "GET /api/settings/env-blobs" },
171
+ "settings set": { tool: "settings_set", door: "POST /api/settings/{jarvis-model,switches,model-routing,env-blobs} + PUT /api/settings/cli-floor" },
172
+ "settings delete": { tool: "settings_delete", door: "DELETE /api/settings/env-blobs" },
163
173
  "team members": { tool: "team_members", door: "GET /api/team/members" },
164
174
  "team device-list": { tool: "team_device_list", door: "GET /api/team/devices" },
175
+ "team invite": { tool: "team_invite", door: "POST /api/team/invite" },
176
+ "team role": { tool: "team_role", door: "PATCH /api/team/members/[userId]/role" },
177
+ "team device-revoke": { tool: "team_device_revoke", door: "POST /api/ambient/devices/[deviceId]/revoke" },
165
178
  "model show": { tool: "model_show", door: "GET /api/settings/jarvis-model" },
179
+ "model set": { tool: "model_set", door: "POST /api/settings/jarvis-model" },
166
180
  "scout board": { tool: "scout_board", door: "GET /api/cockpit/scout" },
181
+ "scout start": { tool: "scout_start", door: "POST /api/cockpit/scout (start)" },
182
+ "scout dismiss": { tool: "scout_dismiss", door: "POST /api/cockpit/scout (dismiss)" },
183
+ "scout undo": { tool: "scout_undo", door: "POST /api/cockpit/scout (undo_dismiss)" },
167
184
  workbook: { tool: "workbook_read", door: "GET /api/cockpit/workbook" },
168
185
  // BLI-3728: one search over documents, messages, issues, meeting notes and
169
186
  // memory. `cockpit search` takes no action word — the whole verb is the noun
@@ -179,23 +196,15 @@ export const MCP_TWINS = {
179
196
  export const TERMINAL_ONLY = {
180
197
  "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)",
181
198
  };
182
- /** Verbs owed a twin, with who owes it. This list should only ever shrink. */
183
- export const AWAITING_TWIN = {
184
- "brief rewrite": "BLI-3756 batch 2 — spends a model call; wants its own gate on this surface",
185
- correct: "BLI-3756 batch 2 filing a correction against a claim has no tool",
186
- "notes move": "BLI-3756 batch 2 needs a signed-in session server-side; reachable, just unwritten",
187
- "notes share": "BLI-3756 batch 2a deliberate act; wants an explicit confirmation on this surface",
188
- "notes unshare": "BLI-3756 batch 2 same gate as notes share",
189
- "notes paste": "BLI-3756 batch 2 the CLI takes the body on stdin; a tool would take it as an argument",
190
- "notes upload": "BLI-3756 batch 2 takes local file paths, which an MCP server on the same machine can also read",
191
- "ops recompile": "BLI-3756 batch 2 — spends a model call; wants its own gate on this surface",
192
- "scout start": "BLI-3756 batch 2 — super_admin server-side; a tool would relay, not decide",
193
- "scout dismiss": "BLI-3756 batch 2 — super_admin server-side; a tool would relay, not decide",
194
- "scout undo": "BLI-3756 batch 2 — super_admin server-side; a tool would relay, not decide",
195
- "settings set": "BLI-3756 batch 2 — mostly reachable, but env CONTENT is stdin-only by rule; a tool must keep that",
196
- "settings delete": "BLI-3756 batch 2 — destructive; wants an explicit confirmation on this surface",
197
- "team invite": "BLI-3756 batch 2 — super_admin; a tool would relay, not decide",
198
- "team role": "BLI-3756 batch 2 — super_admin; a tool would relay, not decide",
199
- "team device-revoke": "BLI-3756 batch 2 — destructive; wants an explicit confirmation on this surface",
200
- "model set": "BLI-3756 batch 2 — a shorthand over settings personal; follows settings",
201
- };
199
+ /**
200
+ * Verbs owed a twin, with who owes it. This list should only ever shrink.
201
+ *
202
+ * **It is empty, and that is the point** (BLI-3756, batch 2, 2026-09-05).
203
+ * Every `cockpit <noun> <verb>` that talks to Tower now has an MCP tool over
204
+ * the same doorreads in batch 1, writes in batch 2 so BLI-3706's rule
205
+ * holds without an exception: a Tower surface an agent cannot reach is as good
206
+ * as dead, and there is no surface left in that state. A new entry here is a
207
+ * new debt, not a normal state; it needs a ticket in its reason and it must
208
+ * come back out.
209
+ */
210
+ export const AWAITING_TWIN = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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",