@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.
- package/README.md +168 -2
- package/dist/agent-door.d.ts +8 -1
- package/dist/agent-door.js +7 -2
- package/dist/brief-tools.d.ts +26 -0
- package/dist/brief-tools.js +160 -0
- package/dist/brief-write-tools.d.ts +37 -0
- package/dist/brief-write-tools.js +178 -0
- package/dist/docs-msg-tools.d.ts +13 -7
- package/dist/docs-msg-tools.js +141 -25
- package/dist/jarvis-answer-envelope.d.ts +13 -1
- package/dist/jarvis-answer-envelope.js +2 -0
- package/dist/jarvis-door.js +16 -1
- package/dist/jarvis-tools.d.ts +18 -10
- package/dist/jarvis-tools.js +28 -17
- package/dist/notes-tools.d.ts +33 -0
- package/dist/notes-tools.js +143 -0
- package/dist/notes-write-tools.d.ts +34 -0
- package/dist/notes-write-tools.js +211 -0
- package/dist/ops-tools.d.ts +23 -0
- package/dist/ops-tools.js +212 -0
- package/dist/pages-tools.d.ts +28 -0
- package/dist/pages-tools.js +190 -0
- package/dist/readme-census.d.ts +18 -0
- package/dist/readme-census.js +79 -0
- package/dist/search-tool.d.ts +54 -0
- package/dist/search-tool.js +134 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +34 -1
- package/dist/settings-tools.d.ts +29 -0
- package/dist/settings-tools.js +151 -0
- package/dist/settings-write-tools.d.ts +47 -0
- package/dist/settings-write-tools.js +183 -0
- package/dist/team-write-tools.d.ts +39 -0
- package/dist/team-write-tools.js +141 -0
- package/dist/tool-result.d.ts +86 -0
- package/dist/tool-result.js +105 -0
- package/dist/verb-census.d.ts +22 -0
- package/dist/verb-census.js +108 -6
- package/dist/work-tools.d.ts +2 -7
- package/dist/work-tools.js +35 -25
- 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;
|