@bli-cockpit/mcp 0.1.59 → 0.1.61

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.
@@ -1,11 +1,22 @@
1
1
  import { z } from "zod";
2
2
  import { callAgentDoor } from "./agent-door.js";
3
- import { doorFailureText, errorResult, registrarFor, textResult, withSession } from "./tool-result.js";
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: { minConfidence: z.enum(["high", "medium", "low"]).optional(), groupBy: z.enum(["repo", "task_type", "topic", "ticket"]).optional(), since: z.string().optional().describe("<n>d (1 to 365), <n>h (1 to 8760), or an ISO timestamp. Default 30d; invalid values return invalid_usage_window."), until: z.string().optional(), include_automated: z.boolean().optional() },
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 textResult(`${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);
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: "List task or subject sessions with titles, tokens and work types; read digest windows on demand with session.",
40
- inputSchema: { task: z.string().regex(/^BLI-[0-9]+$/).optional(), subject: z.string().optional(), repo: z.string().optional(), since: z.string().optional(), until: z.string().optional(), include_automated: z.boolean().optional(), session: z.string().optional(), fromWindow: z.number().int().positive().optional() },
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
- return textResult("Subject sessions and requested digest windows.", response.body);
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
  }
@@ -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. Filter by state, assignee (\"me\", \"unassigned\", or a uuid) "
61
- + "or project (name or uuid). Never a description — call work_get_issue for one issue's full text.",
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(1000).optional(),
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
- if (args.limit)
75
- query.set("limit", String(args.limit));
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 issues = (Array.isArray(response.body.issues) ? response.body.issues : []);
90
- const lines = issues.map((issue) => `${issue.identifier} ${issue.state} ${issue.title}`).join("\n");
91
- return textResult(`${issues.length} issue(s).${lines ? `\n${lines}` : ""}`, { issues });
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.59",
3
+ "version": "0.1.61",
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",