@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.
Files changed (41) hide show
  1. package/README.md +168 -2
  2. package/dist/agent-door.d.ts +8 -1
  3. package/dist/agent-door.js +7 -2
  4. package/dist/brief-tools.d.ts +26 -0
  5. package/dist/brief-tools.js +160 -0
  6. package/dist/brief-write-tools.d.ts +37 -0
  7. package/dist/brief-write-tools.js +178 -0
  8. package/dist/docs-msg-tools.d.ts +13 -7
  9. package/dist/docs-msg-tools.js +141 -25
  10. package/dist/jarvis-answer-envelope.d.ts +13 -1
  11. package/dist/jarvis-answer-envelope.js +2 -0
  12. package/dist/jarvis-door.js +16 -1
  13. package/dist/jarvis-tools.d.ts +18 -10
  14. package/dist/jarvis-tools.js +28 -17
  15. package/dist/notes-tools.d.ts +33 -0
  16. package/dist/notes-tools.js +143 -0
  17. package/dist/notes-write-tools.d.ts +34 -0
  18. package/dist/notes-write-tools.js +211 -0
  19. package/dist/ops-tools.d.ts +23 -0
  20. package/dist/ops-tools.js +212 -0
  21. package/dist/pages-tools.d.ts +28 -0
  22. package/dist/pages-tools.js +190 -0
  23. package/dist/readme-census.d.ts +18 -0
  24. package/dist/readme-census.js +79 -0
  25. package/dist/search-tool.d.ts +54 -0
  26. package/dist/search-tool.js +134 -0
  27. package/dist/server.d.ts +1 -1
  28. package/dist/server.js +34 -1
  29. package/dist/settings-tools.d.ts +29 -0
  30. package/dist/settings-tools.js +151 -0
  31. package/dist/settings-write-tools.d.ts +47 -0
  32. package/dist/settings-write-tools.js +183 -0
  33. package/dist/team-write-tools.d.ts +39 -0
  34. package/dist/team-write-tools.js +141 -0
  35. package/dist/tool-result.d.ts +86 -0
  36. package/dist/tool-result.js +105 -0
  37. package/dist/verb-census.d.ts +22 -0
  38. package/dist/verb-census.js +108 -6
  39. package/dist/work-tools.d.ts +2 -7
  40. package/dist/work-tools.js +35 -25
  41. package/package.json +5 -4
