@bli-cockpit/mcp 0.1.1 → 0.1.3
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 +190 -9
- package/dist/brief-tools.d.ts +26 -0
- package/dist/brief-tools.js +160 -0
- package/dist/docs-msg-tools.d.ts +13 -7
- package/dist/docs-msg-tools.js +130 -24
- package/dist/jarvis-answer-envelope.d.ts +107 -0
- package/dist/jarvis-answer-envelope.js +82 -0
- package/dist/jarvis-door.d.ts +95 -0
- package/dist/jarvis-door.js +163 -0
- package/dist/jarvis-tools.d.ts +50 -0
- package/dist/jarvis-tools.js +248 -0
- package/dist/jarvis-turn-bookmark.d.ts +25 -0
- package/dist/jarvis-turn-bookmark.js +43 -0
- package/dist/notes-tools.d.ts +33 -0
- package/dist/notes-tools.js +143 -0
- package/dist/ops-tools.d.ts +27 -0
- package/dist/ops-tools.js +151 -0
- package/dist/pages-tools.d.ts +24 -0
- package/dist/pages-tools.js +123 -0
- package/dist/readme-census.d.ts +18 -0
- package/dist/readme-census.js +77 -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 +25 -1
- package/dist/settings-tools.d.ts +29 -0
- package/dist/settings-tools.js +151 -0
- package/dist/tool-result.d.ts +70 -0
- package/dist/tool-result.js +79 -0
- package/dist/verb-census.d.ts +53 -0
- package/dist/verb-census.js +201 -0
- package/dist/work-tools.d.ts +2 -7
- package/dist/work-tools.js +35 -25
- package/package.json +5 -4
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `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` stays out: it spends a model call and wants its own gate on
|
|
15
|
+
* this surface (batch 2).
|
|
16
|
+
*/
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { callAgentDoor } from "./agent-door.js";
|
|
19
|
+
import { doorFailureText, errorResult, registrarFor, textResult, queryString, withSession, } from "./tool-result.js";
|
|
20
|
+
/**
|
|
21
|
+
* The verdicts that mean something is wrong, verbatim from `commands/ops.ts`.
|
|
22
|
+
* `failing` is its own word and not a shade of `stale` (BLI-3723): a failing
|
|
23
|
+
* reader's input is CURRENT and its answer is bad.
|
|
24
|
+
*/
|
|
25
|
+
export const UNHEALTHY_VERDICTS = new Set(["stale", "never_produced", "unreadable", "failing"]);
|
|
26
|
+
function numberOf(value) {
|
|
27
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
28
|
+
}
|
|
29
|
+
function asRecord(value) {
|
|
30
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
31
|
+
}
|
|
32
|
+
export function registerOpsTools(server, deps) {
|
|
33
|
+
const register = registrarFor(server);
|
|
34
|
+
register("ops_status", {
|
|
35
|
+
title: "Read the Tower pipeline board",
|
|
36
|
+
description: "Every scheduled job's verdict against its own expected interval, plus the collector fleet and collection "
|
|
37
|
+
+ "coverage. A job's `detail` is the server's sentence for why — relayed as it stands. Status only: nothing "
|
|
38
|
+
+ "here fires a job.",
|
|
39
|
+
inputSchema: {
|
|
40
|
+
job: z.string().min(1).max(100).optional().describe("One pipeline id instead of the whole board."),
|
|
41
|
+
skips: z.boolean().optional().describe("Also return the open ingest-skip ledgers."),
|
|
42
|
+
},
|
|
43
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
44
|
+
const params = new URLSearchParams();
|
|
45
|
+
if (args.job)
|
|
46
|
+
params.set("job", String(args.job));
|
|
47
|
+
if (args.skips)
|
|
48
|
+
params.set("skips", "1");
|
|
49
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/ops/status${queryString(params)}`);
|
|
50
|
+
if (!response.ok)
|
|
51
|
+
return errorResult(doorFailureText("ops_status", response));
|
|
52
|
+
const body = response.body;
|
|
53
|
+
const rows = (Array.isArray(body.pipelines) ? body.pipelines : []);
|
|
54
|
+
const unhealthy = rows.filter((row) => UNHEALTHY_VERDICTS.has(row.verdict ?? ""));
|
|
55
|
+
const lines = rows
|
|
56
|
+
.map((row) => `${(row.verdict ?? "?").padEnd(14)} ${row.id ?? "?"} ${row.detail ?? row.label ?? ""}`.trimEnd())
|
|
57
|
+
.join("\n");
|
|
58
|
+
// Both branches say something. A board that is entirely green and
|
|
59
|
+
// reports nothing cannot answer "did anybody look today?".
|
|
60
|
+
return textResult(`${rows.length} pipeline(s), ${unhealthy.length} unhealthy`
|
|
61
|
+
+ `${unhealthy.length > 0 ? `: ${unhealthy.map((row) => row.id ?? "?").join(", ")}` : "."}`
|
|
62
|
+
+ `${lines ? `\n${lines}` : ""}`, {
|
|
63
|
+
pipelines: rows,
|
|
64
|
+
unhealthy: unhealthy.map((row) => row.id ?? "?"),
|
|
65
|
+
...(body.fleet ? { fleet: body.fleet } : {}),
|
|
66
|
+
...(body.coverage ? { coverage: body.coverage } : {}),
|
|
67
|
+
...(body.skips ? { skips: body.skips } : {}),
|
|
68
|
+
});
|
|
69
|
+
}));
|
|
70
|
+
register("slack_coverage", {
|
|
71
|
+
title: "Read Tower's Slack collection coverage",
|
|
72
|
+
description: "Which Slack channels the bot can actually read, per workspace, and which are stale beyond the server's own "
|
|
73
|
+
+ "threshold. Open to everyone — this is collection health, not message content.",
|
|
74
|
+
inputSchema: {
|
|
75
|
+
workspace: z.string().min(1).max(100).optional().describe("One workspace key instead of all of them."),
|
|
76
|
+
stale_only: z.boolean().optional().describe("Report only the stale channels, not the whole breakdown."),
|
|
77
|
+
},
|
|
78
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
79
|
+
const params = new URLSearchParams();
|
|
80
|
+
if (args.workspace)
|
|
81
|
+
params.set("workspace", String(args.workspace));
|
|
82
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/ops/slack/coverage${queryString(params)}`);
|
|
83
|
+
if (!response.ok)
|
|
84
|
+
return errorResult(doorFailureText("slack_coverage", response));
|
|
85
|
+
const coverage = asRecord(response.body["coverage"]);
|
|
86
|
+
const workspaces = (Array.isArray(coverage["workspaces"]) ? coverage["workspaces"] : []);
|
|
87
|
+
const staleAfter = numberOf(coverage["staleAfterHours"]);
|
|
88
|
+
const staleTotal = workspaces.reduce((sum, row) => sum + numberOf(row["staleChannelsTotal"]), 0);
|
|
89
|
+
const lines = workspaces
|
|
90
|
+
.map((workspace) => {
|
|
91
|
+
const key = String(workspace["workspace"] ?? "?").toUpperCase();
|
|
92
|
+
const head = `${key} ${numberOf(workspace["covered"])} of ${numberOf(workspace["channelsKnown"])} channels readable`;
|
|
93
|
+
const stale = numberOf(workspace["staleChannelsTotal"]);
|
|
94
|
+
const staleLine = stale === 0
|
|
95
|
+
? ` no channel is over ${staleAfter}h since its last sync`
|
|
96
|
+
: ` ${stale} channel(s) over ${staleAfter}h since last sync`;
|
|
97
|
+
const cursor = args.stale_only ? "" : `\n newest cursor: ${String(workspace["newestCursorIso"] ?? "never")}`;
|
|
98
|
+
return `${head}\n${staleLine}${cursor}`;
|
|
99
|
+
})
|
|
100
|
+
.join("\n\n");
|
|
101
|
+
return textResult(`${workspaces.length} workspace(s), ${staleTotal} stale channel(s).${lines ? `\n\n${lines}` : ""}`
|
|
102
|
+
+ (typeof coverage["note"] === "string" ? `\n\n${coverage["note"]}` : ""), { coverage });
|
|
103
|
+
}));
|
|
104
|
+
register("slack_read", {
|
|
105
|
+
title: "Read Tower-collected Slack messages",
|
|
106
|
+
description: "Messages the Slack collector holds, filtered by person, channel, text and date. A narrower audience than "
|
|
107
|
+
+ "slack_coverage: the server decides who may read message content, and refuses in its own words.",
|
|
108
|
+
inputSchema: {
|
|
109
|
+
person: z.string().min(1).max(200).optional().describe("A person — name, email or Slack member id."),
|
|
110
|
+
channel: z.string().min(1).max(200).optional().describe("One channel name."),
|
|
111
|
+
query: z.string().min(1).max(500).optional().describe("Text to look for."),
|
|
112
|
+
since: z.string().min(1).max(30).optional().describe("YYYY-MM-DD or ISO-8601."),
|
|
113
|
+
until: z.string().min(1).max(30).optional().describe("YYYY-MM-DD or ISO-8601."),
|
|
114
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
115
|
+
},
|
|
116
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
117
|
+
const body = {};
|
|
118
|
+
for (const key of ["person", "channel", "query", "since", "until"]) {
|
|
119
|
+
if (args[key])
|
|
120
|
+
body[key] = String(args[key]);
|
|
121
|
+
}
|
|
122
|
+
if (typeof args.limit === "number")
|
|
123
|
+
body["limit"] = args.limit;
|
|
124
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/ops/slack/read", body);
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
// The CLI names the open door beside the closed one on a 403; an
|
|
127
|
+
// agent told only "forbidden" would stop, when the coverage read it
|
|
128
|
+
// is entitled to may well answer the question it was asking.
|
|
129
|
+
const hint = response.status === 403
|
|
130
|
+
? " slack_coverage is open to everyone and answers what the bot can see."
|
|
131
|
+
: "";
|
|
132
|
+
return errorResult(`${doorFailureText("slack_read", response)}${hint}`);
|
|
133
|
+
}
|
|
134
|
+
const read = asRecord(response.body["result"]);
|
|
135
|
+
const messages = (Array.isArray(read["messages"]) ? read["messages"] : []);
|
|
136
|
+
const lines = messages
|
|
137
|
+
.map((message) => `#${String(message["channel"] ?? "?")} ${String(message["author"] ?? "?")} ${String(message["messageTs"] ?? "?")}`
|
|
138
|
+
+ `\n ${String(message["text"] ?? "")}`)
|
|
139
|
+
.join("\n");
|
|
140
|
+
// The server's own summary first and verbatim: it is the sentence that
|
|
141
|
+
// keeps an empty answer from reading as "nobody said anything".
|
|
142
|
+
const summary = typeof read["summary"] === "string" ? read["summary"] : `${messages.length} message(s).`;
|
|
143
|
+
const note = typeof read["note"] === "string" && read["note"] ? `\n\n${read["note"]}` : "";
|
|
144
|
+
return textResult(`${summary}${lines ? `\n\n${lines}` : ""}${note}`, {
|
|
145
|
+
status: read["status"] ?? null,
|
|
146
|
+
reason: read["reason"] ?? null,
|
|
147
|
+
messages,
|
|
148
|
+
coverage: read["coverage"] ?? null,
|
|
149
|
+
});
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
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 (`start`/`dismiss`/`undo`) is super_admin server-side and
|
|
18
|
+
* stays in batch 2.
|
|
19
|
+
*/
|
|
20
|
+
import { type ToolDeps } from "./tool-result.js";
|
|
21
|
+
export type PagesDeps = ToolDeps;
|
|
22
|
+
export declare function registerPagesTools(server: {
|
|
23
|
+
registerTool: (...args: never[]) => unknown;
|
|
24
|
+
}, deps: PagesDeps): void;
|
|
@@ -0,0 +1,123 @@
|
|
|
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 (`start`/`dismiss`/`undo`) is super_admin server-side and
|
|
18
|
+
* stays in batch 2.
|
|
19
|
+
*/
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { callAgentDoor } from "./agent-door.js";
|
|
22
|
+
import { doorFailureText, errorResult, registrarFor, textResult, queryString, withSession, } from "./tool-result.js";
|
|
23
|
+
/** "8 of 23 (truncated)" — never a bare count that hides what was left out. */
|
|
24
|
+
function coverageWord(coverage, shown) {
|
|
25
|
+
if (!coverage)
|
|
26
|
+
return String(shown);
|
|
27
|
+
const total = coverage.total ?? null;
|
|
28
|
+
return `${coverage.returned ?? shown}${total === null ? "" : ` of ${total}`}${coverage.truncated ? " (truncated)" : ""}`;
|
|
29
|
+
}
|
|
30
|
+
export function registerPagesTools(server, deps) {
|
|
31
|
+
const register = registrarFor(server);
|
|
32
|
+
register("scout_board", {
|
|
33
|
+
title: "Read the Tower Scout board",
|
|
34
|
+
description: "The experiment cards Scout is proposing, the ones already settled, and the raw signals behind them — plus "
|
|
35
|
+
+ "the page's own standing sentences. Read only; moving a card is not on this surface.",
|
|
36
|
+
inputSchema: {
|
|
37
|
+
days: z.number().int().min(1).max(365).optional().describe("How many days of signals to look back over."),
|
|
38
|
+
},
|
|
39
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
40
|
+
const params = new URLSearchParams();
|
|
41
|
+
if (typeof args.days === "number")
|
|
42
|
+
params.set("days", String(args.days));
|
|
43
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/cockpit/scout${queryString(params)}`);
|
|
44
|
+
if (!response.ok)
|
|
45
|
+
return errorResult(doorFailureText("scout_board", response));
|
|
46
|
+
const board = (response.body.board ?? {});
|
|
47
|
+
const lines = (response.body.lines ?? {});
|
|
48
|
+
const experiments = board.experiments ?? [];
|
|
49
|
+
const settled = board.settled ?? [];
|
|
50
|
+
const signals = board.signals ?? [];
|
|
51
|
+
const said = [lines.watch, lines.headline, lines.quiet].filter(Boolean).join("\n");
|
|
52
|
+
const cards = experiments
|
|
53
|
+
.map((card) => ` ${card.id ?? "?"} ${card.status ?? "?"} ${card.title ?? ""}\n ${card.claimSummary ?? ""}`)
|
|
54
|
+
.join("\n");
|
|
55
|
+
const settledCards = settled.map((card) => ` ${card.id ?? "?"} ${card.title ?? ""}`).join("\n");
|
|
56
|
+
const rawSignals = signals.map((signal) => ` ${signal.source ?? "?"} ${signal.title ?? ""}`).join("\n");
|
|
57
|
+
return textResult(`${said}\n\n${lines.waitingLabel ?? "OPEN"} — ${coverageWord(board.coverage?.openExperiments, experiments.length)}`
|
|
58
|
+
+ `${cards ? `\n${cards}` : ""}`
|
|
59
|
+
+ `\n\n${lines.settledLabel ?? "SETTLED"} — ${coverageWord(board.coverage?.settledExperiments, settled.length)}`
|
|
60
|
+
+ `${settledCards ? `\n${settledCards}` : ""}`
|
|
61
|
+
+ `\n\n${lines.rawWatchLabel ?? "SIGNALS"} — ${coverageWord(board.coverage?.signals, signals.length)}`
|
|
62
|
+
+ `${rawSignals ? `\n${rawSignals}` : `\n ${lines.rawWatchEmpty ?? ""}`}`, {
|
|
63
|
+
audience: response.body.audience ?? null,
|
|
64
|
+
windowDays: response.body.windowDays ?? board.windowDays ?? null,
|
|
65
|
+
board,
|
|
66
|
+
lines,
|
|
67
|
+
});
|
|
68
|
+
}));
|
|
69
|
+
register("workbook_read", {
|
|
70
|
+
title: "Read the Tower workbook",
|
|
71
|
+
description: "The per-project document library. With no arguments: every project and the documents in it. With `project` "
|
|
72
|
+
+ "and `doc`: that document as markdown, rendered by the same walker the page uses.",
|
|
73
|
+
inputSchema: {
|
|
74
|
+
project: z.string().min(1).max(200).optional().describe("A project slug, as workbook_read lists it."),
|
|
75
|
+
doc: z.string().min(1).max(200).optional().describe("A document slug within that project. Needs `project` too."),
|
|
76
|
+
},
|
|
77
|
+
}, async (args) => withSession(deps, async (session) => {
|
|
78
|
+
const wantsDoc = Boolean(args.doc);
|
|
79
|
+
if (wantsDoc && !args.project) {
|
|
80
|
+
return errorResult("workbook_read needs `project` beside `doc` — a document slug is only unique within its project.");
|
|
81
|
+
}
|
|
82
|
+
const params = new URLSearchParams();
|
|
83
|
+
if (wantsDoc) {
|
|
84
|
+
params.set("project", String(args.project));
|
|
85
|
+
params.set("doc", String(args.doc));
|
|
86
|
+
}
|
|
87
|
+
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/cockpit/workbook${queryString(params)}`);
|
|
88
|
+
if (!response.ok)
|
|
89
|
+
return errorResult(doorFailureText("workbook_read", response));
|
|
90
|
+
if (wantsDoc) {
|
|
91
|
+
const markdown = typeof response.body.markdown === "string" ? response.body.markdown : "";
|
|
92
|
+
if (markdown === "") {
|
|
93
|
+
return errorResult(`Tower answered workbook_read without a document for ${String(args.project)}/${String(args.doc)}.`);
|
|
94
|
+
}
|
|
95
|
+
return textResult(markdown, {
|
|
96
|
+
project: response.body.project ?? null,
|
|
97
|
+
doc: response.body.doc ?? null,
|
|
98
|
+
markdown,
|
|
99
|
+
sections: response.body.sections ?? [],
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const projects = (Array.isArray(response.body.projects) ? response.body.projects : []);
|
|
103
|
+
// A named project that is not there is a refusal with the list beside
|
|
104
|
+
// it: an agent that guessed a slug can fix the guess in one step.
|
|
105
|
+
if (args.project) {
|
|
106
|
+
const found = projects.find((project) => project.slug === args.project);
|
|
107
|
+
if (!found) {
|
|
108
|
+
const known = projects.map((project) => project.slug ?? "").filter(Boolean);
|
|
109
|
+
return errorResult(`No project "${String(args.project)}" in the workbook library (${known.join(", ") || "none"}).`);
|
|
110
|
+
}
|
|
111
|
+
const docs = (found.docs ?? []).map((doc) => ` ${doc.slug ?? "?"} ${doc.title ?? ""}`).join("\n");
|
|
112
|
+
return textResult(`${found.title ?? found.slug ?? ""}\n${found.line ?? ""}${docs ? `\n${docs}` : ""}`, { project: found });
|
|
113
|
+
}
|
|
114
|
+
const docCount = projects.reduce((count, project) => count + (project.docs?.length ?? 0), 0);
|
|
115
|
+
const lines = projects
|
|
116
|
+
.map((project) => {
|
|
117
|
+
const docs = (project.docs ?? []).map((doc) => ` ${doc.slug ?? "?"} ${doc.title ?? ""}`).join("\n");
|
|
118
|
+
return `${(project.title ?? project.slug ?? "").toUpperCase()} (${project.slug ?? "?"})${docs ? `\n${docs}` : ""}`;
|
|
119
|
+
})
|
|
120
|
+
.join("\n\n");
|
|
121
|
+
return textResult(`WORKBOOK · ${projects.length} project(s) · ${docCount} document(s).${lines ? `\n\n${lines}` : ""}`, { projects });
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The README's verb table, rendered from the census (BLI-3756).
|
|
3
|
+
*
|
|
4
|
+
* `npm run mcp:readme` writes the output of `renderCensusMarkdown()` between
|
|
5
|
+
* the two markers below, and `readme-census.test.ts` fails when the committed
|
|
6
|
+
* README no longer matches — so the table a stranger reads and the map the
|
|
7
|
+
* suite polices cannot disagree. A table typed by hand beside a list of tools
|
|
8
|
+
* is a table that goes stale on the next batch; this one cannot.
|
|
9
|
+
*
|
|
10
|
+
* Pure: it reads the census (which reads the collector's own decision tables)
|
|
11
|
+
* and returns a string. No file writing here — that is the script's job, so
|
|
12
|
+
* this can be asserted without touching a disk.
|
|
13
|
+
*/
|
|
14
|
+
export declare const CENSUS_BEGIN = "<!-- BEGIN GENERATED verb census \u2014 `npm run mcp:readme` -->";
|
|
15
|
+
export declare const CENSUS_END = "<!-- END GENERATED verb census -->";
|
|
16
|
+
export declare function renderCensusMarkdown(): string;
|
|
17
|
+
/** The README with a fresh census block spliced in. Throws if the markers are gone. */
|
|
18
|
+
export declare function spliceCensus(readme: string): string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The README's verb table, rendered from the census (BLI-3756).
|
|
3
|
+
*
|
|
4
|
+
* `npm run mcp:readme` writes the output of `renderCensusMarkdown()` between
|
|
5
|
+
* the two markers below, and `readme-census.test.ts` fails when the committed
|
|
6
|
+
* README no longer matches — so the table a stranger reads and the map the
|
|
7
|
+
* suite polices cannot disagree. A table typed by hand beside a list of tools
|
|
8
|
+
* is a table that goes stale on the next batch; this one cannot.
|
|
9
|
+
*
|
|
10
|
+
* Pure: it reads the census (which reads the collector's own decision tables)
|
|
11
|
+
* and returns a string. No file writing here — that is the script's job, so
|
|
12
|
+
* this can be asserted without touching a disk.
|
|
13
|
+
*/
|
|
14
|
+
import { AWAITING_TWIN, MCP_TWINS, TERMINAL_ONLY, towerVerbs } from "./verb-census.js";
|
|
15
|
+
export const CENSUS_BEGIN = "<!-- BEGIN GENERATED verb census — `npm run mcp:readme` -->";
|
|
16
|
+
export const CENSUS_END = "<!-- END GENERATED verb census -->";
|
|
17
|
+
/** `cockpit docs read` → `` `cockpit docs read` ``; `jarvis --trace` keeps its flag. */
|
|
18
|
+
function spell(verb) {
|
|
19
|
+
return `\`cockpit ${verb}\``;
|
|
20
|
+
}
|
|
21
|
+
export function renderCensusMarkdown() {
|
|
22
|
+
// Every verb the collector's own tables name, plus the one hand-entered mode
|
|
23
|
+
// flag — in the same order the census sorts them, so a diff is readable.
|
|
24
|
+
const verbs = [...new Set([...towerVerbs().map((verb) => verb.spelling), ...Object.keys(MCP_TWINS)])].sort();
|
|
25
|
+
const twinRows = verbs
|
|
26
|
+
.filter((verb) => MCP_TWINS[verb])
|
|
27
|
+
.map((verb) => {
|
|
28
|
+
const twin = MCP_TWINS[verb];
|
|
29
|
+
return `| ${spell(verb)} | \`${twin.tool}\` | \`${twin.door}\` |`;
|
|
30
|
+
});
|
|
31
|
+
const owedRows = verbs
|
|
32
|
+
.filter((verb) => AWAITING_TWIN[verb])
|
|
33
|
+
.map((verb) => `| ${spell(verb)} | ${AWAITING_TWIN[verb]} |`);
|
|
34
|
+
const terminalRows = verbs
|
|
35
|
+
.filter((verb) => TERMINAL_ONLY[verb])
|
|
36
|
+
.map((verb) => `| ${spell(verb)} | ${TERMINAL_ONLY[verb]} |`);
|
|
37
|
+
return [
|
|
38
|
+
CENSUS_BEGIN,
|
|
39
|
+
"",
|
|
40
|
+
`**${twinRows.length} of ${twinRows.length + owedRows.length + terminalRows.length} Tower verbs have an MCP twin.**`,
|
|
41
|
+
"Each tool goes through the SAME door its CLI verb calls, with the same",
|
|
42
|
+
"collector device token — never a second route and never a service-role",
|
|
43
|
+
"reader. `src/verb-census.test.ts` fails when a verb is in none of the",
|
|
44
|
+
"three tables below.",
|
|
45
|
+
"",
|
|
46
|
+
"| CLI verb | MCP tool | Door |",
|
|
47
|
+
"| --- | --- | --- |",
|
|
48
|
+
...twinRows,
|
|
49
|
+
"",
|
|
50
|
+
"### Owed a twin",
|
|
51
|
+
"",
|
|
52
|
+
"This table should only ever shrink.",
|
|
53
|
+
"",
|
|
54
|
+
"| CLI verb | Who owes it, and why it is not built yet |",
|
|
55
|
+
"| --- | --- |",
|
|
56
|
+
...owedRows,
|
|
57
|
+
"",
|
|
58
|
+
"### Terminal-only",
|
|
59
|
+
"",
|
|
60
|
+
"A claim about the verb's nature, not a backlog.",
|
|
61
|
+
"",
|
|
62
|
+
"| CLI verb | Why it can never have a twin |",
|
|
63
|
+
"| --- | --- |",
|
|
64
|
+
...terminalRows,
|
|
65
|
+
"",
|
|
66
|
+
CENSUS_END,
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|
|
69
|
+
/** The README with a fresh census block spliced in. Throws if the markers are gone. */
|
|
70
|
+
export function spliceCensus(readme) {
|
|
71
|
+
const start = readme.indexOf(CENSUS_BEGIN);
|
|
72
|
+
const end = readme.indexOf(CENSUS_END);
|
|
73
|
+
if (start < 0 || end < 0) {
|
|
74
|
+
throw new Error(`README.md has no census markers. Put ${CENSUS_BEGIN} and ${CENSUS_END} back, or the generated table has nowhere to go.`);
|
|
75
|
+
}
|
|
76
|
+
return readme.slice(0, start) + renderCensusMarkdown() + readme.slice(end + CENSUS_END.length);
|
|
77
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tower_search` — one bar over five corpora, as an MCP tool (BLI-3728).
|
|
3
|
+
*
|
|
4
|
+
* The same `GET /api/search` door the browser's search bar and `cockpit
|
|
5
|
+
* search` press, over this machine's device token
|
|
6
|
+
* (`agent-door-session.ts`/`agent-door.ts`). Nothing about ranking, scoping or
|
|
7
|
+
* snippets is decided here; this file turns one HTTP answer into one tool
|
|
8
|
+
* result.
|
|
9
|
+
*
|
|
10
|
+
* WHY IT IS ONE TOOL AND NOT FIVE
|
|
11
|
+
* -------------------------------
|
|
12
|
+
* `docs_list`, `msg_read` and the rest already exist and answer "show me this
|
|
13
|
+
* corpus". The question this tool answers is different and is the reason the
|
|
14
|
+
* ticket exists: "where in Tower is the thing I half-remember?" A model that
|
|
15
|
+
* has to pick a corpus before it can look has already been asked to know the
|
|
16
|
+
* answer. `kinds` narrows it when the model DOES know.
|
|
17
|
+
*
|
|
18
|
+
* Its result carries provenance, and that is not decoration: every hit says
|
|
19
|
+
* which corpus produced it, what its address is, and — for the corpora that
|
|
20
|
+
* could not answer — that they did not. A tool result that folds "messages
|
|
21
|
+
* failed" into "no messages matched" invites the model to report the record as
|
|
22
|
+
* silent when nobody asked it. That is the same rule the door, the CLI and the
|
|
23
|
+
* browser overlay all keep.
|
|
24
|
+
*/
|
|
25
|
+
import { loadAgentDoorSession } from "./agent-door-session.js";
|
|
26
|
+
import { type FetchImpl } from "./agent-door.js";
|
|
27
|
+
export interface SearchToolDeps {
|
|
28
|
+
fetchImpl: FetchImpl;
|
|
29
|
+
/** Injectable for tests; defaults to reading `~/.config/bli-cockpit/session.json`. */
|
|
30
|
+
loadSession?: typeof loadAgentDoorSession;
|
|
31
|
+
}
|
|
32
|
+
interface SearchHit {
|
|
33
|
+
kind: string;
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
snippet: string;
|
|
37
|
+
date: string | null;
|
|
38
|
+
author: string | null;
|
|
39
|
+
href: string | null;
|
|
40
|
+
score: number;
|
|
41
|
+
channels: string[];
|
|
42
|
+
}
|
|
43
|
+
export declare function registerSearchTool(server: {
|
|
44
|
+
registerTool: (...args: never[]) => unknown;
|
|
45
|
+
}, deps: SearchToolDeps): void;
|
|
46
|
+
/**
|
|
47
|
+
* The sentence a model reads.
|
|
48
|
+
*
|
|
49
|
+
* Every line carries its own provenance — corpus, address, author, date — so a
|
|
50
|
+
* model quoting a result can cite it without a second call, and so a `Source:`
|
|
51
|
+
* line in its answer is copied rather than invented.
|
|
52
|
+
*/
|
|
53
|
+
export declare function renderHits(query: string, hits: readonly SearchHit[], failures: Record<string, string>): string;
|
|
54
|
+
export {};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tower_search` — one bar over five corpora, as an MCP tool (BLI-3728).
|
|
3
|
+
*
|
|
4
|
+
* The same `GET /api/search` door the browser's search bar and `cockpit
|
|
5
|
+
* search` press, over this machine's device token
|
|
6
|
+
* (`agent-door-session.ts`/`agent-door.ts`). Nothing about ranking, scoping or
|
|
7
|
+
* snippets is decided here; this file turns one HTTP answer into one tool
|
|
8
|
+
* result.
|
|
9
|
+
*
|
|
10
|
+
* WHY IT IS ONE TOOL AND NOT FIVE
|
|
11
|
+
* -------------------------------
|
|
12
|
+
* `docs_list`, `msg_read` and the rest already exist and answer "show me this
|
|
13
|
+
* corpus". The question this tool answers is different and is the reason the
|
|
14
|
+
* ticket exists: "where in Tower is the thing I half-remember?" A model that
|
|
15
|
+
* has to pick a corpus before it can look has already been asked to know the
|
|
16
|
+
* answer. `kinds` narrows it when the model DOES know.
|
|
17
|
+
*
|
|
18
|
+
* Its result carries provenance, and that is not decoration: every hit says
|
|
19
|
+
* which corpus produced it, what its address is, and — for the corpora that
|
|
20
|
+
* could not answer — that they did not. A tool result that folds "messages
|
|
21
|
+
* failed" into "no messages matched" invites the model to report the record as
|
|
22
|
+
* silent when nobody asked it. That is the same rule the door, the CLI and the
|
|
23
|
+
* browser overlay all keep.
|
|
24
|
+
*/
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
import { loadAgentDoorSession } from "./agent-door-session.js";
|
|
27
|
+
import { callAgentDoor } from "./agent-door.js";
|
|
28
|
+
const KINDS = ["doc", "msg", "issue", "note", "memory"];
|
|
29
|
+
const KIND_LABELS = {
|
|
30
|
+
doc: "Document",
|
|
31
|
+
msg: "Message",
|
|
32
|
+
issue: "Issue",
|
|
33
|
+
note: "Meeting note",
|
|
34
|
+
memory: "Memory",
|
|
35
|
+
};
|
|
36
|
+
function textResult(text, structured) {
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: "text", text }],
|
|
39
|
+
...(structured ? { structuredContent: structured } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function errorResult(text) {
|
|
43
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
44
|
+
}
|
|
45
|
+
export function registerSearchTool(server, deps) {
|
|
46
|
+
const register = server.registerTool.bind(server);
|
|
47
|
+
register("tower_search", {
|
|
48
|
+
title: "Search everything in Tower",
|
|
49
|
+
description: "One search over documents, messages, issues, meeting notes and BLI Memory. Use it when you know WHAT you are looking for but not WHERE it lives. Every result names the corpus it came from and where to open it; a memory has no page, so its text is the whole record. A corpus that could not answer is reported separately from one that found nothing — do not read a failure as silence.",
|
|
50
|
+
inputSchema: {
|
|
51
|
+
query: z.string().min(2).max(1000).describe("What to look for, in the words a person would type."),
|
|
52
|
+
kinds: z
|
|
53
|
+
.array(z.enum(KINDS))
|
|
54
|
+
.optional()
|
|
55
|
+
.describe("Narrow to these corpora. Omit to search all five."),
|
|
56
|
+
limit: z.number().int().min(1).max(50).optional().describe("Results to return (default 20)."),
|
|
57
|
+
},
|
|
58
|
+
}, async (args) => {
|
|
59
|
+
const loadSession = deps.loadSession ?? loadAgentDoorSession;
|
|
60
|
+
const loaded = loadSession();
|
|
61
|
+
if (!loaded.ok) {
|
|
62
|
+
return errorResult(`This machine is not paired with Tower (${loaded.reason}). ${loaded.message}`);
|
|
63
|
+
}
|
|
64
|
+
const query = String(args.query ?? "");
|
|
65
|
+
const params = new URLSearchParams({ q: query });
|
|
66
|
+
if (Array.isArray(args.kinds) && args.kinds.length > 0) {
|
|
67
|
+
params.set("kinds", args.kinds.join(","));
|
|
68
|
+
}
|
|
69
|
+
if (typeof args.limit === "number")
|
|
70
|
+
params.set("limit", String(args.limit));
|
|
71
|
+
const response = await callAgentDoor(loaded.session, deps.fetchImpl, "GET", `/api/search?${params.toString()}`);
|
|
72
|
+
if (!response.ok) {
|
|
73
|
+
if (response.transportError) {
|
|
74
|
+
return errorResult(`Tower could not be reached for tower_search (${response.transportError}). Nothing was searched — this is an outage, not an empty result.`);
|
|
75
|
+
}
|
|
76
|
+
const reason = typeof response.body.reason === "string" ? response.body.reason : "unknown_error";
|
|
77
|
+
const message = typeof response.body.message === "string"
|
|
78
|
+
? response.body.message
|
|
79
|
+
: `Tower answered ${response.status}.`;
|
|
80
|
+
return errorResult(`Tower refused tower_search (${reason}): ${message}`);
|
|
81
|
+
}
|
|
82
|
+
const hits = (Array.isArray(response.body.hits) ? response.body.hits : []);
|
|
83
|
+
const failures = (response.body.failures ?? {});
|
|
84
|
+
return textResult(renderHits(query, hits, failures), {
|
|
85
|
+
query,
|
|
86
|
+
hits,
|
|
87
|
+
failures,
|
|
88
|
+
elapsedMs: response.body.elapsedMs ?? null,
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The sentence a model reads.
|
|
94
|
+
*
|
|
95
|
+
* Every line carries its own provenance — corpus, address, author, date — so a
|
|
96
|
+
* model quoting a result can cite it without a second call, and so a `Source:`
|
|
97
|
+
* line in its answer is copied rather than invented.
|
|
98
|
+
*/
|
|
99
|
+
export function renderHits(query, hits, failures) {
|
|
100
|
+
const lines = [];
|
|
101
|
+
const failed = Object.entries(failures);
|
|
102
|
+
if (hits.length === 0) {
|
|
103
|
+
lines.push(failed.length === 0
|
|
104
|
+
? `Nothing in Tower matches "${query}". Every corpus answered — the record is silent on this, it did not fail.`
|
|
105
|
+
: `Nothing matched "${query}" in the corpora that answered.`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
lines.push(`${hits.length} result(s) for "${query}".`);
|
|
109
|
+
lines.push("");
|
|
110
|
+
for (const hit of hits) {
|
|
111
|
+
const label = KIND_LABELS[hit.kind] ?? hit.kind;
|
|
112
|
+
lines.push(`${label}: ${hit.title}`);
|
|
113
|
+
if (hit.snippet)
|
|
114
|
+
lines.push(` ${hit.snippet.replace(/\s+/g, " ").trim()}`);
|
|
115
|
+
const provenance = [
|
|
116
|
+
`Source: ${label.toLowerCase()}`,
|
|
117
|
+
hit.href ? `at ${hit.href}` : "no page — the text above is the whole record",
|
|
118
|
+
hit.author ? `by ${hit.author}` : null,
|
|
119
|
+
hit.date ? `on ${hit.date.slice(0, 10)}` : null,
|
|
120
|
+
`matched by ${(hit.channels ?? []).join(" + ") || "unknown channel"}`,
|
|
121
|
+
]
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
.join(", ");
|
|
124
|
+
lines.push(` ${provenance}`);
|
|
125
|
+
lines.push("");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Never folded into "nothing matched". A model that cannot tell them apart
|
|
129
|
+
// will report the record as silent when the search was simply broken.
|
|
130
|
+
for (const [kind, reason] of failed) {
|
|
131
|
+
lines.push(`${KIND_LABELS[kind] ?? kind} search did not answer (${reason}). Nothing from that corpus is in this list — do not conclude it holds nothing.`);
|
|
132
|
+
}
|
|
133
|
+
return lines.join("\n").trim();
|
|
134
|
+
}
|
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.2";
|
|
23
23
|
export declare const emitEventInput: {
|
|
24
24
|
ticket_id: z.ZodString;
|
|
25
25
|
event_type: z.ZodString;
|
package/dist/server.js
CHANGED
|
@@ -6,10 +6,17 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
+
import { registerBriefTools } from "./brief-tools.js";
|
|
9
10
|
import { registerDocsMsgTools } from "./docs-msg-tools.js";
|
|
11
|
+
import { registerJarvisTools } from "./jarvis-tools.js";
|
|
12
|
+
import { registerNotesTools } from "./notes-tools.js";
|
|
13
|
+
import { registerOpsTools } from "./ops-tools.js";
|
|
14
|
+
import { registerPagesTools } from "./pages-tools.js";
|
|
15
|
+
import { registerSearchTool } from "./search-tool.js";
|
|
16
|
+
import { registerSettingsTools } from "./settings-tools.js";
|
|
10
17
|
import { registerWorkTools } from "./work-tools.js";
|
|
11
18
|
export const PACKAGE_NAME = "@bli-cockpit/mcp";
|
|
12
|
-
export const PACKAGE_VERSION = "0.1.
|
|
19
|
+
export const PACKAGE_VERSION = "0.1.2";
|
|
13
20
|
// ---- input schemas (Zod raw shapes) -----------------------------------------
|
|
14
21
|
export const emitEventInput = {
|
|
15
22
|
ticket_id: z
|
|
@@ -375,5 +382,22 @@ export function createServer(deps) {
|
|
|
375
382
|
// the docs/msg tools above, for the same reason: a coding session should
|
|
376
383
|
// file and move a Tower issue the way it files and moves a Linear one.
|
|
377
384
|
registerWorkTools(server, { fetchImpl: deps.fetchImpl });
|
|
385
|
+
// BLI-3732: `jarvis_*` — the assistant itself, on the same device-token path
|
|
386
|
+
// as the three families above, over the doors `cockpit jarvis` already
|
|
387
|
+
// calls. It is the last Tower surface that had a CLI door and no MCP one.
|
|
388
|
+
registerJarvisTools(server, { fetchImpl: deps.fetchImpl });
|
|
389
|
+
// BLI-3756 batch 1: the READS the CLI already had and this server did not —
|
|
390
|
+
// the daily page, the meeting-notes library, the ops board, Slack coverage,
|
|
391
|
+
// settings/team/model, Scout and the workbook. Same doors, same device
|
|
392
|
+
// token, same refusal words; `verb-census.test.ts` is what says the list is
|
|
393
|
+
// complete.
|
|
394
|
+
registerBriefTools(server, { fetchImpl: deps.fetchImpl });
|
|
395
|
+
registerNotesTools(server, { fetchImpl: deps.fetchImpl });
|
|
396
|
+
registerOpsTools(server, { fetchImpl: deps.fetchImpl });
|
|
397
|
+
registerSettingsTools(server, { fetchImpl: deps.fetchImpl });
|
|
398
|
+
registerPagesTools(server, { fetchImpl: deps.fetchImpl });
|
|
399
|
+
// BLI-3728: one search over documents, messages, issues, meeting notes and
|
|
400
|
+
// memory. Same door, same ranking, same snippets the browser bar shows.
|
|
401
|
+
registerSearchTool(server, { fetchImpl: deps.fetchImpl });
|
|
378
402
|
return server;
|
|
379
403
|
}
|