@bli-cockpit/mcp 0.1.59 → 0.1.63
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 +74 -1
- package/dist/agent-door.d.ts +12 -0
- package/dist/agent-door.js +12 -1
- package/dist/cal-tools.js +25 -22
- package/dist/careers-tools.d.ts +6 -0
- package/dist/careers-tools.js +79 -32
- package/dist/docs-msg-helpers.d.ts +89 -0
- package/dist/docs-msg-helpers.js +106 -0
- package/dist/docs-msg-tools.d.ts +3 -13
- package/dist/docs-msg-tools.js +59 -81
- package/dist/docs-notion-tools.d.ts +16 -0
- package/dist/docs-notion-tools.js +89 -0
- package/dist/mail-tools.js +47 -26
- package/dist/memory-consolidation-tools.js +20 -2
- package/dist/memory-experience-tools.js +5 -1
- package/dist/models-tools.js +15 -2
- package/dist/note-folder-tools.js +11 -5
- package/dist/notes-account-tools.js +2 -2
- package/dist/notes-tools.js +11 -4
- package/dist/ops-tools.js +40 -6
- package/dist/server.d.ts +8 -2
- package/dist/server.js +30 -4
- package/dist/settings-tools.js +35 -8
- package/dist/tool-catalog.d.ts +32 -0
- package/dist/tool-catalog.js +152 -0
- package/dist/tool-result.d.ts +52 -1
- package/dist/tool-result.js +92 -2
- package/dist/usage-tools.js +40 -8
- package/dist/verb-census.js +2 -0
- package/dist/work-tools.js +48 -19
- package/package.json +1 -1
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every `bli-tower` tool, sorted by what calling it DOES (BLI-10432).
|
|
3
|
+
*
|
|
4
|
+
* `npm run mcp:smoke` calls every READ and every DRY-RUN against production
|
|
5
|
+
* and never a WRITE; `tool-catalog.test.ts` fails when a registered tool is in
|
|
6
|
+
* none of the three lists, so a new tool joins the smoke (or is declared a
|
|
7
|
+
* write) the day it is registered, not the day somebody notices.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately dependency-free: `scripts/smoke.ts` imports this file straight
|
|
10
|
+
* from `src/` under Node's type stripping, so it may import nothing.
|
|
11
|
+
*/
|
|
12
|
+
/** Tools that change nothing. The smoke calls every one. */
|
|
13
|
+
export const READ_TOOLS = [
|
|
14
|
+
"get_ticket_timeline",
|
|
15
|
+
"get_active_tickets",
|
|
16
|
+
"docs_list",
|
|
17
|
+
"docs_tree",
|
|
18
|
+
"docs_read",
|
|
19
|
+
"docs_sync_status",
|
|
20
|
+
"msg_channels",
|
|
21
|
+
"msg_read",
|
|
22
|
+
"msg_thread",
|
|
23
|
+
"work_list_issues",
|
|
24
|
+
"work_get_issue",
|
|
25
|
+
"work_issue_history",
|
|
26
|
+
"work_list_projects",
|
|
27
|
+
"careers_list",
|
|
28
|
+
"careers_show",
|
|
29
|
+
"careers_resume_url",
|
|
30
|
+
"careers_takehome_show",
|
|
31
|
+
"careers_settings_show",
|
|
32
|
+
"usage_people",
|
|
33
|
+
"usage_sessions",
|
|
34
|
+
"mail_accounts",
|
|
35
|
+
"mail_inbox",
|
|
36
|
+
"mail_read",
|
|
37
|
+
"mail_search",
|
|
38
|
+
"mail_attachment",
|
|
39
|
+
"cal_today",
|
|
40
|
+
"cal_week",
|
|
41
|
+
"cal_next",
|
|
42
|
+
"cal_find",
|
|
43
|
+
"cal_calendars",
|
|
44
|
+
"jarvis_ask",
|
|
45
|
+
"jarvis_trace",
|
|
46
|
+
"jarvis_check",
|
|
47
|
+
"models_list",
|
|
48
|
+
"models_card",
|
|
49
|
+
"models_compare",
|
|
50
|
+
"brief_read",
|
|
51
|
+
"brief_history",
|
|
52
|
+
"brief_status",
|
|
53
|
+
"notes_folders",
|
|
54
|
+
"notes_list",
|
|
55
|
+
"notes_show",
|
|
56
|
+
"notes_shelf",
|
|
57
|
+
"notes_shelves",
|
|
58
|
+
"notes_place",
|
|
59
|
+
"notes_accounts",
|
|
60
|
+
"ops_status",
|
|
61
|
+
"slack_coverage",
|
|
62
|
+
"slack_read",
|
|
63
|
+
"memory_profile",
|
|
64
|
+
"memory_consolidation",
|
|
65
|
+
"settings_show",
|
|
66
|
+
"settings_list",
|
|
67
|
+
"model_show",
|
|
68
|
+
"team_members",
|
|
69
|
+
"team_device_list",
|
|
70
|
+
"scout_board",
|
|
71
|
+
"workbook_read",
|
|
72
|
+
"tower_search",
|
|
73
|
+
];
|
|
74
|
+
/**
|
|
75
|
+
* Tools that write, but answer a dry run that writes nothing. The smoke calls
|
|
76
|
+
* each one ONLY with its dry-run flag set (`scripts/smoke.ts` says which).
|
|
77
|
+
*/
|
|
78
|
+
export const DRY_RUN_TOOLS = ["ops_recompile", "memory_requeue"];
|
|
79
|
+
/**
|
|
80
|
+
* Tools that change real data: send mail, post messages, file issues, move
|
|
81
|
+
* candidates, share calendars. Never called by the smoke; covered by the unit
|
|
82
|
+
* suites (`write-tools.test.ts` and each family's own).
|
|
83
|
+
*/
|
|
84
|
+
export const WRITE_TOOLS = [
|
|
85
|
+
"emit_event",
|
|
86
|
+
"docs_create",
|
|
87
|
+
"docs_update",
|
|
88
|
+
"docs_link",
|
|
89
|
+
"msg_create_channel",
|
|
90
|
+
"msg_dm",
|
|
91
|
+
"msg_send",
|
|
92
|
+
"work_create_issue",
|
|
93
|
+
"work_update_issue",
|
|
94
|
+
"work_move_issue",
|
|
95
|
+
"work_comment_issue",
|
|
96
|
+
"careers_rescreen",
|
|
97
|
+
"careers_send_takehome",
|
|
98
|
+
"careers_invite",
|
|
99
|
+
"careers_grade",
|
|
100
|
+
"careers_decide",
|
|
101
|
+
"careers_takehome_set",
|
|
102
|
+
"careers_settings_set",
|
|
103
|
+
"mail_send",
|
|
104
|
+
"mail_sync",
|
|
105
|
+
"mail_detach",
|
|
106
|
+
"cal_create",
|
|
107
|
+
"cal_share",
|
|
108
|
+
"cal_sync",
|
|
109
|
+
"cal_detach",
|
|
110
|
+
"jarvis_dispatch",
|
|
111
|
+
"notes_mkdir",
|
|
112
|
+
"notes_rmdir",
|
|
113
|
+
"notes_rename",
|
|
114
|
+
"notes_upload",
|
|
115
|
+
"notes_paste",
|
|
116
|
+
"notes_share",
|
|
117
|
+
"notes_unshare",
|
|
118
|
+
"notes_move",
|
|
119
|
+
"notes_sync",
|
|
120
|
+
"notes_detach",
|
|
121
|
+
"memory_experience",
|
|
122
|
+
"scout_start",
|
|
123
|
+
"scout_dismiss",
|
|
124
|
+
"scout_undo",
|
|
125
|
+
"brief_rewrite",
|
|
126
|
+
"brief_correct",
|
|
127
|
+
"brief_done",
|
|
128
|
+
"brief_undone",
|
|
129
|
+
"settings_set",
|
|
130
|
+
"settings_delete",
|
|
131
|
+
"model_set",
|
|
132
|
+
"team_invite",
|
|
133
|
+
"team_role",
|
|
134
|
+
"team_device_revoke",
|
|
135
|
+
];
|
|
136
|
+
/** Reads that spend a model turn. The smoke's `--no-model` leaves these out. */
|
|
137
|
+
export const MODEL_SPENDING_TOOLS = ["jarvis_ask", "jarvis_check"];
|
|
138
|
+
/**
|
|
139
|
+
* Reads whose answer IS one whole item (a document, a transcript, a thread),
|
|
140
|
+
* so its size is the item's and not a list the caller failed to narrow. The
|
|
141
|
+
* smoke does not hold these to the per-half budget.
|
|
142
|
+
*/
|
|
143
|
+
export const SINGLE_ITEM_TOOLS = [
|
|
144
|
+
"docs_read",
|
|
145
|
+
"notes_show",
|
|
146
|
+
"mail_read",
|
|
147
|
+
"workbook_read",
|
|
148
|
+
"jarvis_trace",
|
|
149
|
+
"models_card",
|
|
150
|
+
"careers_show",
|
|
151
|
+
"memory_profile",
|
|
152
|
+
];
|
package/dist/tool-result.d.ts
CHANGED
|
@@ -36,6 +36,37 @@ export interface DoorSession {
|
|
|
36
36
|
deviceToken: string;
|
|
37
37
|
}
|
|
38
38
|
export declare function textResult(text: string, structured?: Record<string, unknown>): ToolResult;
|
|
39
|
+
/**
|
|
40
|
+
* A result whose TEXT half carries the data, not just a headline (BLI-10432).
|
|
41
|
+
*
|
|
42
|
+
* MCP clients do not agree on which half of a tool result the model reads.
|
|
43
|
+
* Claude Code, claude.ai and Claude Desktop hand the model `content` and
|
|
44
|
+
* render `structuredContent` for the person (or drop it); Codex hands the
|
|
45
|
+
* model `structuredContent` and drops `content`. So a tool that answered
|
|
46
|
+
* `"5 document(s)."` in text and the rows in `structuredContent` was blind on
|
|
47
|
+
* every Claude surface: the model was told five documents exist and never
|
|
48
|
+
* given one id. Every read is written so EACH half stands alone. Use this when
|
|
49
|
+
* there is no terser rendering worth writing by hand: the headline, then the
|
|
50
|
+
* structured half as one line of compact JSON.
|
|
51
|
+
*/
|
|
52
|
+
export declare function dataResult(headline: string, structured: Record<string, unknown>): ToolResult;
|
|
53
|
+
/**
|
|
54
|
+
* The per-half byte budget a DEFAULT read is held to (BLI-10432): about 8k
|
|
55
|
+
* tokens. A list over it names how to narrow instead of shipping everything;
|
|
56
|
+
* a single-item read (one document, one transcript) may exceed it because the
|
|
57
|
+
* item IS what was asked for. `scripts/smoke.ts` measures every read against
|
|
58
|
+
* it and the per-call log below warns when a result crosses it.
|
|
59
|
+
*/
|
|
60
|
+
export declare const RESULT_BUDGET_BYTES: number;
|
|
61
|
+
/**
|
|
62
|
+
* The page a list returns when the caller named no `limit` (BLI-10432).
|
|
63
|
+
* `work_list_issues` with no limit returned 1,000 issues WITH descriptions,
|
|
64
|
+
* 4.2 MB, which no agent can afford; a list now answers a page and says when
|
|
65
|
+
* more exist.
|
|
66
|
+
*/
|
|
67
|
+
export declare const DEFAULT_LIST_LIMIT = 50;
|
|
68
|
+
/** "…, and more exist — pass a larger limit (up to N) or narrow the filter." */
|
|
69
|
+
export declare function moreNote(more: boolean, max: number): string;
|
|
39
70
|
export declare function errorResult(text: string): ToolResult;
|
|
40
71
|
/**
|
|
41
72
|
* The session is loaded fresh per call (a cheap file read) rather than once at
|
|
@@ -63,7 +94,16 @@ export type RegisterTool = (name: string, config: {
|
|
|
63
94
|
description: string;
|
|
64
95
|
inputSchema: Record<string, unknown>;
|
|
65
96
|
}, handler: (args: Record<string, unknown>) => Promise<ToolResult>) => unknown;
|
|
66
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* The one cast, so no tool family repeats it — and the one place every
|
|
99
|
+
* agent-door call is logged (BLI-10432): tool, outcome, milliseconds and the
|
|
100
|
+
* byte size of each half. Metadata only, never an argument or a body. A result
|
|
101
|
+
* over `RESULT_BUDGET_BYTES` logs as a warning so an oversized read names
|
|
102
|
+
* itself in the client's MCP log instead of silently eating a context window.
|
|
103
|
+
* `BLI_MCP_QUIET_TOOL_LOG=1` silences the answered and refused lines (the unit
|
|
104
|
+
* suites set it, since they exercise refusals on purpose); a handler that
|
|
105
|
+
* threw always prints.
|
|
106
|
+
*/
|
|
67
107
|
export declare function registrarFor(server: {
|
|
68
108
|
registerTool: (...args: never[]) => unknown;
|
|
69
109
|
}): RegisterTool;
|
|
@@ -93,5 +133,16 @@ export declare function queryString(params: URLSearchParams): string;
|
|
|
93
133
|
* `server.test.ts` walks every registered tool's shape for exactly this.
|
|
94
134
|
*/
|
|
95
135
|
export declare const CONFIRM_INPUT: z.ZodOptional<z.ZodBoolean>;
|
|
136
|
+
/**
|
|
137
|
+
* An email address, in a schema every client can compile (BLI-10432).
|
|
138
|
+
*
|
|
139
|
+
* `z.string().email()` publishes zod's own address pattern, which opens with
|
|
140
|
+
* two negative lookaheads (`^(?!\.)(?!.*\.\.)…`). Lookaround is not RE2, and
|
|
141
|
+
* RE2 is what OpenAI's strict tool schemas and Google's function declarations
|
|
142
|
+
* compile patterns with, so one `mail_send` in the tool list was enough for a
|
|
143
|
+
* strict client to refuse the whole server. This pattern is lookaround-free;
|
|
144
|
+
* the mail and calendar doors validate the address again on the server.
|
|
145
|
+
*/
|
|
146
|
+
export declare const EMAIL_INPUT: z.ZodString;
|
|
96
147
|
/** `null` when the caller confirmed; the refusal itself when they did not. */
|
|
97
148
|
export declare function unconfirmed(args: Record<string, unknown>, sentence: string): ToolResult | null;
|
package/dist/tool-result.js
CHANGED
|
@@ -18,6 +18,41 @@ import { loadAgentDoorSession } from "./agent-door-session.js";
|
|
|
18
18
|
export function textResult(text, structured) {
|
|
19
19
|
return { content: [{ type: "text", text }], ...(structured ? { structuredContent: structured } : {}) };
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* A result whose TEXT half carries the data, not just a headline (BLI-10432).
|
|
23
|
+
*
|
|
24
|
+
* MCP clients do not agree on which half of a tool result the model reads.
|
|
25
|
+
* Claude Code, claude.ai and Claude Desktop hand the model `content` and
|
|
26
|
+
* render `structuredContent` for the person (or drop it); Codex hands the
|
|
27
|
+
* model `structuredContent` and drops `content`. So a tool that answered
|
|
28
|
+
* `"5 document(s)."` in text and the rows in `structuredContent` was blind on
|
|
29
|
+
* every Claude surface: the model was told five documents exist and never
|
|
30
|
+
* given one id. Every read is written so EACH half stands alone. Use this when
|
|
31
|
+
* there is no terser rendering worth writing by hand: the headline, then the
|
|
32
|
+
* structured half as one line of compact JSON.
|
|
33
|
+
*/
|
|
34
|
+
export function dataResult(headline, structured) {
|
|
35
|
+
return textResult(`${headline}\n${JSON.stringify(structured)}`, structured);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The per-half byte budget a DEFAULT read is held to (BLI-10432): about 8k
|
|
39
|
+
* tokens. A list over it names how to narrow instead of shipping everything;
|
|
40
|
+
* a single-item read (one document, one transcript) may exceed it because the
|
|
41
|
+
* item IS what was asked for. `scripts/smoke.ts` measures every read against
|
|
42
|
+
* it and the per-call log below warns when a result crosses it.
|
|
43
|
+
*/
|
|
44
|
+
export const RESULT_BUDGET_BYTES = 32 * 1024;
|
|
45
|
+
/**
|
|
46
|
+
* The page a list returns when the caller named no `limit` (BLI-10432).
|
|
47
|
+
* `work_list_issues` with no limit returned 1,000 issues WITH descriptions,
|
|
48
|
+
* 4.2 MB, which no agent can afford; a list now answers a page and says when
|
|
49
|
+
* more exist.
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_LIST_LIMIT = 50;
|
|
52
|
+
/** "…, and more exist — pass a larger limit (up to N) or narrow the filter." */
|
|
53
|
+
export function moreNote(more, max) {
|
|
54
|
+
return more ? ` More exist — pass a larger limit (up to ${max}) or narrow the filter.` : "";
|
|
55
|
+
}
|
|
21
56
|
export function errorResult(text) {
|
|
22
57
|
return { isError: true, content: [{ type: "text", text }] };
|
|
23
58
|
}
|
|
@@ -69,9 +104,49 @@ function doorSentence(body, status) {
|
|
|
69
104
|
const said = [headline ?? message, ...lines].filter(Boolean);
|
|
70
105
|
return said.length > 0 ? said.join(" ") : `Tower answered ${status}.`;
|
|
71
106
|
}
|
|
72
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* The one cast, so no tool family repeats it — and the one place every
|
|
109
|
+
* agent-door call is logged (BLI-10432): tool, outcome, milliseconds and the
|
|
110
|
+
* byte size of each half. Metadata only, never an argument or a body. A result
|
|
111
|
+
* over `RESULT_BUDGET_BYTES` logs as a warning so an oversized read names
|
|
112
|
+
* itself in the client's MCP log instead of silently eating a context window.
|
|
113
|
+
* `BLI_MCP_QUIET_TOOL_LOG=1` silences the answered and refused lines (the unit
|
|
114
|
+
* suites set it, since they exercise refusals on purpose); a handler that
|
|
115
|
+
* threw always prints.
|
|
116
|
+
*/
|
|
73
117
|
export function registrarFor(server) {
|
|
74
|
-
|
|
118
|
+
const register = server.registerTool.bind(server);
|
|
119
|
+
return (name, config, handler) => register(name, config, async (args) => {
|
|
120
|
+
const started = Date.now();
|
|
121
|
+
let result;
|
|
122
|
+
try {
|
|
123
|
+
result = await handler(args);
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
// A handler that throws would surface as a bare protocol error with no
|
|
127
|
+
// reason label; name it, log it, and answer by name instead.
|
|
128
|
+
const reason = error instanceof Error ? error.message.split("\n")[0] ?? "unknown" : String(error);
|
|
129
|
+
logToolCall("error", { tool: name, outcome: "threw", ms: Date.now() - started, reason: reason.slice(0, 200) });
|
|
130
|
+
return errorResult(`${name} failed inside the bli-tower server (tool_threw): ${reason}`);
|
|
131
|
+
}
|
|
132
|
+
const textBytes = Buffer.byteLength(result.content.map((part) => part.text).join(""));
|
|
133
|
+
const structuredBytes = result.structuredContent ? Buffer.byteLength(JSON.stringify(result.structuredContent)) : 0;
|
|
134
|
+
const oversized = Math.max(textBytes, structuredBytes) > RESULT_BUDGET_BYTES;
|
|
135
|
+
logToolCall(result.isError || oversized ? "warn" : "info", {
|
|
136
|
+
tool: name,
|
|
137
|
+
outcome: result.isError ? "refused" : oversized ? "answered_oversized" : "answered",
|
|
138
|
+
ms: Date.now() - started,
|
|
139
|
+
text_bytes: textBytes,
|
|
140
|
+
structured_bytes: structuredBytes,
|
|
141
|
+
});
|
|
142
|
+
return result;
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function logToolCall(level, fields) {
|
|
146
|
+
if (level !== "error" && process.env.BLI_MCP_QUIET_TOOL_LOG === "1")
|
|
147
|
+
return;
|
|
148
|
+
// stdout is the MCP transport; every log line goes to stderr.
|
|
149
|
+
process.stderr.write(`[bli-cockpit-mcp tool] ${level} ${JSON.stringify(fields)}\n`);
|
|
75
150
|
}
|
|
76
151
|
/** `?a=1&b=2`, or "" when nothing was asked for. Never a bare `?`. */
|
|
77
152
|
export function queryString(params) {
|
|
@@ -108,6 +183,21 @@ export const CONFIRM_INPUT = z
|
|
|
108
183
|
.boolean()
|
|
109
184
|
.optional()
|
|
110
185
|
.describe("Must be true. This act is deliberate and is refused without it — the same rule as the CLI's --yes.");
|
|
186
|
+
/**
|
|
187
|
+
* An email address, in a schema every client can compile (BLI-10432).
|
|
188
|
+
*
|
|
189
|
+
* `z.string().email()` publishes zod's own address pattern, which opens with
|
|
190
|
+
* two negative lookaheads (`^(?!\.)(?!.*\.\.)…`). Lookaround is not RE2, and
|
|
191
|
+
* RE2 is what OpenAI's strict tool schemas and Google's function declarations
|
|
192
|
+
* compile patterns with, so one `mail_send` in the tool list was enough for a
|
|
193
|
+
* strict client to refuse the whole server. This pattern is lookaround-free;
|
|
194
|
+
* the mail and calendar doors validate the address again on the server.
|
|
195
|
+
*/
|
|
196
|
+
export const EMAIL_INPUT = z
|
|
197
|
+
.string()
|
|
198
|
+
.min(3)
|
|
199
|
+
.max(320)
|
|
200
|
+
.regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/u, "an email address");
|
|
111
201
|
/** `null` when the caller confirmed; the refusal itself when they did not. */
|
|
112
202
|
export function unconfirmed(args, sentence) {
|
|
113
203
|
if (args.confirm === true)
|
package/dist/usage-tools.js
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { callAgentDoor } from "./agent-door.js";
|
|
3
|
-
import { doorFailureText, errorResult, registrarFor,
|
|
3
|
+
import { dataResult, doorFailureText, errorResult, registrarFor, withSession } from "./tool-result.js";
|
|
4
|
+
// Both reads answer with `dataResult`: the text half used to carry only the
|
|
5
|
+
// coverage sentence, so a Claude-family client (which shows the model the text
|
|
6
|
+
// half alone) could say how many sessions were counted and never whose they
|
|
7
|
+
// were or what they cost (BLI-10432).
|
|
8
|
+
const WINDOW_BOUND = "<n>d (1 to 365), <n>h (1 to 8760), or an ISO timestamp";
|
|
4
9
|
export function registerUsageTools(server, deps) {
|
|
5
10
|
registrarFor(server)("usage_people", {
|
|
6
11
|
title: "Read usage by person",
|
|
7
|
-
description: "Per-person Claude Code and Codex token usage, list-price equivalent, and extraction coverage.",
|
|
8
|
-
inputSchema: {
|
|
12
|
+
description: "Per-person Claude Code and Codex token usage, list-price equivalent (not actual spend), and extraction coverage, over a window (default the last 30 days). groupBy adds a per-person breakdown; groupBy topic or ticket lists the labels usage_sessions takes as subject or task.",
|
|
13
|
+
inputSchema: {
|
|
14
|
+
minConfidence: z.enum(["high", "medium", "low"]).optional().describe("With groupBy task_type or topic: only labels at or above this confidence."),
|
|
15
|
+
groupBy: z.enum(["repo", "task_type", "topic", "ticket"]).optional().describe("Break each person's usage down by repo, task type, topic or ticket."),
|
|
16
|
+
since: z.string().min(1).max(40).optional().describe(`${WINDOW_BOUND}. Default 30d; invalid values return invalid_usage_window.`),
|
|
17
|
+
until: z.string().min(1).max(40).optional().describe(`${WINDOW_BOUND}. Default now.`),
|
|
18
|
+
include_automated: z.boolean().optional().describe("Count automated (scripted, non-interactive) sessions too. Off by default."),
|
|
19
|
+
},
|
|
9
20
|
}, async (args) => withSession(deps, async (session) => {
|
|
10
21
|
const query = new URLSearchParams({ since: String(args.since ?? "30d") });
|
|
11
22
|
if (args.until)
|
|
@@ -32,15 +43,31 @@ export function registerUsageTools(server, deps) {
|
|
|
32
43
|
const priceLabel = typeof response.body.api_list_price_equivalent_label === "string"
|
|
33
44
|
? ` ${response.body.api_list_price_equivalent_label}.`
|
|
34
45
|
: "";
|
|
35
|
-
return
|
|
46
|
+
return dataResult(`${coverage?.sessions_extracted ?? 0} of ${coverage?.sessions_observed ?? 0} sessions extracted.${why} ${coverage?.sessions_counted_at_upload ?? 0} of ${coverage?.sessions_observed ?? 0} sessions counted at upload, ${coverage?.sessions_counted_by_server ?? 0} by server backfill, ${coverage?.sessions_counted_by_ladder ?? 0} by the ladder.${args.groupBy === "task_type" ? ` ${coverage?.sessions_labelled ?? 0} of ${coverage?.sessions_observed ?? 0} sessions labelled by topic.` : ""}${priceLabel}`, response.body);
|
|
36
47
|
}));
|
|
37
48
|
registrarFor(server)("usage_sessions", {
|
|
38
49
|
title: "Read task or subject sessions",
|
|
39
|
-
description: "
|
|
40
|
-
inputSchema: {
|
|
50
|
+
description: "The sessions behind one ticket or one topic: titles, tokens and work types. Name exactly one of task (a BLI-#### id) or subject (a TOPIC label, as usage_people with groupBy topic lists it; not a person). Or pass session (a session id) alone to read that session's digest windows, ten at a time from fromWindow. This read can take 25 seconds.",
|
|
51
|
+
inputSchema: {
|
|
52
|
+
task: z.string().regex(/^BLI-[0-9]+$/).optional().describe("A ticket id, e.g. BLI-4258: the sessions labelled with it."),
|
|
53
|
+
subject: z.string().min(1).max(300).optional().describe("A topic label exactly as usage_people groupBy topic prints it. Not a person or an email."),
|
|
54
|
+
repo: z.string().min(1).max(200).optional().describe("Only sessions in this repo label."),
|
|
55
|
+
person: z.string().min(1).max(64).optional().describe("Only this person's sessions, by user id (usage_people user_id)."),
|
|
56
|
+
subtopic: z.string().min(1).max(300).optional().describe("Only sessions with this subtopic label."),
|
|
57
|
+
since: z.string().min(1).max(40).optional().describe(`${WINDOW_BOUND}. Default 30d.`),
|
|
58
|
+
until: z.string().min(1).max(40).optional().describe(`${WINDOW_BOUND}. Default now.`),
|
|
59
|
+
include_automated: z.boolean().optional().describe("Count automated sessions too. Off by default."),
|
|
60
|
+
session: z.string().min(1).max(200).optional().describe("A session id: read its digest windows instead of listing sessions."),
|
|
61
|
+
fromWindow: z.number().int().min(1).optional().describe("With session: the first window number to read. Default 1."),
|
|
62
|
+
},
|
|
41
63
|
}, async (args) => withSession(deps, async (session) => {
|
|
64
|
+
// The door answers a bare 400 `subject_and_valid_window_required` when
|
|
65
|
+
// neither is named; say what to pass instead of "Tower answered 400".
|
|
66
|
+
if (args.session === undefined && args.task === undefined && (typeof args.subject !== "string" || args.subject.trim() === "")) {
|
|
67
|
+
return errorResult("usage_sessions needs task (a BLI-#### id), subject (a topic label from usage_people groupBy topic) or session (a session id) (task_or_subject_required). Nothing was read.");
|
|
68
|
+
}
|
|
42
69
|
const query = new URLSearchParams();
|
|
43
|
-
for (const key of ["task", "subject", "repo", "since", "until", "session", "fromWindow"])
|
|
70
|
+
for (const key of ["task", "subject", "repo", "person", "subtopic", "since", "until", "session", "fromWindow"])
|
|
44
71
|
if (args[key] !== undefined)
|
|
45
72
|
query.set(key, String(args[key]));
|
|
46
73
|
if (args.include_automated)
|
|
@@ -48,6 +75,11 @@ export function registerUsageTools(server, deps) {
|
|
|
48
75
|
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/usage/sessions?${query}`);
|
|
49
76
|
if (!response.ok)
|
|
50
77
|
return errorResult(doorFailureText("usage_sessions", response));
|
|
51
|
-
|
|
78
|
+
if (args.session !== undefined) {
|
|
79
|
+
const windows = Array.isArray(response.body.windows) ? response.body.windows.length : 0;
|
|
80
|
+
return dataResult(`${windows} digest window(s) of session ${String(args.session)}.`, response.body);
|
|
81
|
+
}
|
|
82
|
+
const sessions = Array.isArray(response.body.sessions) ? response.body.sessions.length : 0;
|
|
83
|
+
return dataResult(`${sessions} session(s) for ${args.task ? `task ${String(args.task)}` : `topic "${String(args.subject)}"`}.`, response.body);
|
|
52
84
|
}));
|
|
53
85
|
}
|
package/dist/verb-census.js
CHANGED
|
@@ -234,6 +234,8 @@ export const MCP_TWINS = {
|
|
|
234
234
|
"docs read": { tool: "docs_read", door: "GET /api/docs/documents/[id]" },
|
|
235
235
|
"docs create": { tool: "docs_create", door: "POST /api/docs/documents" },
|
|
236
236
|
"docs update": { tool: "docs_update", door: "PATCH /api/docs/documents/[id]" },
|
|
237
|
+
"docs link": { tool: "docs_link", door: "PUT /api/docs/documents/[id]/notion-link" },
|
|
238
|
+
"docs sync-status": { tool: "docs_sync_status", door: "GET /api/docs/notion-sync" },
|
|
237
239
|
"msg channels": { tool: "msg_channels", door: "GET /api/msg/channels" },
|
|
238
240
|
"msg create": { tool: "msg_create_channel", door: "POST /api/msg/channels" },
|
|
239
241
|
"msg dm": { tool: "msg_dm", door: "POST /api/msg/channels (dm)" },
|
package/dist/work-tools.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
import { callAgentDoor } from "./agent-door.js";
|
|
19
|
-
import { doorFailureText, errorResult, registrarFor, textResult, withSession, } from "./tool-result.js";
|
|
19
|
+
import { DEFAULT_LIST_LIMIT, doorFailureText, errorResult, moreNote, registrarFor, textResult, withSession, } from "./tool-result.js";
|
|
20
20
|
/** The closed state vocabulary, verbatim from `lib/work/api-doors.ts` `WORK_ISSUE_STATES`. */
|
|
21
21
|
export const WORK_ISSUE_STATES = [
|
|
22
22
|
"backlog",
|
|
@@ -26,6 +26,27 @@ export const WORK_ISSUE_STATES = [
|
|
|
26
26
|
"done",
|
|
27
27
|
"canceled",
|
|
28
28
|
];
|
|
29
|
+
/** `/api/work/issues`' own ceiling, and the most a caller may ask for here. */
|
|
30
|
+
const ISSUE_LIST_MAX = 1000;
|
|
31
|
+
/**
|
|
32
|
+
* The fields `work_list_issues` promises. The door's row carries the whole
|
|
33
|
+
* description and a dozen bookkeeping columns; with no limit that was 1,000
|
|
34
|
+
* issues and 4.2 MB, 3.3 MB of it descriptions the tool said it never
|
|
35
|
+
* returned (BLI-10432). work_get_issue reads one issue in full.
|
|
36
|
+
*/
|
|
37
|
+
function issueListRow(row) {
|
|
38
|
+
return {
|
|
39
|
+
id: row.id,
|
|
40
|
+
identifier: row.identifier,
|
|
41
|
+
title: row.title,
|
|
42
|
+
state: row.state,
|
|
43
|
+
priority: row.priority ?? null,
|
|
44
|
+
assignee_id: row.assignee_id ?? null,
|
|
45
|
+
project_id: row.project_id ?? null,
|
|
46
|
+
parent_id: row.parent_id ?? null,
|
|
47
|
+
updated_at: row.updated_at ?? null,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
29
50
|
/**
|
|
30
51
|
* A row the Linear import could not give state names to says which it is.
|
|
31
52
|
* Printing "(none) -> (none)" is honest and useless; naming the gap keeps
|
|
@@ -57,13 +78,15 @@ export function registerWorkTools(server, deps) {
|
|
|
57
78
|
const register = registrarFor(server);
|
|
58
79
|
register("work_list_issues", {
|
|
59
80
|
title: "List Tower issues",
|
|
60
|
-
description: "Issues you may see, most recently updated first
|
|
61
|
-
+ "
|
|
81
|
+
description: "Issues you may see, most recently updated first: identifier, state, title, priority, assignee, project. "
|
|
82
|
+
+ "Filter by state, assignee (\"me\", \"unassigned\", or a uuid) or project (name or uuid). "
|
|
83
|
+
+ `Default ${DEFAULT_LIST_LIMIT} issues; the answer says when more exist. `
|
|
84
|
+
+ "Never a description — call work_get_issue for one issue's full text.",
|
|
62
85
|
inputSchema: {
|
|
63
|
-
state: z.enum(WORK_ISSUE_STATES).optional(),
|
|
86
|
+
state: z.enum(WORK_ISSUE_STATES).optional().describe("Only issues in this state."),
|
|
64
87
|
assignee: z.string().min(1).max(100).optional().describe('"me", "unassigned", or a person\'s uuid.'),
|
|
65
88
|
project: z.string().min(1).max(200).optional().describe("A project name or uuid."),
|
|
66
|
-
limit: z.number().int().min(1).max(
|
|
89
|
+
limit: z.number().int().min(1).max(ISSUE_LIST_MAX).optional().describe(`At most this many issues. Default ${DEFAULT_LIST_LIMIT}.`),
|
|
67
90
|
},
|
|
68
91
|
}, async (args) => withSession(deps, async (session) => {
|
|
69
92
|
const query = new URLSearchParams();
|
|
@@ -71,8 +94,9 @@ export function registerWorkTools(server, deps) {
|
|
|
71
94
|
query.set("state", String(args.state));
|
|
72
95
|
if (args.assignee)
|
|
73
96
|
query.set("assignee_id", String(args.assignee));
|
|
74
|
-
|
|
75
|
-
|
|
97
|
+
// No limit named: one page plus one row, so "more exist" is read off
|
|
98
|
+
// the door rather than guessed from a full page.
|
|
99
|
+
query.set("limit", String(args.limit ?? DEFAULT_LIST_LIMIT + 1));
|
|
76
100
|
if (args.project) {
|
|
77
101
|
const resolved = await resolveProjectId(deps, session, String(args.project));
|
|
78
102
|
if (resolved.status === "list_failed")
|
|
@@ -86,9 +110,14 @@ export function registerWorkTools(server, deps) {
|
|
|
86
110
|
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/issues${suffix ? `?${suffix}` : ""}`);
|
|
87
111
|
if (!response.ok)
|
|
88
112
|
return errorResult(doorFailureText("work_list_issues", response));
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
113
|
+
const all = (Array.isArray(response.body.issues) ? response.body.issues : []);
|
|
114
|
+
const more = args.limit === undefined && all.length > DEFAULT_LIST_LIMIT;
|
|
115
|
+
const issues = more ? all.slice(0, DEFAULT_LIST_LIMIT) : all;
|
|
116
|
+
const lines = issues
|
|
117
|
+
.map((issue) => `${issue.identifier} ${issue.state} ${issue.title}`
|
|
118
|
+
+ ` [p${issue.priority ?? 0}, assignee ${issue.assignee_id ?? "none"}, project ${issue.project_id ?? "none"}]`)
|
|
119
|
+
.join("\n");
|
|
120
|
+
return textResult(`${issues.length} issue(s).${moreNote(more, ISSUE_LIST_MAX)}${lines ? `\n${lines}` : ""}`, { issues: issues.map(issueListRow), more });
|
|
92
121
|
}));
|
|
93
122
|
register("work_get_issue", {
|
|
94
123
|
title: "Read a Tower issue",
|
|
@@ -114,8 +143,8 @@ export function registerWorkTools(server, deps) {
|
|
|
114
143
|
title: "File a Tower issue",
|
|
115
144
|
description: "Files a new issue and returns the BLI-#### identifier it was given. Use this the way you would file a Linear ticket.",
|
|
116
145
|
inputSchema: {
|
|
117
|
-
title: z.string().min(1).max(300),
|
|
118
|
-
description: z.string().max(200_000).optional(),
|
|
146
|
+
title: z.string().min(1).max(300).describe("The issue title."),
|
|
147
|
+
description: z.string().max(200_000).optional().describe("The issue body, markdown."),
|
|
119
148
|
project: z.string().min(1).max(200).optional().describe("A project name or uuid."),
|
|
120
149
|
priority: z.number().int().min(0).max(4).optional().describe("0 none, 1 urgent, 2 high, 3 medium, 4 low."),
|
|
121
150
|
assignee: z.string().min(1).max(100).optional().describe('"me" or a person\'s uuid.'),
|
|
@@ -149,9 +178,9 @@ export function registerWorkTools(server, deps) {
|
|
|
149
178
|
+ "State does NOT move here — call work_move_issue, which records the move.",
|
|
150
179
|
inputSchema: {
|
|
151
180
|
id: ISSUE_REF,
|
|
152
|
-
title: z.string().min(1).max(300).optional(),
|
|
153
|
-
description: z.string().max(200_000).nullable().optional(),
|
|
154
|
-
priority: z.number().int().min(0).max(4).optional(),
|
|
181
|
+
title: z.string().min(1).max(300).optional().describe("A new title."),
|
|
182
|
+
description: z.string().max(200_000).nullable().optional().describe("The WHOLE new body, markdown; null clears it."),
|
|
183
|
+
priority: z.number().int().min(0).max(4).optional().describe("0 none, 1 urgent, 2 high, 3 medium, 4 low."),
|
|
155
184
|
assignee: z.string().min(1).max(100).nullable().optional().describe('"me", a uuid, or null to unassign.'),
|
|
156
185
|
project: z.string().min(1).max(200).optional().describe("A project name or uuid."),
|
|
157
186
|
},
|
|
@@ -183,7 +212,7 @@ export function registerWorkTools(server, deps) {
|
|
|
183
212
|
title: "Move a Tower issue",
|
|
184
213
|
description: "Moves an issue to a new state and records the move in its history. This is how an issue's state changes; "
|
|
185
214
|
+ "there is no other door.",
|
|
186
|
-
inputSchema: { id: ISSUE_REF, state: z.enum(WORK_ISSUE_STATES) },
|
|
215
|
+
inputSchema: { id: ISSUE_REF, state: z.enum(WORK_ISSUE_STATES).describe("The state to move it to.") },
|
|
187
216
|
}, async (args) => withSession(deps, async (session) => {
|
|
188
217
|
const ref = String(args.id ?? "");
|
|
189
218
|
const response = await callAgentDoor(session, deps.fetchImpl, "POST", `/api/work/issues/${encodeURIComponent(ref)}/state`, {
|
|
@@ -197,7 +226,7 @@ export function registerWorkTools(server, deps) {
|
|
|
197
226
|
register("work_comment_issue", {
|
|
198
227
|
title: "Comment on a Tower issue",
|
|
199
228
|
description: "Posts a comment. Comments are append-only — nothing here edits or deletes one.",
|
|
200
|
-
inputSchema: { id: ISSUE_REF, body_markdown: z.string().min(1).max(50_000) },
|
|
229
|
+
inputSchema: { id: ISSUE_REF, body_markdown: z.string().min(1).max(50_000).describe("The comment, markdown.") },
|
|
201
230
|
}, async (args) => withSession(deps, async (session) => {
|
|
202
231
|
const ref = String(args.id ?? "");
|
|
203
232
|
const response = await callAgentDoor(session, deps.fetchImpl, "POST", `/api/work/issues/${encodeURIComponent(ref)}/comments`, { body_markdown: args.body_markdown });
|
|
@@ -213,7 +242,7 @@ export function registerWorkTools(server, deps) {
|
|
|
213
242
|
+ "\"(none) -> (none)\".",
|
|
214
243
|
inputSchema: {
|
|
215
244
|
id: ISSUE_REF,
|
|
216
|
-
limit: z.number().int().min(1).max(500).optional(),
|
|
245
|
+
limit: z.number().int().min(1).max(500).optional().describe("At most this many changes."),
|
|
217
246
|
},
|
|
218
247
|
}, async (args) => withSession(deps, async (session) => {
|
|
219
248
|
const ref = String(args.id ?? "");
|
|
@@ -232,7 +261,7 @@ export function registerWorkTools(server, deps) {
|
|
|
232
261
|
register("work_list_projects", {
|
|
233
262
|
title: "List Tower projects",
|
|
234
263
|
description: "The projects issues are filed under. Use a project's name with work_list_issues or work_create_issue.",
|
|
235
|
-
inputSchema: { include_archived: z.boolean().optional() },
|
|
264
|
+
inputSchema: { include_archived: z.boolean().optional().describe("true: archived projects too.") },
|
|
236
265
|
}, async (args) => withSession(deps, async (session) => {
|
|
237
266
|
const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/projects${args.include_archived ? "?include_archived=true" : ""}`);
|
|
238
267
|
if (!response.ok)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.63",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "bli-tower: an MCP server over BLI Cockpit's agent doors: JARVIS (jarvis_*), documents (docs_*), channels (msg_*), issues (work_*), the daily page (brief_*), meeting notes (notes_*), the ops board (ops_status/slack_*), settings/team/model, Scout and the workbook, plus the legacy event-stream tools (emit_event, get_ticket_timeline, get_active_tickets).",
|
|
6
6
|
"type": "module",
|