@bli-cockpit/mcp 0.1.3 → 0.1.5
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 +75 -26
- package/dist/agent-door.d.ts +8 -1
- package/dist/agent-door.js +7 -2
- package/dist/brief-write-tools.d.ts +37 -0
- package/dist/brief-write-tools.js +178 -0
- package/dist/docs-msg-tools.js +11 -1
- 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 +27 -10
- package/dist/jarvis-tools.js +65 -27
- package/dist/notes-write-tools.d.ts +34 -0
- package/dist/notes-write-tools.js +211 -0
- package/dist/ops-tools.d.ts +3 -7
- package/dist/ops-tools.js +64 -3
- package/dist/pages-tools.d.ts +6 -2
- package/dist/pages-tools.js +69 -2
- package/dist/readme-census.js +8 -6
- package/dist/server.d.ts +1 -1
- package/dist/server.js +15 -1
- 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 +16 -0
- package/dist/tool-result.js +26 -0
- package/dist/verb-census.d.ts +11 -1
- package/dist/verb-census.js +29 -20
- package/package.json +1 -1
package/dist/readme-census.js
CHANGED
|
@@ -51,17 +51,19 @@ export function renderCensusMarkdown() {
|
|
|
51
51
|
"",
|
|
52
52
|
"This table should only ever shrink.",
|
|
53
53
|
"",
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
...owedRows
|
|
54
|
+
// An empty table is a rendering wart AND a lost claim: "nothing is owed"
|
|
55
|
+
// is the thing a reader wants to know, and a bare header does not say it.
|
|
56
|
+
...(owedRows.length === 0
|
|
57
|
+
? ["_Nothing. Every Tower verb an agent could want has a door (BLI-3756)._"]
|
|
58
|
+
: ["| CLI verb | Who owes it, and why it is not built yet |", "| --- | --- |", ...owedRows]),
|
|
57
59
|
"",
|
|
58
60
|
"### Terminal-only",
|
|
59
61
|
"",
|
|
60
62
|
"A claim about the verb's nature, not a backlog.",
|
|
61
63
|
"",
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
...(terminalRows.length === 0
|
|
65
|
+
? ["_Nothing: no verb has been claimed as impossible._"]
|
|
66
|
+
: ["| CLI verb | Why it can never have a twin |", "| --- | --- |", ...terminalRows]),
|
|
65
67
|
"",
|
|
66
68
|
CENSUS_END,
|
|
67
69
|
].join("\n");
|
package/dist/server.d.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface ServerDeps {
|
|
|
19
19
|
fetchImpl: FetchImpl;
|
|
20
20
|
}
|
|
21
21
|
export declare const PACKAGE_NAME = "@bli-cockpit/mcp";
|
|
22
|
-
export declare const PACKAGE_VERSION = "0.1.
|
|
22
|
+
export declare const PACKAGE_VERSION = "0.1.4";
|
|
23
23
|
export declare const emitEventInput: {
|
|
24
24
|
ticket_id: z.ZodString;
|
|
25
25
|
event_type: z.ZodString;
|
package/dist/server.js
CHANGED
|
@@ -7,16 +7,20 @@
|
|
|
7
7
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { registerBriefTools } from "./brief-tools.js";
|
|
10
|
+
import { registerBriefWriteTools } from "./brief-write-tools.js";
|
|
10
11
|
import { registerDocsMsgTools } from "./docs-msg-tools.js";
|
|
11
12
|
import { registerJarvisTools } from "./jarvis-tools.js";
|
|
12
13
|
import { registerNotesTools } from "./notes-tools.js";
|
|
14
|
+
import { registerNotesWriteTools } from "./notes-write-tools.js";
|
|
13
15
|
import { registerOpsTools } from "./ops-tools.js";
|
|
14
16
|
import { registerPagesTools } from "./pages-tools.js";
|
|
15
17
|
import { registerSearchTool } from "./search-tool.js";
|
|
16
18
|
import { registerSettingsTools } from "./settings-tools.js";
|
|
19
|
+
import { registerSettingsWriteTools } from "./settings-write-tools.js";
|
|
20
|
+
import { registerTeamWriteTools } from "./team-write-tools.js";
|
|
17
21
|
import { registerWorkTools } from "./work-tools.js";
|
|
18
22
|
export const PACKAGE_NAME = "@bli-cockpit/mcp";
|
|
19
|
-
export const PACKAGE_VERSION = "0.1.
|
|
23
|
+
export const PACKAGE_VERSION = "0.1.4";
|
|
20
24
|
// ---- input schemas (Zod raw shapes) -----------------------------------------
|
|
21
25
|
export const emitEventInput = {
|
|
22
26
|
ticket_id: z
|
|
@@ -396,6 +400,16 @@ export function createServer(deps) {
|
|
|
396
400
|
registerOpsTools(server, { fetchImpl: deps.fetchImpl });
|
|
397
401
|
registerSettingsTools(server, { fetchImpl: deps.fetchImpl });
|
|
398
402
|
registerPagesTools(server, { fetchImpl: deps.fetchImpl });
|
|
403
|
+
// BLI-3756 batch 2: the WRITES. Same doors, same device token, same refusal
|
|
404
|
+
// words — and one rule the reads never needed: an act that cannot be undone
|
|
405
|
+
// (share, delete, revoke, role change) is refused without `confirm: true`,
|
|
406
|
+
// because `--yes` or a person at a keyboard is the CLI's gate and a client
|
|
407
|
+
// has no keyboard. `verb-census.test.ts` is what says the list is complete;
|
|
408
|
+
// `AWAITING_TWIN` is empty as of this batch.
|
|
409
|
+
registerBriefWriteTools(server, { fetchImpl: deps.fetchImpl });
|
|
410
|
+
registerNotesWriteTools(server, { fetchImpl: deps.fetchImpl });
|
|
411
|
+
registerSettingsWriteTools(server, { fetchImpl: deps.fetchImpl });
|
|
412
|
+
registerTeamWriteTools(server, { fetchImpl: deps.fetchImpl });
|
|
399
413
|
// BLI-3728: one search over documents, messages, issues, meeting notes and
|
|
400
414
|
// memory. Same door, same ranking, same snippets the browser bar shows.
|
|
401
415
|
registerSearchTool(server, { fetchImpl: deps.fetchImpl });
|
|
@@ -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
|
+
}
|
package/dist/tool-result.d.ts
CHANGED
|
@@ -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;
|
package/dist/tool-result.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/verb-census.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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>;
|