@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,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `notes_upload` / `notes_paste` / `notes_share` / `notes_unshare` /
|
|
3
|
+
* `notes_move` MCP tools (BLI-3756 batch 2) — putting a meeting note in,
|
|
4
|
+
* sharing it, taking it back and moving it to another shelf, over the same
|
|
5
|
+
* `/api/notes/**` doors `cockpit notes` calls with the same device token.
|
|
6
|
+
*
|
|
7
|
+
* Reads live in `notes-tools.ts`. Three rules this half keeps.
|
|
8
|
+
*
|
|
9
|
+
* **The local screen runs before the network, on the name first.** A path is
|
|
10
|
+
* refused for a secret-shaped NAME without its bytes ever being read, then for
|
|
11
|
+
* being missing, unreadable, empty or over 20 MB — the same four checks and
|
|
12
|
+
* the same sentences as `commands/notes-file.ts`, because the server's own
|
|
13
|
+
* guard is the authority and this only avoids spending a round trip on a file
|
|
14
|
+
* it is certain to refuse. If the two ever disagree, the server wins.
|
|
15
|
+
*
|
|
16
|
+
* **Sharing is deliberate.** `cockpit notes share` asks before it acts unless
|
|
17
|
+
* `--yes` is passed; a client has no keyboard, so `confirm: true` is the whole
|
|
18
|
+
* of that gate here and a share without it is refused in the CLI's own words.
|
|
19
|
+
* Taking a note BACK narrows who can read it, and nobody needs to be talked
|
|
20
|
+
* out of that — `notes_unshare` asks for nothing, exactly as the CLI does not.
|
|
21
|
+
*
|
|
22
|
+
* **A slow door is named, not hidden.** `/api/notes/upload` reads the whole
|
|
23
|
+
* note with one model call and is budgeted to 300 seconds server-side; this
|
|
24
|
+
* waits slightly longer so the server's named failure wins the race whenever
|
|
25
|
+
* it manages to send one. An MCP client's own deadline may still be shorter —
|
|
26
|
+
* the description says so rather than letting a cut call read as a refusal.
|
|
27
|
+
*/
|
|
28
|
+
import { type ToolDeps } from "./tool-result.js";
|
|
29
|
+
export type NotesWriteDeps = ToolDeps;
|
|
30
|
+
/** Mirrors `UPLOADED_NOTE_MAX_BYTES`; a courtesy check, not a second authority. */
|
|
31
|
+
export declare const NOTE_FILE_MAX_BYTES: number;
|
|
32
|
+
export declare function registerNotesWriteTools(server: {
|
|
33
|
+
registerTool: (...args: never[]) => unknown;
|
|
34
|
+
}, deps: NotesWriteDeps): void;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `notes_upload` / `notes_paste` / `notes_share` / `notes_unshare` /
|
|
3
|
+
* `notes_move` MCP tools (BLI-3756 batch 2) — putting a meeting note in,
|
|
4
|
+
* sharing it, taking it back and moving it to another shelf, over the same
|
|
5
|
+
* `/api/notes/**` doors `cockpit notes` calls with the same device token.
|
|
6
|
+
*
|
|
7
|
+
* Reads live in `notes-tools.ts`. Three rules this half keeps.
|
|
8
|
+
*
|
|
9
|
+
* **The local screen runs before the network, on the name first.** A path is
|
|
10
|
+
* refused for a secret-shaped NAME without its bytes ever being read, then for
|
|
11
|
+
* being missing, unreadable, empty or over 20 MB — the same four checks and
|
|
12
|
+
* the same sentences as `commands/notes-file.ts`, because the server's own
|
|
13
|
+
* guard is the authority and this only avoids spending a round trip on a file
|
|
14
|
+
* it is certain to refuse. If the two ever disagree, the server wins.
|
|
15
|
+
*
|
|
16
|
+
* **Sharing is deliberate.** `cockpit notes share` asks before it acts unless
|
|
17
|
+
* `--yes` is passed; a client has no keyboard, so `confirm: true` is the whole
|
|
18
|
+
* of that gate here and a share without it is refused in the CLI's own words.
|
|
19
|
+
* Taking a note BACK narrows who can read it, and nobody needs to be talked
|
|
20
|
+
* out of that — `notes_unshare` asks for nothing, exactly as the CLI does not.
|
|
21
|
+
*
|
|
22
|
+
* **A slow door is named, not hidden.** `/api/notes/upload` reads the whole
|
|
23
|
+
* note with one model call and is budgeted to 300 seconds server-side; this
|
|
24
|
+
* waits slightly longer so the server's named failure wins the race whenever
|
|
25
|
+
* it manages to send one. An MCP client's own deadline may still be shorter —
|
|
26
|
+
* the description says so rather than letting a cut call read as a refusal.
|
|
27
|
+
*/
|
|
28
|
+
import { readFile, stat } from "node:fs/promises";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { isSecretLikePathSegment } from "@bli-cockpit/telemetry-core";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
import { callAgentDoor } from "./agent-door.js";
|
|
33
|
+
import { CONFIRM_INPUT, doorFailureText, errorResult, registrarFor, textResult, unconfirmed, withSession, } from "./tool-result.js";
|
|
34
|
+
/** Mirrors `UPLOADED_NOTE_MAX_BYTES`; a courtesy check, not a second authority. */
|
|
35
|
+
export const NOTE_FILE_MAX_BYTES = 20 * 1024 * 1024;
|
|
36
|
+
/** The route's own ceiling is 300 s. Wait past it so its sentence wins. */
|
|
37
|
+
const UPLOAD_DEADLINE_MS = 305_000;
|
|
38
|
+
const WRITE_DEADLINE_MS = 60_000;
|
|
39
|
+
const PASTE_MAX_CHARS = 2_000_000;
|
|
40
|
+
/** The sentence the CLI prints for a refusal caught locally, kept word for word. */
|
|
41
|
+
function refusalSentence(refusal, filePath) {
|
|
42
|
+
switch (refusal) {
|
|
43
|
+
case "file_not_found":
|
|
44
|
+
return `I could not find that file: ${filePath}`;
|
|
45
|
+
case "file_unreadable":
|
|
46
|
+
return `I could not read that file: ${filePath}`;
|
|
47
|
+
case "file_empty":
|
|
48
|
+
return `There is nothing in that file: ${filePath}`;
|
|
49
|
+
case "file_too_big":
|
|
50
|
+
return "That file is too big to put in as a note — keep it under 20 MB.";
|
|
51
|
+
case "looks_like_a_key_file":
|
|
52
|
+
// The same rule the server's gate applies, said the same way: the name
|
|
53
|
+
// is all it takes to decide, and looking inside to be sure would already
|
|
54
|
+
// be the thing the rule forbids.
|
|
55
|
+
return `That name looks like a key or credential file, so I did not open it: ${filePath}`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Reads and locally screens one note file. Never throws. */
|
|
59
|
+
async function readNoteFile(filePath) {
|
|
60
|
+
const fileName = path.basename(filePath);
|
|
61
|
+
const refuse = (refusal) => ({
|
|
62
|
+
ok: false,
|
|
63
|
+
refusal,
|
|
64
|
+
sentence: refusalSentence(refusal, filePath),
|
|
65
|
+
});
|
|
66
|
+
if (isSecretLikePathSegment(fileName))
|
|
67
|
+
return refuse("looks_like_a_key_file");
|
|
68
|
+
let size;
|
|
69
|
+
try {
|
|
70
|
+
const info = await stat(filePath);
|
|
71
|
+
if (!info.isFile())
|
|
72
|
+
return refuse("file_unreadable");
|
|
73
|
+
size = info.size;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
return refuse(error.code === "ENOENT" ? "file_not_found" : "file_unreadable");
|
|
77
|
+
}
|
|
78
|
+
if (size === 0)
|
|
79
|
+
return refuse("file_empty");
|
|
80
|
+
if (size > NOTE_FILE_MAX_BYTES)
|
|
81
|
+
return refuse("file_too_big");
|
|
82
|
+
try {
|
|
83
|
+
return { ok: true, bytes: await readFile(filePath), fileName };
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return refuse("file_unreadable");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** The route's own words for what happened. Never rephrased here. */
|
|
90
|
+
function saidByTower(body) {
|
|
91
|
+
const said = [body.headline ?? "Tower answered without a sentence.", ...(body.lines ?? [])];
|
|
92
|
+
if (body.noteId)
|
|
93
|
+
said.push(`Note id: ${body.noteId}`);
|
|
94
|
+
return said.join("\n");
|
|
95
|
+
}
|
|
96
|
+
async function postForm(deps, session, tool, form) {
|
|
97
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/notes/upload", form, UPLOAD_DEADLINE_MS);
|
|
98
|
+
if (!response.ok)
|
|
99
|
+
return errorResult(doorFailureText(tool, response));
|
|
100
|
+
const body = response.body;
|
|
101
|
+
const text = saidByTower(body);
|
|
102
|
+
// `stored: false` is the door saying it did not keep the note. That is a
|
|
103
|
+
// refusal with a reason, not a success with a caveat.
|
|
104
|
+
return body.stored === true
|
|
105
|
+
? textResult(text, { ok: true, noteId: body.noteId ?? null, scope: body.scope ?? null })
|
|
106
|
+
: errorResult(text);
|
|
107
|
+
}
|
|
108
|
+
export function registerNotesWriteTools(server, deps) {
|
|
109
|
+
const register = registrarFor(server);
|
|
110
|
+
register("notes_upload", {
|
|
111
|
+
title: "Put a local file in as a Tower meeting note",
|
|
112
|
+
description: "Uploads a transcript or note file from THIS machine's disk — the same door `cockpit notes upload` uses. "
|
|
113
|
+
+ "A name that looks like a key or credential file is refused without being opened; so is a missing, empty or "
|
|
114
|
+
+ "over-20-MB file. Tower reads the whole note with one model call, so a large file can take a couple of "
|
|
115
|
+
+ "minutes and your client may give up first — the upload keeps going; notes_list will show it.",
|
|
116
|
+
inputSchema: {
|
|
117
|
+
path: z.string().min(1).max(4096).describe("An absolute path on this machine."),
|
|
118
|
+
exclude: z
|
|
119
|
+
.string()
|
|
120
|
+
.min(1)
|
|
121
|
+
.max(2000)
|
|
122
|
+
.optional()
|
|
123
|
+
.describe("Anything in the note to leave out, in plain words — forwarded to the reader verbatim."),
|
|
124
|
+
},
|
|
125
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
126
|
+
const filePath = String(args.path ?? "");
|
|
127
|
+
const read = await readNoteFile(filePath);
|
|
128
|
+
if (!read.ok)
|
|
129
|
+
return errorResult(`Refused (${read.refusal}): ${read.sentence} Nothing was sent.`);
|
|
130
|
+
const form = new FormData();
|
|
131
|
+
form.set("file", new File([new Uint8Array(read.bytes)], read.fileName));
|
|
132
|
+
if (args.exclude)
|
|
133
|
+
form.set("exclusions", String(args.exclude));
|
|
134
|
+
return postForm(deps, session, "notes_upload", form);
|
|
135
|
+
}));
|
|
136
|
+
register("notes_paste", {
|
|
137
|
+
title: "Put text in as a Tower meeting note",
|
|
138
|
+
description: "Stores text you already have as a meeting note — the same door as notes_upload, which is what "
|
|
139
|
+
+ "`cockpit notes paste` posts to. The CLI takes the body on stdin; here it is an argument, so the text "
|
|
140
|
+
+ "travels in the request and is never echoed back to you.",
|
|
141
|
+
inputSchema: {
|
|
142
|
+
text: z.string().min(1).max(PASTE_MAX_CHARS).describe("The note itself."),
|
|
143
|
+
name: z.string().min(1).max(300).optional().describe("What to call it. Tower names it by date if you do not."),
|
|
144
|
+
exclude: z.string().min(1).max(2000).optional().describe("Anything to leave out, in plain words."),
|
|
145
|
+
},
|
|
146
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
147
|
+
const text = String(args.text ?? "");
|
|
148
|
+
if (text.trim() === "") {
|
|
149
|
+
return errorResult("Refused (empty_paste): there was nothing to paste. Nothing was sent.");
|
|
150
|
+
}
|
|
151
|
+
const form = new FormData();
|
|
152
|
+
form.set("text", text);
|
|
153
|
+
if (args.name)
|
|
154
|
+
form.set("name", String(args.name));
|
|
155
|
+
if (args.exclude)
|
|
156
|
+
form.set("exclusions", String(args.exclude));
|
|
157
|
+
return postForm(deps, session, "notes_paste", form);
|
|
158
|
+
}));
|
|
159
|
+
register("notes_share", {
|
|
160
|
+
title: "Share a Tower meeting note with the team",
|
|
161
|
+
description: "Lets everyone signed in read one note. Deliberate: refused without `confirm: true`, the same rule as the "
|
|
162
|
+
+ "CLI's --yes. Use notes_unshare to take it back.",
|
|
163
|
+
inputSchema: {
|
|
164
|
+
note_id: z.string().min(1).max(200).describe("A note id, as notes_list or notes_shelf reports it."),
|
|
165
|
+
confirm: CONFIRM_INPUT,
|
|
166
|
+
},
|
|
167
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
168
|
+
const refusal = unconfirmed(args, "Sharing a note lets everyone signed in read it. Pass confirm: true to do it without being asked.");
|
|
169
|
+
if (refusal)
|
|
170
|
+
return refusal;
|
|
171
|
+
return shareCall(deps, session, args, true);
|
|
172
|
+
}));
|
|
173
|
+
register("notes_unshare", {
|
|
174
|
+
title: "Take a shared Tower note back",
|
|
175
|
+
description: "Makes one note yours again. No confirmation: taking a note back narrows who can read it, and nobody needs "
|
|
176
|
+
+ "to be talked out of that — the same asymmetry `cockpit notes unshare` has.",
|
|
177
|
+
inputSchema: { note_id: z.string().min(1).max(200).describe("A note id.") },
|
|
178
|
+
}, async (args) => withSession(deps, async (session) => shareCall(deps, session, args, false)));
|
|
179
|
+
register("notes_move", {
|
|
180
|
+
title: "Move a Tower meeting note to another shelf",
|
|
181
|
+
description: "Puts one note on a different shelf, or clears its shelf with `clear: true` — the empty string the browser's "
|
|
182
|
+
+ "own move box sends. A shelf is free text; notes_shelves lists the ones in use.",
|
|
183
|
+
inputSchema: {
|
|
184
|
+
note_id: z.string().min(1).max(200).describe("A note id."),
|
|
185
|
+
to: z.string().min(1).max(200).optional().describe("The shelf to move it to."),
|
|
186
|
+
clear: z.boolean().optional().describe("Take it off every shelf instead."),
|
|
187
|
+
},
|
|
188
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
189
|
+
const clearing = args.clear === true;
|
|
190
|
+
if (!clearing && !args.to) {
|
|
191
|
+
return errorResult("Refused (no_destination): say which shelf with `to`, or pass `clear: true`. Nothing was moved.");
|
|
192
|
+
}
|
|
193
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/notes/move", { note_id: String(args.note_id ?? ""), category: clearing ? "" : String(args.to) }, WRITE_DEADLINE_MS);
|
|
194
|
+
if (!response.ok)
|
|
195
|
+
return errorResult(doorFailureText("notes_move", response));
|
|
196
|
+
const body = response.body;
|
|
197
|
+
const text = [body.headline ?? "Tower answered without a sentence.", ...(body.lines ?? [])].join("\n");
|
|
198
|
+
return body.ok === true
|
|
199
|
+
? textResult(text, { ok: true, shelf: body.shelf ?? null })
|
|
200
|
+
: errorResult(text);
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
/** Share and unshare are one door and one body; only the boolean differs. */
|
|
204
|
+
async function shareCall(deps, session, args, share) {
|
|
205
|
+
const tool = share ? "notes_share" : "notes_unshare";
|
|
206
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/notes/share", { note_id: String(args.note_id ?? ""), share }, WRITE_DEADLINE_MS);
|
|
207
|
+
if (!response.ok)
|
|
208
|
+
return errorResult(doorFailureText(tool, response));
|
|
209
|
+
const body = response.body;
|
|
210
|
+
return textResult(saidByTower(body), { ok: true, shared: share, noteId: body.noteId ?? null });
|
|
211
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ops_status` / `slack_*` MCP tools (BLI-3756) — the `/api/ops/**` board an
|
|
3
|
+
* agent can open, over the same doors `cockpit ops` and `cockpit slack` call
|
|
4
|
+
* with the same device token.
|
|
5
|
+
*
|
|
6
|
+
* These three share a file because they share a door family and a gate, not
|
|
7
|
+
* because they share a noun. `lib/ops/pipeline-status.ts` is the one place
|
|
8
|
+
* that knows a job's EXPECTED INTERVAL — written next to its reader, so a
|
|
9
|
+
* once-daily job quiet for 18 hours reads healthy (BLI-3276) — and no verdict
|
|
10
|
+
* is computed here. This surface reports the server's verdicts and counts the
|
|
11
|
+
* unhealthy ones so an agent does not have to know the vocabulary to notice a
|
|
12
|
+
* red board.
|
|
13
|
+
*
|
|
14
|
+
* `ops_recompile` (BLI-3756 batch 2) is the one write here: it spends a model
|
|
15
|
+
* call, so it is the only tool in this file that can cost money, and the only
|
|
16
|
+
* one whose failure can leave the effect UNKNOWN rather than known.
|
|
17
|
+
*/
|
|
18
|
+
import { type ToolDeps } from "./tool-result.js";
|
|
19
|
+
export type OpsDeps = ToolDeps;
|
|
20
|
+
export declare const UNHEALTHY_VERDICTS: Set<string>;
|
|
21
|
+
export declare function registerOpsTools(server: {
|
|
22
|
+
registerTool: (...args: never[]) => unknown;
|
|
23
|
+
}, deps: OpsDeps): void;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ops_status` / `slack_*` MCP tools (BLI-3756) — the `/api/ops/**` board an
|
|
3
|
+
* agent can open, over the same doors `cockpit ops` and `cockpit slack` call
|
|
4
|
+
* with the same device token.
|
|
5
|
+
*
|
|
6
|
+
* These three share a file because they share a door family and a gate, not
|
|
7
|
+
* because they share a noun. `lib/ops/pipeline-status.ts` is the one place
|
|
8
|
+
* that knows a job's EXPECTED INTERVAL — written next to its reader, so a
|
|
9
|
+
* once-daily job quiet for 18 hours reads healthy (BLI-3276) — and no verdict
|
|
10
|
+
* is computed here. This surface reports the server's verdicts and counts the
|
|
11
|
+
* unhealthy ones so an agent does not have to know the vocabulary to notice a
|
|
12
|
+
* red board.
|
|
13
|
+
*
|
|
14
|
+
* `ops_recompile` (BLI-3756 batch 2) is the one write here: it spends a model
|
|
15
|
+
* call, so it is the only tool in this file that can cost money, and the only
|
|
16
|
+
* one whose failure can leave the effect UNKNOWN rather than known.
|
|
17
|
+
*/
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { callAgentDoor } from "./agent-door.js";
|
|
20
|
+
import { doorFailureText, errorResult, registrarFor, textResult, queryString, withSession, } from "./tool-result.js";
|
|
21
|
+
/**
|
|
22
|
+
* The verdicts that mean something is wrong, verbatim from `commands/ops.ts`.
|
|
23
|
+
* `failing` is its own word and not a shade of `stale` (BLI-3723): a failing
|
|
24
|
+
* reader's input is CURRENT and its answer is bad.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* A whole compile, plus a little. `maxDuration` on `/api/ops/recompile` is 800
|
|
28
|
+
* seconds; waiting slightly past it is what lets this tool tell "Tower gave up"
|
|
29
|
+
* from "the network did", instead of hanging up first and blaming the wrong
|
|
30
|
+
* one. A client with a shorter deadline of its own will cut the call first —
|
|
31
|
+
* the description says so.
|
|
32
|
+
*/
|
|
33
|
+
const RECOMPILE_DEADLINE_MS = 830_000;
|
|
34
|
+
const TOWER_RECOMPILE_CEILING_SECONDS = 800;
|
|
35
|
+
export const UNHEALTHY_VERDICTS = new Set([
|
|
36
|
+
"stale",
|
|
37
|
+
"never_produced",
|
|
38
|
+
"unreadable",
|
|
39
|
+
"failing",
|
|
40
|
+
// BLI-3762: a job that wrote less than it owed is not healthy. Amber counts.
|
|
41
|
+
"degraded",
|
|
42
|
+
]);
|
|
43
|
+
function numberOf(value) {
|
|
44
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
45
|
+
}
|
|
46
|
+
function asRecord(value) {
|
|
47
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
48
|
+
}
|
|
49
|
+
export function registerOpsTools(server, deps) {
|
|
50
|
+
const register = registrarFor(server);
|
|
51
|
+
register("ops_status", {
|
|
52
|
+
title: "Read the Tower pipeline board",
|
|
53
|
+
description: "Every scheduled job's verdict against its own expected interval, plus the collector fleet and collection "
|
|
54
|
+
+ "coverage. A job's `detail` is the server's sentence for why — relayed as it stands. Status only: nothing "
|
|
55
|
+
+ "here fires a job.",
|
|
56
|
+
inputSchema: {
|
|
57
|
+
job: z.string().min(1).max(100).optional().describe("One pipeline id instead of the whole board."),
|
|
58
|
+
skips: z.boolean().optional().describe("Also return the open ingest-skip ledgers."),
|
|
59
|
+
},
|
|
60
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
61
|
+
const params = new URLSearchParams();
|
|
62
|
+
if (args.job)
|
|
63
|
+
params.set("job", String(args.job));
|
|
64
|
+
if (args.skips)
|
|
65
|
+
params.set("skips", "1");
|
|
66
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/ops/status${queryString(params)}`);
|
|
67
|
+
if (!response.ok)
|
|
68
|
+
return errorResult(doorFailureText("ops_status", response));
|
|
69
|
+
const body = response.body;
|
|
70
|
+
const rows = (Array.isArray(body.pipelines) ? body.pipelines : []);
|
|
71
|
+
const unhealthy = rows.filter((row) => UNHEALTHY_VERDICTS.has(row.verdict ?? ""));
|
|
72
|
+
const lines = rows
|
|
73
|
+
.map((row) => `${(row.verdict ?? "?").padEnd(14)} ${row.id ?? "?"} ${row.detail ?? row.label ?? ""}`.trimEnd())
|
|
74
|
+
.join("\n");
|
|
75
|
+
// Both branches say something. A board that is entirely green and
|
|
76
|
+
// reports nothing cannot answer "did anybody look today?".
|
|
77
|
+
return textResult(`${rows.length} pipeline(s), ${unhealthy.length} unhealthy`
|
|
78
|
+
+ `${unhealthy.length > 0 ? `: ${unhealthy.map((row) => row.id ?? "?").join(", ")}` : "."}`
|
|
79
|
+
+ `${lines ? `\n${lines}` : ""}`, {
|
|
80
|
+
pipelines: rows,
|
|
81
|
+
unhealthy: unhealthy.map((row) => row.id ?? "?"),
|
|
82
|
+
...(body.fleet ? { fleet: body.fleet } : {}),
|
|
83
|
+
...(body.coverage ? { coverage: body.coverage } : {}),
|
|
84
|
+
...(body.skips ? { skips: body.skips } : {}),
|
|
85
|
+
});
|
|
86
|
+
}));
|
|
87
|
+
register("slack_coverage", {
|
|
88
|
+
title: "Read Tower's Slack collection coverage",
|
|
89
|
+
description: "Which Slack channels the bot can actually read, per workspace, and which are stale beyond the server's own "
|
|
90
|
+
+ "threshold. Open to everyone — this is collection health, not message content.",
|
|
91
|
+
inputSchema: {
|
|
92
|
+
workspace: z.string().min(1).max(100).optional().describe("One workspace key instead of all of them."),
|
|
93
|
+
stale_only: z.boolean().optional().describe("Report only the stale channels, not the whole breakdown."),
|
|
94
|
+
},
|
|
95
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
96
|
+
const params = new URLSearchParams();
|
|
97
|
+
if (args.workspace)
|
|
98
|
+
params.set("workspace", String(args.workspace));
|
|
99
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/ops/slack/coverage${queryString(params)}`);
|
|
100
|
+
if (!response.ok)
|
|
101
|
+
return errorResult(doorFailureText("slack_coverage", response));
|
|
102
|
+
const coverage = asRecord(response.body["coverage"]);
|
|
103
|
+
const workspaces = (Array.isArray(coverage["workspaces"]) ? coverage["workspaces"] : []);
|
|
104
|
+
const staleAfter = numberOf(coverage["staleAfterHours"]);
|
|
105
|
+
const staleTotal = workspaces.reduce((sum, row) => sum + numberOf(row["staleChannelsTotal"]), 0);
|
|
106
|
+
const lines = workspaces
|
|
107
|
+
.map((workspace) => {
|
|
108
|
+
const key = String(workspace["workspace"] ?? "?").toUpperCase();
|
|
109
|
+
const head = `${key} ${numberOf(workspace["covered"])} of ${numberOf(workspace["channelsKnown"])} channels readable`;
|
|
110
|
+
const stale = numberOf(workspace["staleChannelsTotal"]);
|
|
111
|
+
const staleLine = stale === 0
|
|
112
|
+
? ` no channel is over ${staleAfter}h since its last sync`
|
|
113
|
+
: ` ${stale} channel(s) over ${staleAfter}h since last sync`;
|
|
114
|
+
const cursor = args.stale_only ? "" : `\n newest cursor: ${String(workspace["newestCursorIso"] ?? "never")}`;
|
|
115
|
+
return `${head}\n${staleLine}${cursor}`;
|
|
116
|
+
})
|
|
117
|
+
.join("\n\n");
|
|
118
|
+
return textResult(`${workspaces.length} workspace(s), ${staleTotal} stale channel(s).${lines ? `\n\n${lines}` : ""}`
|
|
119
|
+
+ (typeof coverage["note"] === "string" ? `\n\n${coverage["note"]}` : ""), { coverage });
|
|
120
|
+
}));
|
|
121
|
+
register("slack_read", {
|
|
122
|
+
title: "Read Tower-collected Slack messages",
|
|
123
|
+
description: "Messages the Slack collector holds, filtered by person, channel, text and date. A narrower audience than "
|
|
124
|
+
+ "slack_coverage: the server decides who may read message content, and refuses in its own words.",
|
|
125
|
+
inputSchema: {
|
|
126
|
+
person: z.string().min(1).max(200).optional().describe("A person — name, email or Slack member id."),
|
|
127
|
+
channel: z.string().min(1).max(200).optional().describe("One channel name."),
|
|
128
|
+
query: z.string().min(1).max(500).optional().describe("Text to look for."),
|
|
129
|
+
since: z.string().min(1).max(30).optional().describe("YYYY-MM-DD or ISO-8601."),
|
|
130
|
+
until: z.string().min(1).max(30).optional().describe("YYYY-MM-DD or ISO-8601."),
|
|
131
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
132
|
+
},
|
|
133
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
134
|
+
const body = {};
|
|
135
|
+
for (const key of ["person", "channel", "query", "since", "until"]) {
|
|
136
|
+
if (args[key])
|
|
137
|
+
body[key] = String(args[key]);
|
|
138
|
+
}
|
|
139
|
+
if (typeof args.limit === "number")
|
|
140
|
+
body["limit"] = args.limit;
|
|
141
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/ops/slack/read", body);
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
// The CLI names the open door beside the closed one on a 403; an
|
|
144
|
+
// agent told only "forbidden" would stop, when the coverage read it
|
|
145
|
+
// is entitled to may well answer the question it was asking.
|
|
146
|
+
const hint = response.status === 403
|
|
147
|
+
? " slack_coverage is open to everyone and answers what the bot can see."
|
|
148
|
+
: "";
|
|
149
|
+
return errorResult(`${doorFailureText("slack_read", response)}${hint}`);
|
|
150
|
+
}
|
|
151
|
+
const read = asRecord(response.body["result"]);
|
|
152
|
+
const messages = (Array.isArray(read["messages"]) ? read["messages"] : []);
|
|
153
|
+
const lines = messages
|
|
154
|
+
.map((message) => `#${String(message["channel"] ?? "?")} ${String(message["author"] ?? "?")} ${String(message["messageTs"] ?? "?")}`
|
|
155
|
+
+ `\n ${String(message["text"] ?? "")}`)
|
|
156
|
+
.join("\n");
|
|
157
|
+
// The server's own summary first and verbatim: it is the sentence that
|
|
158
|
+
// keeps an empty answer from reading as "nobody said anything".
|
|
159
|
+
const summary = typeof read["summary"] === "string" ? read["summary"] : `${messages.length} message(s).`;
|
|
160
|
+
const note = typeof read["note"] === "string" && read["note"] ? `\n\n${read["note"]}` : "";
|
|
161
|
+
return textResult(`${summary}${lines ? `\n\n${lines}` : ""}${note}`, {
|
|
162
|
+
status: read["status"] ?? null,
|
|
163
|
+
reason: read["reason"] ?? null,
|
|
164
|
+
messages,
|
|
165
|
+
coverage: read["coverage"] ?? null,
|
|
166
|
+
});
|
|
167
|
+
}));
|
|
168
|
+
register("ops_recompile", {
|
|
169
|
+
title: "Write one person's Tower page again",
|
|
170
|
+
description: "Compiles a daily page on demand — `cockpit ops recompile`, same door. It SPENDS A MODEL CALL and takes "
|
|
171
|
+
+ "several minutes; pass `dry_run: true` to resolve the person and see which page is being served without "
|
|
172
|
+
+ "compiling anything. Your own page always; anybody else's is admin only, decided on the server. Tower's own "
|
|
173
|
+
+ "budget is 800 seconds — if that runs out the effect is UNKNOWN, not failed, and this says so.",
|
|
174
|
+
inputSchema: {
|
|
175
|
+
person: z.string().min(1).max(200).describe("An email, a display name, or a person id."),
|
|
176
|
+
dry_run: z.boolean().optional().describe("Resolve the person and read the current page; compile nothing."),
|
|
177
|
+
},
|
|
178
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
179
|
+
const dryRun = args.dry_run === true;
|
|
180
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/ops/recompile", { person: String(args.person ?? ""), dryRun }, RECOMPILE_DEADLINE_MS);
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
// The ceiling is its own outcome, and its effect is UNKNOWN rather
|
|
183
|
+
// than failed: the compile may well have finished after the
|
|
184
|
+
// connection was cut. Said in the CLI's own words, because a caller
|
|
185
|
+
// told "it failed" would reasonably ask for it again and pay twice.
|
|
186
|
+
if (response.transportError) {
|
|
187
|
+
return errorResult(`Refused (recompile_ceiling_exceeded): Tower's ${TOWER_RECOMPILE_CEILING_SECONDS}-second budget for one `
|
|
188
|
+
+ "recompile ran out before it answered. That is not the same as the compile failing: it may have "
|
|
189
|
+
+ "finished after the connection was cut. Call brief_read for that person to see which page is being "
|
|
190
|
+
+ "served now.");
|
|
191
|
+
}
|
|
192
|
+
return errorResult(doorFailureText("ops_recompile", response));
|
|
193
|
+
}
|
|
194
|
+
const body = response.body;
|
|
195
|
+
const status = typeof body["status"] === "string" ? body["status"] : "compiled";
|
|
196
|
+
const displayName = typeof body["displayName"] === "string" ? body["displayName"] : String(args.person ?? "");
|
|
197
|
+
const pageId = typeof body["pageId"] === "string" ? body["pageId"] : null;
|
|
198
|
+
if (status === "dry_run") {
|
|
199
|
+
const latest = typeof body["latestPageId"] === "string" ? body["latestPageId"] : null;
|
|
200
|
+
return textResult(`${displayName} is on the roster.\n`
|
|
201
|
+
+ (latest
|
|
202
|
+
? `The page being served for them right now is ${latest}.`
|
|
203
|
+
: "Nothing has ever been written for them.")
|
|
204
|
+
+ "\nNothing was compiled — this was a dry run.", body);
|
|
205
|
+
}
|
|
206
|
+
return textResult(`Wrote ${displayName}'s page again: ${pageId ?? "(no id returned)"}\n`
|
|
207
|
+
+ (body["isNowLive"] === true
|
|
208
|
+
? "It is the page being served now."
|
|
209
|
+
: "It is NOT the page being served: a scheduled compile is stamped for a later delivery window, so that "
|
|
210
|
+
+ "one still wins. Nothing was lost — this is a new version alongside it."), body);
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `scout_board` / `workbook_read` MCP tools (BLI-3756) — the two read-only
|
|
3
|
+
* `/api/cockpit/**` pages, over the same doors `cockpit scout` and
|
|
4
|
+
* `cockpit workbook` call with the same device token.
|
|
5
|
+
*
|
|
6
|
+
* **The sentences belong to the page, not to this file.** Both doors return
|
|
7
|
+
* the finished words the browser renders — Scout's standing lines come from
|
|
8
|
+
* `lib/cockpit/scout-lines.ts`, and the workbook's markdown comes from
|
|
9
|
+
* `lib/workbook/to-markdown.ts` walking the same static elements the page
|
|
10
|
+
* renders. A tool that reworded either would eventually disagree with the page
|
|
11
|
+
* about what happened, and an agent would have no way to tell which was right.
|
|
12
|
+
*
|
|
13
|
+
* **A bounded read says so.** The Scout door returns at most 8 cards and 12
|
|
14
|
+
* signals and reports the totals in `coverage`; a truncated board and a quiet
|
|
15
|
+
* board must never read alike.
|
|
16
|
+
*
|
|
17
|
+
* Moving a Scout card (`scout_start`/`scout_dismiss`/`scout_undo`, BLI-3756
|
|
18
|
+
* batch 2) is super_admin server-side, so those three RELAY a decision rather
|
|
19
|
+
* than making one — and each reads the board first, so an id is matched
|
|
20
|
+
* exactly against cards that exist and a refusal still hands back the read.
|
|
21
|
+
*/
|
|
22
|
+
import { type ToolDeps } from "./tool-result.js";
|
|
23
|
+
export type PagesDeps = ToolDeps;
|
|
24
|
+
export declare function registerPagesTools(server: {
|
|
25
|
+
registerTool: (...args: never[]) => unknown;
|
|
26
|
+
}, deps: PagesDeps): void;
|
|
27
|
+
/** The three verbs `cockpit scout` has beyond reading the board. */
|
|
28
|
+
export declare const SCOUT_ACTIONS: readonly ["start", "dismiss", "undo"];
|