@@ -0,0 +1,190 @@
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 { z } from "zod";
23
+ import { callAgentDoor } from "./agent-door.js";
24
+ import { doorFailureText, errorResult, registrarFor, textResult, queryString, withSession, } from "./tool-result.js";
25
+ /** "8 of 23 (truncated)" — never a bare count that hides what was left out. */
26
+ function coverageWord(coverage, shown) {
27
+ if (!coverage)
28
+ return String(shown);
29
+ const total = coverage.total ?? null;
30
+ return `${coverage.returned ?? shown}${total === null ? "" : ` of ${total}`}${coverage.truncated ? " (truncated)" : ""}`;
31
+ }
32
+ export function registerPagesTools(server, deps) {
33
+ const register = registrarFor(server);
34
+ register("scout_board", {
35
+ title: "Read the Tower Scout board",
36
+ description: "The experiment cards Scout is proposing, the ones already settled, and the raw signals behind them — plus "
37
+ + "the page's own standing sentences. Read only; moving a card is not on this surface.",
38
+ inputSchema: {
39
+ days: z.number().int().min(1).max(365).optional().describe("How many days of signals to look back over."),
40
+ },
41
+ }, async (args) => withSession(deps, async (session) => {
42
+ const params = new URLSearchParams();
43
+ if (typeof args.days === "number")
44
+ params.set("days", String(args.days));
45
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/cockpit/scout${queryString(params)}`);
46
+ if (!response.ok)
47
+ return errorResult(doorFailureText("scout_board", response));
48
+ const board = (response.body.board ?? {});
49
+ const lines = (response.body.lines ?? {});
50
+ const experiments = board.experiments ?? [];
51
+ const settled = board.settled ?? [];
52
+ const signals = board.signals ?? [];
53
+ const said = [lines.watch, lines.headline, lines.quiet].filter(Boolean).join("\n");
54
+ const cards = experiments
55
+ .map((card) => ` ${card.id ?? "?"} ${card.status ?? "?"} ${card.title ?? ""}\n ${card.claimSummary ?? ""}`)
56
+ .join("\n");
57
+ const settledCards = settled.map((card) => ` ${card.id ?? "?"} ${card.title ?? ""}`).join("\n");
58
+ const rawSignals = signals.map((signal) => ` ${signal.source ?? "?"} ${signal.title ?? ""}`).join("\n");
59
+ return textResult(`${said}\n\n${lines.waitingLabel ?? "OPEN"} — ${coverageWord(board.coverage?.openExperiments, experiments.length)}`
60
+ + `${cards ? `\n${cards}` : ""}`
61
+ + `\n\n${lines.settledLabel ?? "SETTLED"} — ${coverageWord(board.coverage?.settledExperiments, settled.length)}`
62
+ + `${settledCards ? `\n${settledCards}` : ""}`
63
+ + `\n\n${lines.rawWatchLabel ?? "SIGNALS"} — ${coverageWord(board.coverage?.signals, signals.length)}`
64
+ + `${rawSignals ? `\n${rawSignals}` : `\n ${lines.rawWatchEmpty ?? ""}`}`, {
65
+ audience: response.body.audience ?? null,
66
+ windowDays: response.body.windowDays ?? board.windowDays ?? null,
67
+ board,
68
+ lines,
69
+ });
70
+ }));
71
+ register("workbook_read", {
72
+ title: "Read the Tower workbook",
73
+ description: "The per-project document library. With no arguments: every project and the documents in it. With `project` "
74
+ + "and `doc`: that document as markdown, rendered by the same walker the page uses.",
75
+ inputSchema: {
76
+ project: z.string().min(1).max(200).optional().describe("A project slug, as workbook_read lists it."),
77
+ doc: z.string().min(1).max(200).optional().describe("A document slug within that project. Needs `project` too."),
78
+ },
79
+ }, async (args) => withSession(deps, async (session) => {
80
+ const wantsDoc = Boolean(args.doc);
81
+ if (wantsDoc && !args.project) {
82
+ return errorResult("workbook_read needs `project` beside `doc` — a document slug is only unique within its project.");
83
+ }
84
+ const params = new URLSearchParams();
85
+ if (wantsDoc) {
86
+ params.set("project", String(args.project));
87
+ params.set("doc", String(args.doc));
88
+ }
89
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/cockpit/workbook${queryString(params)}`);
90
+ if (!response.ok)
91
+ return errorResult(doorFailureText("workbook_read", response));
92
+ if (wantsDoc) {
93
+ const markdown = typeof response.body.markdown === "string" ? response.body.markdown : "";
94
+ if (markdown === "") {
95
+ return errorResult(`Tower answered workbook_read without a document for ${String(args.project)}/${String(args.doc)}.`);
96
+ }
97
+ return textResult(markdown, {
98
+ project: response.body.project ?? null,
99
+ doc: response.body.doc ?? null,
100
+ markdown,
101
+ sections: response.body.sections ?? [],
102
+ });
103
+ }
104
+ const projects = (Array.isArray(response.body.projects) ? response.body.projects : []);
105
+ // A named project that is not there is a refusal with the list beside
106
+ // it: an agent that guessed a slug can fix the guess in one step.
107
+ if (args.project) {
108
+ const found = projects.find((project) => project.slug === args.project);
109
+ if (!found) {
110
+ const known = projects.map((project) => project.slug ?? "").filter(Boolean);
111
+ return errorResult(`No project "${String(args.project)}" in the workbook library (${known.join(", ") || "none"}).`);
112
+ }
113
+ const docs = (found.docs ?? []).map((doc) => ` ${doc.slug ?? "?"} ${doc.title ?? ""}`).join("\n");
114
+ return textResult(`${found.title ?? found.slug ?? ""}\n${found.line ?? ""}${docs ? `\n${docs}` : ""}`, { project: found });
115
+ }
116
+ const docCount = projects.reduce((count, project) => count + (project.docs?.length ?? 0), 0);
117
+ const lines = projects
118
+ .map((project) => {
119
+ const docs = (project.docs ?? []).map((doc) => ` ${doc.slug ?? "?"} ${doc.title ?? ""}`).join("\n");
120
+ return `${(project.title ?? project.slug ?? "").toUpperCase()} (${project.slug ?? "?"})${docs ? `\n${docs}` : ""}`;
121
+ })
122
+ .join("\n\n");
123
+ return textResult(`WORKBOOK · ${projects.length} project(s) · ${docCount} document(s).${lines ? `\n\n${lines}` : ""}`, { projects });
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
+ }
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
+ };
@@ -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,79 @@
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
+ // 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]),
59
+ "",
60
+ "### Terminal-only",
61
+ "",
62
+ "A claim about the verb's nature, not a backlog.",
63
+ "",
64
+ ...(terminalRows.length === 0
65
+ ? ["_Nothing: no verb has been claimed as impossible._"]
66
+ : ["| CLI verb | Why it can never have a twin |", "| --- | --- |", ...terminalRows]),
67
+ "",
68
+ CENSUS_END,
69
+ ].join("\n");
70
+ }
71
+ /** The README with a fresh census block spliced in. Throws if the markers are gone. */
72
+ export function spliceCensus(readme) {
73
+ const start = readme.indexOf(CENSUS_BEGIN);
74
+ const end = readme.indexOf(CENSUS_END);
75
+ if (start < 0 || end < 0) {
76
+ throw new Error(`README.md has no census markers. Put ${CENSUS_BEGIN} and ${CENSUS_END} back, or the generated table has nowhere to go.`);
77
+ }
78
+ return readme.slice(0, start) + renderCensusMarkdown() + readme.slice(end + CENSUS_END.length);
79
+ }
@@ -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.2";
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
@@ -6,11 +6,21 @@
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";
10
+ import { registerBriefWriteTools } from "./brief-write-tools.js";
9
11
  import { registerDocsMsgTools } from "./docs-msg-tools.js";
