@bli-cockpit/mcp 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -25
- 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 +18 -10
- package/dist/jarvis-tools.js +28 -17
- 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/jarvis-tools.js
CHANGED
|
@@ -32,16 +32,24 @@
|
|
|
32
32
|
* and cannot compute a code for any plan, so an invented code is refused by
|
|
33
33
|
* the dashboard exactly as it always was.
|
|
34
34
|
*
|
|
35
|
-
* Lock 2 is weaker on THIS surface than it is in a terminal
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* to
|
|
35
|
+
* Lock 2 is weaker on THIS surface than it is in a terminal: in a terminal the
|
|
36
|
+
* person types the code themselves, while through MCP it arrives as a tool
|
|
37
|
+
* argument and nothing on the wire distinguishes a code a person handed their
|
|
38
|
+
* agent from one the agent lifted out of the previous answer. BLI-3755 closed
|
|
39
|
+
* the two halves of that gap where the gate lives, not here:
|
|
40
|
+
*
|
|
41
|
+
* * `jarvis_ask` and `jarvis_dispatch` tell the dashboard they are an AGENT
|
|
42
|
+
* surface (`jarvis-door.ts` sends it on every turn), so a proposal comes
|
|
43
|
+
* back with a `proposal_id` and NO code. There is nothing in the previous
|
|
44
|
+
* answer to lift. The person fetches the code where a person reads — a
|
|
45
|
+
* Tower tab, their Slack DM, their own `cockpit jarvis` — and hands it on.
|
|
46
|
+
* * A code is spendable ONCE. A replay is refused with `approval_code_spent`,
|
|
47
|
+
* so a code that did reach an agent buys at most the one run the person
|
|
48
|
+
* approved.
|
|
49
|
+
*
|
|
50
|
+
* What is unchanged here: this server holds no secret and cannot compute a code
|
|
51
|
+
* for any plan, it never derives, guesses or fabricates one, and every relay is
|
|
52
|
+
* logged (presence and length only, never the code).
|
|
45
53
|
*/
|
|
46
54
|
import { z } from "zod";
|
|
47
55
|
import { callAgentDoor } from "./agent-door.js";
|
|
@@ -167,10 +175,13 @@ export function registerJarvisTools(server, deps) {
|
|
|
167
175
|
title: "Dispatch a coding task to JARVIS's coding arm",
|
|
168
176
|
description: "Asks JARVIS to run a coding task on the runner: it clones the repo, works, pushes a branch "
|
|
169
177
|
+ "and opens a pull request. It never merges. TWO CALLS, always. Call this once WITHOUT "
|
|
170
|
-
+ "approval_code
|
|
171
|
-
+ "
|
|
172
|
-
+ "
|
|
173
|
-
+ "
|
|
178
|
+
+ "approval_code: you get the plan and a proposal_id back, and DELIBERATELY no code — a code "
|
|
179
|
+
+ "you can read is a code you could approve with. Show the person the plan and the "
|
|
180
|
+
+ "proposal_id and ask them to fetch the code themself, in a Tower browser tab, their Slack "
|
|
181
|
+
+ "DM with JARVIS, or their own `cockpit jarvis` terminal. Then call this again with the code "
|
|
182
|
+
+ "THEY give you and the same instruction. A code approves exactly ONE run: a reused one is "
|
|
183
|
+
+ "refused (approval_code_spent) and the answer is a fresh approval, never a retry. Never "
|
|
184
|
+
+ "derive, guess, or reuse a code the person has not just handed you.",
|
|
174
185
|
inputSchema: {
|
|
175
186
|
instruction: z
|
|
176
187
|
.string()
|
|
@@ -188,8 +199,8 @@ export function registerJarvisTools(server, deps) {
|
|
|
188
199
|
.min(1)
|
|
189
200
|
.max(64)
|
|
190
201
|
.optional()
|
|
191
|
-
.describe("The 8-character hex confirmation code THE PERSON
|
|
192
|
-
+ "
|
|
202
|
+
.describe("The 8-character hex confirmation code THE PERSON fetched for this proposal and handed "
|
|
203
|
+
+ "to you. Omit it on the first call — that call is never given one."),
|
|
193
204
|
thread: z.string().min(1).max(200).optional().describe('Defaults to "main".'),
|
|
194
205
|
},
|
|
195
206
|
}, async (args) => withSession(deps, async (session) => {
|
|
@@ -212,7 +223,7 @@ export function registerJarvisTools(server, deps) {
|
|
|
212
223
|
const question = [
|
|
213
224
|
code.length > 0
|
|
214
225
|
? "Dispatch this coding task to the coding arm now."
|
|
215
|
-
: "Propose a coding task for the coding arm. Do not dispatch it yet — read back the plan and the
|
|
226
|
+
: "Propose a coding task for the coding arm. Do not dispatch it yet — read back the plan and the proposal id so the person can fetch the approval code themselves.",
|
|
216
227
|
...(typeof args.repo === "string" ? [`Repo: ${args.repo}`] : []),
|
|
217
228
|
"",
|
|
218
229
|
String(args.instruction ?? ""),
|
|
@@ -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
|
+
}
|
package/dist/ops-tools.d.ts
CHANGED
|
@@ -11,16 +11,12 @@
|
|
|
11
11
|
* unhealthy ones so an agent does not have to know the vocabulary to notice a
|
|
12
12
|
* red board.
|
|
13
13
|
*
|
|
14
|
-
* `
|
|
15
|
-
* this
|
|
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.
|
|
16
17
|
*/
|
|
17
18
|
import { type ToolDeps } from "./tool-result.js";
|
|
18
19
|
export type OpsDeps = ToolDeps;
|
|
19
|
-
/**
|
|
20
|
-
* The verdicts that mean something is wrong, verbatim from `commands/ops.ts`.
|
|
21
|
-
* `failing` is its own word and not a shade of `stale` (BLI-3723): a failing
|
|
22
|
-
* reader's input is CURRENT and its answer is bad.
|
|
23
|
-
*/
|
|
24
20
|
export declare const UNHEALTHY_VERDICTS: Set<string>;
|
|
25
21
|
export declare function registerOpsTools(server: {
|
|
26
22
|
registerTool: (...args: never[]) => unknown;
|
package/dist/ops-tools.js
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
* unhealthy ones so an agent does not have to know the vocabulary to notice a
|
|
12
12
|
* red board.
|
|
13
13
|
*
|
|
14
|
-
* `
|
|
15
|
-
* this
|
|
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.
|
|
16
17
|
*/
|
|
17
18
|
import { z } from "zod";
|
|
18
19
|
import { callAgentDoor } from "./agent-door.js";
|
|
@@ -22,7 +23,23 @@ import { doorFailureText, errorResult, registrarFor, textResult, queryString, wi
|
|
|
22
23
|
* `failing` is its own word and not a shade of `stale` (BLI-3723): a failing
|
|
23
24
|
* reader's input is CURRENT and its answer is bad.
|
|
24
25
|
*/
|
|
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
|
+
]);
|
|
26
43
|
function numberOf(value) {
|
|
27
44
|
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
28
45
|
}
|
|
@@ -148,4 +165,48 @@ export function registerOpsTools(server, deps) {
|
|
|
148
165
|
coverage: read["coverage"] ?? null,
|
|
149
166
|
});
|
|
150
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
|
+
}));
|
|
151
212
|
}
|
package/dist/pages-tools.d.ts
CHANGED
|
@@ -14,11 +14,15 @@
|
|
|
14
14
|
* signals and reports the totals in `coverage`; a truncated board and a quiet
|
|
15
15
|
* board must never read alike.
|
|
16
16
|
*
|
|
17
|
-
* Moving a Scout card (`
|
|
18
|
-
*
|
|
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.
|
|
19
21
|
*/
|
|
20
22
|
import { type ToolDeps } from "./tool-result.js";
|
|
21
23
|
export type PagesDeps = ToolDeps;
|
|
22
24
|
export declare function registerPagesTools(server: {
|
|
23
25
|
registerTool: (...args: never[]) => unknown;
|
|
24
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"];
|
package/dist/pages-tools.js
CHANGED
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
* signals and reports the totals in `coverage`; a truncated board and a quiet
|
|
15
15
|
* board must never read alike.
|
|
16
16
|
*
|
|
17
|
-
* Moving a Scout card (`
|
|
18
|
-
*
|
|
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.
|
|
19
21
|
*/
|
|
20
22
|
import { z } from "zod";
|
|
21
23
|
import { callAgentDoor } from "./agent-door.js";
|
|
@@ -120,4 +122,69 @@ export function registerPagesTools(server, deps) {
|
|
|
120
122
|
.join("\n\n");
|
|
121
123
|
return textResult(`WORKBOOK · ${projects.length} project(s) · ${docCount} document(s).${lines ? `\n\n${lines}` : ""}`, { projects });
|
|
122
124
|
}));
|
|
125
|
+
// BLI-3756 batch 2: the three verbs that MOVE a Scout card. Deciding a card
|
|
126
|
+
// is super_admin server-side and reading the board is not, so a refusal here
|
|
127
|
+
// never costs the caller the read it already had — the open cards come back
|
|
128
|
+
// beside the refusal, exactly as `cockpit scout` leaves the board on stdout.
|
|
129
|
+
for (const action of SCOUT_ACTIONS) {
|
|
130
|
+
register(`scout_${action}`, {
|
|
131
|
+
title: SCOUT_TITLES[action],
|
|
132
|
+
description: SCOUT_DESCRIPTIONS[action],
|
|
133
|
+
inputSchema: {
|
|
134
|
+
experiment_id: z
|
|
135
|
+
.string()
|
|
136
|
+
.min(1)
|
|
137
|
+
.max(200)
|
|
138
|
+
.describe("A full experiment id, exactly as scout_board reports it. No prefixes on this surface."),
|
|
139
|
+
},
|
|
140
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
141
|
+
const experimentId = String(args.experiment_id ?? "");
|
|
142
|
+
// The board is read FIRST, as the CLI does: it is what says whether
|
|
143
|
+
// the id names a card that exists, and it is what a refused caller
|
|
144
|
+
// still gets to keep.
|
|
145
|
+
const read = await callAgentDoor(session, deps.fetchImpl, "GET", "/api/cockpit/scout");
|
|
146
|
+
if (!read.ok)
|
|
147
|
+
return errorResult(doorFailureText(`scout_${action}`, read));
|
|
148
|
+
const board = (read.body.board ?? {});
|
|
149
|
+
const open = board.experiments ?? [];
|
|
150
|
+
const card = [...open, ...(board.settled ?? [])].find((one) => one.id === experimentId);
|
|
151
|
+
if (!card) {
|
|
152
|
+
// The exact-match rule, and the list to fix a wrong guess from in
|
|
153
|
+
// one step. Never a prefix: a near-miss that resolved to the wrong
|
|
154
|
+
// card would settle an experiment nobody decided on.
|
|
155
|
+
const known = open.map((one) => ` ${one.id ?? "?"} ${one.title ?? ""}`).join("\n");
|
|
156
|
+
return errorResult(`Refused (unknown_experiment): no card on the Scout board has the id "${experimentId}". `
|
|
157
|
+
+ `Ids come from scout_board and are matched exactly.${known ? `\n\nOpen cards:\n${known}` : ""}`);
|
|
158
|
+
}
|
|
159
|
+
const serverAction = action === "undo" ? "undo_dismiss" : action;
|
|
160
|
+
const applied = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/cockpit/scout", {
|
|
161
|
+
experiment_id: experimentId,
|
|
162
|
+
action: serverAction,
|
|
163
|
+
});
|
|
164
|
+
if (!applied.ok) {
|
|
165
|
+
const gate = applied.status === 403
|
|
166
|
+
? " Deciding a Scout card is a super_admin action; reading the board is not."
|
|
167
|
+
: "";
|
|
168
|
+
return errorResult(`${doorFailureText(`scout_${action}`, applied)}${gate}`);
|
|
169
|
+
}
|
|
170
|
+
return textResult(`${SCOUT_PAST_TENSE[action]} ${experimentId} · ${card.claimSummary ?? card.title ?? "that card"}`, { ok: true, action: serverAction, experiment: applied.body.experiment ?? null });
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
123
173
|
}
|
|
174
|
+
/** The three verbs `cockpit scout` has beyond reading the board. */
|
|
175
|
+
export const SCOUT_ACTIONS = ["start", "dismiss", "undo"];
|
|
176
|
+
const SCOUT_TITLES = {
|
|
177
|
+
start: "Start a Scout experiment",
|
|
178
|
+
dismiss: "Dismiss a Scout card",
|
|
179
|
+
undo: "Restore a dismissed Scout card",
|
|
180
|
+
};
|
|
181
|
+
const SCOUT_DESCRIPTIONS = {
|
|
182
|
+
start: "Marks one experiment card as being run — `cockpit scout start`, same door. super_admin, decided on the server.",
|
|
183
|
+
dismiss: "Sets one experiment card aside — `cockpit scout dismiss`, same door. Reversible with scout_undo. super_admin, decided on the server.",
|
|
184
|
+
undo: "Puts a dismissed card back on the board — `cockpit scout undo`, which the door calls `undo_dismiss`. super_admin, decided on the server.",
|
|
185
|
+
};
|
|
186
|
+
const SCOUT_PAST_TENSE = {
|
|
187
|
+
start: "Started",
|
|
188
|
+
dismiss: "Dismissed",
|
|
189
|
+
undo: "Restored",
|
|
190
|
+
};
|
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 });
|