10
12
  import { registerJarvisTools } from "./jarvis-tools.js";
13
+ import { registerNotesTools } from "./notes-tools.js";
14
+ import { registerNotesWriteTools } from "./notes-write-tools.js";
15
+ import { registerOpsTools } from "./ops-tools.js";
16
+ import { registerPagesTools } from "./pages-tools.js";
17
+ import { registerSearchTool } from "./search-tool.js";
18
+ import { registerSettingsTools } from "./settings-tools.js";
19
+ import { registerSettingsWriteTools } from "./settings-write-tools.js";
20
+ import { registerTeamWriteTools } from "./team-write-tools.js";
11
21
  import { registerWorkTools } from "./work-tools.js";
12
22
  export const PACKAGE_NAME = "@bli-cockpit/mcp";
13
- export const PACKAGE_VERSION = "0.1.2";
23
+ export const PACKAGE_VERSION = "0.1.4";
14
24
  // ---- input schemas (Zod raw shapes) -----------------------------------------
15
25
  export const emitEventInput = {
16
26
  ticket_id: z
@@ -380,5 +390,28 @@ export function createServer(deps) {
380
390
  // as the three families above, over the doors `cockpit jarvis` already
381
391
  // calls. It is the last Tower surface that had a CLI door and no MCP one.
382
392
  registerJarvisTools(server, { fetchImpl: deps.fetchImpl });
393
+ // BLI-3756 batch 1: the READS the CLI already had and this server did not —
394
+ // the daily page, the meeting-notes library, the ops board, Slack coverage,
395
+ // settings/team/model, Scout and the workbook. Same doors, same device
396
+ // token, same refusal words; `verb-census.test.ts` is what says the list is
397
+ // complete.
398
+ registerBriefTools(server, { fetchImpl: deps.fetchImpl });
399
+ registerNotesTools(server, { fetchImpl: deps.fetchImpl });
400
+ registerOpsTools(server, { fetchImpl: deps.fetchImpl });
401
+ registerSettingsTools(server, { fetchImpl: deps.fetchImpl });
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 });
413
+ // BLI-3728: one search over documents, messages, issues, meeting notes and
414
+ // memory. Same door, same ranking, same snippets the browser bar shows.
415
+ registerSearchTool(server, { fetchImpl: deps.fetchImpl });
383
416
  return server;
384
417
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `settings_*` / `team_*` / `model_show` MCP tools (BLI-3756) — the settings
3
+ * surface read by an agent, over the same `/api/settings/*` and `/api/team/*`
4
+ * doors `cockpit settings`, `cockpit team` and `cockpit model` call with the
5
+ * same device token.
6
+ *
7
+ * One file because the dashboard has one owner for all of it (`lib/settings/`
8
+ * behind `settings-gate.ts`, which the team directory and the model shorthand
9
+ * both sit behind), and because the reads share the rule below.
10
+ *
11
+ * **"Admin only" is not an error.** A 403 on a section is the system working;
12
+ * `settings_show` renders it as one line saying so and keeps every other
13
+ * section, exactly as the CLI does. Returning `isError` for the whole call
14
+ * would teach an agent that the surface is broken when in fact it is simply
15
+ * not this caller's to see.
16
+ *
17
+ * **A secret never comes back.** `settings_list` lists env-blob NAMES, sizes
18
+ * and stamps — the server cannot return a value and nothing here asks for one.
19
+ * Writing content is stdin-only by rule and stays in batch 2.
20
+ */
21
+ import { type ToolDeps } from "./tool-result.js";
22
+ export type SettingsDeps = ToolDeps;
23
+ /** The sections `cockpit settings [section]` reads, plus the all-at-once view. */
24
+ export declare const SETTINGS_SECTIONS: readonly ["overview", "personal", "switches", "models", "cli-floor"];
25
+ export declare function registerSettingsTools(server: {
26
+ registerTool: (...args: never[]) => unknown;
27
+ }, deps: SettingsDeps): void;
28
+ /** `provider:model`, or `unset`. The same rendering `cockpit model show` prints. */
29
+ export declare function modelKeyOf(value: unknown): string;