@bli-cockpit/mcp 0.1.69 → 0.1.70

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.
@@ -0,0 +1,22 @@
1
+ /** Flags `parseIssueArgs` accepts a VALUE for (`--flag value`), read from its own `valueFlags` array. */
2
+ export declare function issueValueFlags(): string[];
3
+ /** The bare field names (`--parent` → `parent`) `cockpit issue create`/`update` actually write. */
4
+ export declare function issueWriteFieldNames(): string[];
5
+ export declare const WORK_WRITE_VERBS: readonly ["issue create", "issue update"];
6
+ export type WorkWriteVerb = (typeof WORK_WRITE_VERBS)[number];
7
+ export declare const MCP_TOOL_FOR_WRITE_VERB: Record<WorkWriteVerb, string>;
8
+ /** The field names an MCP tool's REGISTERED zod shape actually carries. */
9
+ export declare function mcpToolFieldNames(toolName: string): string[];
10
+ /**
11
+ * A field on exactly one side ON PURPOSE, with why — a claim about shape,
12
+ * never a backlog (the same discipline `TERMINAL_ONLY` keeps in
13
+ * `verb-census.ts`). Anything asymmetric that is NOT listed here fails the
14
+ * test in `work-flag-census.test.ts`.
15
+ */
16
+ export interface OneSidedField {
17
+ verb: WorkWriteVerb;
18
+ side: "cli" | "mcp";
19
+ field: string;
20
+ reason: string;
21
+ }
22
+ export declare const EXPECTED_ONE_SIDED_FIELDS: OneSidedField[];
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Flag-level parity for `cockpit issue create|update` against their MCP
3
+ * twins, `work_create_issue`/`work_update_issue` (BLI-10575).
4
+ *
5
+ * `verb-census.ts` proves every VERB has a twin — it never looks at a verb's
6
+ * FLAGS, by its own header ("What it deliberately does NOT see"): the
7
+ * extraction there only ever reads a noun's `action` union, never a shape's
8
+ * own fields. That is exactly how `work_create_issue` shipped with no
9
+ * `parent` while `cockpit issue create --parent` already had one (BLI-3716)
10
+ * — the VERB existed on both sides, its FLAGS did not, and nothing checked
11
+ * because nothing looked. `--size`, added in this same ticket, would have
12
+ * been the next one to drift silently were this test not written alongside
13
+ * it.
14
+ *
15
+ * This reads both real sources rather than trusting a hand-written list on
16
+ * either side:
17
+ *
18
+ * - the collector's own `valueFlags` array for `parseIssueArgs`, a text
19
+ * read the same way `verb-census.ts` reads the collector (this package
20
+ * declares no dependency on it);
21
+ * - the MCP tool's REGISTERED zod shape — `createServer(...)`'s
22
+ * `_registeredTools[name].inputSchema.shape`, the same private field
23
+ * `server.test.ts` already introspects, not a text scan of the source.
24
+ *
25
+ * `cockpit issue` has ONE shared flag vocabulary across every verb (`list`,
26
+ * `create`, `update`, `move`, ... all parse from the same `valueFlags`
27
+ * array — `local-args-tower-work.ts` gates a flag's MEANING per action, not
28
+ * its presence), so the CLI side of this comparison is the same set for
29
+ * `create` and `update` alike; only the filter list below narrows it to the
30
+ * fields that name something an issue's ROW carries, as opposed to a filter
31
+ * (`--state`, `--limit`) or a global (`--home`, `--dashboard-url`).
32
+ */
33
+ import { readFileSync } from "node:fs";
34
+ import { dirname, resolve } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ import { createServer } from "./server.js";
37
+ const HERE = dirname(fileURLToPath(import.meta.url));
38
+ const WORK_ARGS_FILE = resolve(HERE, "../../cockpit-local-collector/src/commands/local-args-tower-work.ts");
39
+ /** Flags `parseIssueArgs` accepts a VALUE for (`--flag value`), read from its own `valueFlags` array. */
40
+ export function issueValueFlags() {
41
+ const source = readFileSync(WORK_ARGS_FILE, "utf8");
42
+ const start = source.indexOf("export function parseIssueArgs");
43
+ const end = source.indexOf("export type ProjectAction", start);
44
+ if (start < 0 || end < 0) {
45
+ throw new Error("parseIssueArgs (or the point where it ends, ProjectAction) moved in local-args-tower-work.ts — "
46
+ + "work-flag-census.ts's extraction needs updating alongside it.");
47
+ }
48
+ const slice = source.slice(start, end);
49
+ const valueFlagsStart = slice.indexOf("valueFlags: [");
50
+ if (valueFlagsStart < 0)
51
+ throw new Error("parseIssueArgs's own valueFlags array not found.");
52
+ const valueFlagsEnd = slice.indexOf("]", valueFlagsStart);
53
+ return [...slice.slice(valueFlagsStart, valueFlagsEnd).matchAll(/"(--[a-z-]+)"/g)].map((match) => match[1]);
54
+ }
55
+ /**
56
+ * Flags that name a filter, a global, or a transport knob rather than a
57
+ * FIELD on the issue row itself — never counted as a "write field" either
58
+ * side owes the other. `--file` carries the same DESCRIPTION the MCP tools
59
+ * take as a plain `description` argument; see `EXPECTED_ONE_SIDED` below for
60
+ * why that one stays a documented asymmetry rather than a shared name.
61
+ */
62
+ const NON_FIELD_FLAGS = new Set(["--home", "--dashboard-url", "--state", "--limit", "--file"]);
63
+ /** The bare field names (`--parent` → `parent`) `cockpit issue create`/`update` actually write. */
64
+ export function issueWriteFieldNames() {
65
+ return issueValueFlags()
66
+ .filter((flag) => !NON_FIELD_FLAGS.has(flag))
67
+ .map((flag) => flag.replace(/^--/, ""));
68
+ }
69
+ export const WORK_WRITE_VERBS = ["issue create", "issue update"];
70
+ export const MCP_TOOL_FOR_WRITE_VERB = {
71
+ "issue create": "work_create_issue",
72
+ "issue update": "work_update_issue",
73
+ };
74
+ /** The field names an MCP tool's REGISTERED zod shape actually carries. */
75
+ export function mcpToolFieldNames(toolName) {
76
+ const deps = {
77
+ resolveToken: async () => "token-not-used-by-registration",
78
+ baseUrl: "https://dashboard.example.test",
79
+ fetchImpl: (async () => new Response("{}")),
80
+ };
81
+ const server = createServer(deps);
82
+ const shape = server._registeredTools[toolName]?.inputSchema?.shape;
83
+ if (!shape) {
84
+ throw new Error(`${toolName} has no inputSchema shape — is it still registered under this name?`);
85
+ }
86
+ return Object.keys(shape);
87
+ }
88
+ export const EXPECTED_ONE_SIDED_FIELDS = [
89
+ {
90
+ verb: "issue update",
91
+ side: "mcp",
92
+ field: "id",
93
+ reason: "the issue being updated is a positional argument on the CLI (`cockpit issue update <id>`), never a flag — "
94
+ + "there is nothing for it to be symmetric WITH.",
95
+ },
96
+ {
97
+ verb: "issue create",
98
+ side: "mcp",
99
+ field: "description",
100
+ reason: "the CLI never takes a description on argv, by the sourcing-secrets discipline every body follows here — "
101
+ + "stdin or --file only. An MCP call has no stdin, so its tool takes description as a plain argument instead.",
102
+ },
103
+ {
104
+ verb: "issue update",
105
+ side: "mcp",
106
+ field: "description",
107
+ reason: "the same reason as issue create's: stdin/--file on the CLI, a plain argument on MCP.",
108
+ },
109
+ ];
@@ -19,11 +19,40 @@ export interface IssueRow {
19
19
  title: string;
20
20
  state: string;
21
21
  priority?: number | null;
22
+ estimate?: number | null;
22
23
  assignee_id?: string | null;
23
24
  project_id?: string | null;
24
25
  parent_id?: string | null;
25
26
  updated_at?: string | null;
26
27
  }
28
+ /**
29
+ * `size` on `work_create_issue`/`work_update_issue` (BLI-10575) maps onto the
30
+ * existing `work_issues.estimate` numeric column — the CLI twin of this,
31
+ * `issue-contracts.ts` in the collector, carries the identical table and
32
+ * comment; kept as a second copy rather than a shared import for the same
33
+ * reason `work-directory.ts` duplicates `issue-directory.ts` instead of
34
+ * depending on the collector package.
35
+ */
36
+ export declare const SIZE_ESTIMATES: {
37
+ readonly S: 1;
38
+ readonly M: 3;
39
+ readonly L: 5;
40
+ };
41
+ export type IssueSize = keyof typeof SIZE_ESTIMATES;
42
+ export declare function estimateForSize(size: string): number;
43
+ export declare function sizeForEstimate(estimate: number | null | undefined): IssueSize | null;
44
+ /**
45
+ * `T extends object` rather than `T extends { estimate?: ... }` on purpose:
46
+ * a caller here often has `result.issue ?? {}` in hand, and TypeScript's
47
+ * excess-property check on an all-optional ("weak") object type refuses a
48
+ * `{}` that shares NO property with `{ estimate?: ... }` at all (TS2559) —
49
+ * exactly the shape `withAssigneeIdentity(... ?? {}, directory)` hands back
50
+ * when the issue itself is absent. Reading `estimate` off an `object` cast
51
+ * keeps the same runtime behaviour without that false refusal.
52
+ */
53
+ export declare function withSizeLabel<T extends object>(row: T): T & {
54
+ size: IssueSize | null;
55
+ };
27
56
  /**
28
57
  * The fields `work_list_issues` promises. The door's row carries the whole
29
58
  * description and a dozen bookkeeping columns; with no limit that was 1,000
@@ -36,6 +65,7 @@ export interface IssueListRow {
36
65
  title: string;
37
66
  state: string;
38
67
  priority: number | null;
68
+ estimate: number | null;
39
69
  assignee_id: string | null;
40
70
  project_id: string | null;
41
71
  parent_id: string | null;
@@ -13,6 +13,44 @@
13
13
  * Every string moved verbatim.
14
14
  */
15
15
  import { assigneeLabel } from "./work-directory.js";
16
+ /**
17
+ * `size` on `work_create_issue`/`work_update_issue` (BLI-10575) maps onto the
18
+ * existing `work_issues.estimate` numeric column — the CLI twin of this,
19
+ * `issue-contracts.ts` in the collector, carries the identical table and
20
+ * comment; kept as a second copy rather than a shared import for the same
21
+ * reason `work-directory.ts` duplicates `issue-directory.ts` instead of
22
+ * depending on the collector package.
23
+ */
24
+ export const SIZE_ESTIMATES = { S: 1, M: 3, L: 5 };
25
+ export function estimateForSize(size) {
26
+ const key = size.trim().toUpperCase();
27
+ if (key !== "S" && key !== "M" && key !== "L") {
28
+ throw new Error("size must be S, M, or L.");
29
+ }
30
+ return SIZE_ESTIMATES[key];
31
+ }
32
+ export function sizeForEstimate(estimate) {
33
+ if (estimate === null || estimate === undefined)
34
+ return null;
35
+ for (const key of Object.keys(SIZE_ESTIMATES)) {
36
+ if (SIZE_ESTIMATES[key] === estimate)
37
+ return key;
38
+ }
39
+ return null;
40
+ }
41
+ /**
42
+ * `T extends object` rather than `T extends { estimate?: ... }` on purpose:
43
+ * a caller here often has `result.issue ?? {}` in hand, and TypeScript's
44
+ * excess-property check on an all-optional ("weak") object type refuses a
45
+ * `{}` that shares NO property with `{ estimate?: ... }` at all (TS2559) —
46
+ * exactly the shape `withAssigneeIdentity(... ?? {}, directory)` hands back
47
+ * when the issue itself is absent. Reading `estimate` off an `object` cast
48
+ * keeps the same runtime behaviour without that false refusal.
49
+ */
50
+ export function withSizeLabel(row) {
51
+ const estimate = row.estimate;
52
+ return { ...row, size: sizeForEstimate(estimate ?? null) };
53
+ }
16
54
  export function issueListRow(row) {
17
55
  return {
18
56
  id: row.id,
@@ -20,6 +58,7 @@ export function issueListRow(row) {
20
58
  title: row.title,
21
59
  state: row.state,
22
60
  priority: row.priority ?? null,
61
+ estimate: row.estimate ?? null,
23
62
  assignee_id: row.assignee_id ?? null,
24
63
  project_id: row.project_id ?? null,
25
64
  parent_id: row.parent_id ?? null,
@@ -55,8 +94,9 @@ export function outsideNote(outside) {
55
94
  }
56
95
  export function renderOneIssue(result, directory) {
57
96
  const { issue, comments } = result;
97
+ const size = sizeForEstimate(issue?.estimate ?? null);
58
98
  const commentText = comments.map((comment) => `[${comment.created_at ?? ""}] ${comment.body_markdown ?? ""}`).join("\n\n");
59
- return `${issue?.identifier ?? result.ref} ${issue?.title ?? ""} (${issue?.state ?? "?"}) · assignee ${assigneeLabel(issue?.assignee_id, directory)}`
99
+ return `${issue?.identifier ?? result.ref} ${issue?.title ?? ""} (${issue?.state ?? "?"})${size ? ` · size ${size}` : ""} · assignee ${assigneeLabel(issue?.assignee_id, directory)}`
60
100
  + `\n\n${issue?.description ?? "(no description)"}`
61
101
  + (commentText ? `\n\n--- comments ---\n${commentText}` : "")
62
102
  + (result.commentsUnread ? "\n\n(comments could not be read on this call)" : "");
@@ -18,7 +18,7 @@ import { z } from "zod";
18
18
  import { callAgentDoor } from "./agent-door.js";
19
19
  import { DEFAULT_LIST_LIMIT, doorFailureText, errorResult, moreNote, registrarFor, textResult, withSession, } from "./tool-result.js";
20
20
  import { assigneeLabel, resolveAssigneeDirectory, withAssigneeIdentity } from "./work-directory.js";
21
- import { historyChange, issueListRow, listCompleteness, outsideNote, outsideProject, renderOneIssue, } from "./work-issue-rows.js";
21
+ import { estimateForSize, historyChange, issueListRow, listCompleteness, outsideNote, outsideProject, renderOneIssue, sizeForEstimate, withSizeLabel, } from "./work-issue-rows.js";
22
22
  /** The closed state vocabulary, verbatim from `lib/work/api-doors.ts` `WORK_ISSUE_STATES`. */
23
23
  export const WORK_ISSUE_STATES = [
24
24
  "backlog",
@@ -63,6 +63,24 @@ const ISSUE_REF = z
63
63
  .min(1)
64
64
  .max(200)
65
65
  .describe("A BLI-#### identifier (e.g. BLI-3654) or an issue's uuid. Both work.");
66
+ /**
67
+ * A parent issue's uuid from any issue reference — the `create_id`/`update_id`
68
+ * bodies below need a uuid, and `[issueId]`'s own ref resolution
69
+ * (`lib/work/issue-ref.ts`) only runs on the PATH segment of `GET
70
+ * /api/work/issues/[issueId]`, never on a `parent_id` in a POST/PATCH body.
71
+ * `issue-contracts.ts#resolveIssueUuid` in the collector is the CLI's own
72
+ * copy of the identical resolve-then-write two-step.
73
+ */
74
+ async function resolveParentId(deps, session, ref) {
75
+ const response = await callAgentDoor(session, deps.fetchImpl, "GET", `/api/work/issues/${encodeURIComponent(ref)}`);
76
+ if (!response.ok)
77
+ return { status: "failed", text: doorFailureText("work_get_issue", response) };
78
+ const parent = response.body.issue;
79
+ if (!parent?.id) {
80
+ return { status: "failed", text: `No issue matches "${ref}" — it may not exist, or you cannot read it.` };
81
+ }
82
+ return { status: "ok", id: parent.id };
83
+ }
66
84
  export function registerWorkTools(server, deps) {
67
85
  const register = registrarFor(server);
68
86
  register("work_list_issues", {
@@ -111,12 +129,15 @@ export function registerWorkTools(server, deps) {
111
129
  // BLI-10450: one directory call for the whole page, never one per row.
112
130
  const directory = await resolveAssigneeDirectory(deps, session, issues.map((issue) => issue.assignee_id));
113
131
  const lines = issues
114
- .map((issue) => `${issue.identifier} ${issue.state} ${issue.title}`
115
- + ` [p${issue.priority ?? 0}, assignee ${assigneeLabel(issue.assignee_id, directory)}, project ${issue.project_id ?? "none"}]`)
132
+ .map((issue) => {
133
+ const size = sizeForEstimate(issue.estimate ?? null);
134
+ return `${issue.identifier} ${issue.state} ${issue.title}`
135
+ + ` [p${issue.priority ?? 0}${size ? `, size ${size}` : ""}, assignee ${assigneeLabel(issue.assignee_id, directory)}, project ${issue.project_id ?? "none"}]`;
136
+ })
116
137
  .join("\n");
117
138
  const totalNote = completeness?.total != null && completeness.total > issues.length ? ` ${completeness.total} match in all.` : "";
118
139
  return textResult(`${issues.length} issue(s).${totalNote}${moreNote(more, ISSUE_LIST_MAX)}${outsideNote(outside)}${lines ? `\n${lines}` : ""}`, {
119
- issues: issues.map((issue) => withAssigneeIdentity(issueListRow(issue), directory)),
140
+ issues: issues.map((issue) => withSizeLabel(withAssigneeIdentity(issueListRow(issue), directory))),
120
141
  more,
121
142
  ...(completeness ? { total: completeness.total } : {}),
122
143
  ...(outside ? { outside_project: outside } : {}),
@@ -146,7 +167,7 @@ export function registerWorkTools(server, deps) {
146
167
  if (!result?.ok)
147
168
  return errorResult(result?.text ?? "work_get_issue could not read that issue.");
148
169
  return textResult(renderOneIssue(result, directory), {
149
- issue: withAssigneeIdentity(result.issue ?? {}, directory),
170
+ issue: withSizeLabel(withAssigneeIdentity(result.issue ?? {}, directory)),
150
171
  comments: result.comments,
151
172
  ...(result.commentsUnread ? { comments_unread: true } : {}),
152
173
  });
@@ -160,7 +181,7 @@ export function registerWorkTools(server, deps) {
160
181
  ? {
161
182
  ref: result.ref,
162
183
  ok: true,
163
- issue: withAssigneeIdentity(result.issue ?? {}, directory),
184
+ issue: withSizeLabel(withAssigneeIdentity(result.issue ?? {}, directory)),
164
185
  comments: result.comments,
165
186
  ...(result.commentsUnread ? { comments_unread: true } : {}),
166
187
  }
@@ -175,6 +196,11 @@ export function registerWorkTools(server, deps) {
175
196
  description: z.string().max(200_000).optional().describe("The issue body, markdown."),
176
197
  project: z.string().min(1).max(200).optional().describe("A project name or uuid."),
177
198
  priority: z.number().int().min(0).max(4).optional().describe("0 none, 1 urgent, 2 high, 3 medium, 4 low."),
199
+ // BLI-10575: the CLI twin of this tool, `cockpit issue create`, has
200
+ // had --parent since BLI-3716; this field was missing, and nothing
201
+ // checked for the gap — see work-flag-census.test.ts.
202
+ parent: ISSUE_REF.optional().describe("Another issue's BLI-#### identifier or uuid — this issue becomes its sub-issue."),
203
+ size: z.enum(["S", "M", "L"]).optional().describe("Tower's own size scale, stored in the existing estimate column: S=1, M=3, L=5 (BLI-10575)."),
178
204
  assignee: z.string().min(1).max(100).optional().describe('"me" or a person\'s uuid.'),
179
205
  },
180
206
  }, async (args) => withSession(deps, async (session) => {
@@ -188,17 +214,26 @@ export function registerWorkTools(server, deps) {
188
214
  }
189
215
  projectId = resolved.id;
190
216
  }
217
+ let parentId;
218
+ if (args.parent) {
219
+ const resolved = await resolveParentId(deps, session, String(args.parent));
220
+ if (resolved.status === "failed")
221
+ return errorResult(resolved.text);
222
+ parentId = resolved.id;
223
+ }
191
224
  const response = await callAgentDoor(session, deps.fetchImpl, "POST", "/api/work/issues", {
192
225
  title: args.title,
193
226
  ...(args.description === undefined ? {} : { description: args.description }),
194
227
  ...(projectId ? { project_id: projectId } : {}),
195
228
  ...(args.priority === undefined ? {} : { priority: args.priority }),
229
+ ...(args.size === undefined ? {} : { estimate: estimateForSize(String(args.size)) }),
196
230
  ...(args.assignee ? { assignee_id: args.assignee } : {}),
231
+ ...(parentId ? { parent_id: parentId } : {}),
197
232
  });
198
233
  if (!response.ok)
199
234
  return errorResult(doorFailureText("work_create_issue", response));
200
235
  const issue = response.body.issue;
201
- return textResult(`Filed ${issue?.identifier ?? ""} — ${issue?.title ?? ""} (${issue?.id ?? ""}).`, { issue });
236
+ return textResult(`Filed ${issue?.identifier ?? ""} — ${issue?.title ?? ""} (${issue?.id ?? ""}).`, { issue: issue ? withSizeLabel(issue) : issue });
202
237
  }));
203
238
  register("work_update_issue", {
204
239
  title: "Update a Tower issue",
@@ -209,6 +244,10 @@ export function registerWorkTools(server, deps) {
209
244
  title: z.string().min(1).max(300).optional().describe("A new title."),
210
245
  description: z.string().max(200_000).nullable().optional().describe("The WHOLE new body, markdown; null clears it."),
211
246
  priority: z.number().int().min(0).max(4).optional().describe("0 none, 1 urgent, 2 high, 3 medium, 4 low."),
247
+ // BLI-10575: had no MCP twin at all — `cockpit issue update --parent`
248
+ // could re-parent a ticket and nothing on this server could.
249
+ parent: ISSUE_REF.optional().describe("Another issue's BLI-#### identifier or uuid — this issue becomes its sub-issue."),
250
+ size: z.enum(["S", "M", "L"]).optional().describe("Tower's own size scale, stored in the existing estimate column: S=1, M=3, L=5 (BLI-10575)."),
212
251
  assignee: z.string().min(1).max(100).nullable().optional().describe('"me", a uuid, or null to unassign.'),
213
252
  project: z.string().min(1).max(200).optional().describe("A project name or uuid."),
214
253
  },
@@ -224,17 +263,26 @@ export function registerWorkTools(server, deps) {
224
263
  }
225
264
  projectId = resolved.id;
226
265
  }
266
+ let parentId;
267
+ if (args.parent) {
268
+ const resolved = await resolveParentId(deps, session, String(args.parent));
269
+ if (resolved.status === "failed")
270
+ return errorResult(resolved.text);
271
+ parentId = resolved.id;
272
+ }
227
273
  const response = await callAgentDoor(session, deps.fetchImpl, "PATCH", `/api/work/issues/${encodeURIComponent(ref)}`, {
228
274
  ...(args.title !== undefined ? { title: args.title } : {}),
229
275
  ...(args.description !== undefined ? { description: args.description } : {}),
230
276
  ...(args.priority !== undefined ? { priority: args.priority } : {}),
277
+ ...(args.size === undefined ? {} : { estimate: estimateForSize(String(args.size)) }),
231
278
  ...(args.assignee !== undefined ? { assignee_id: args.assignee } : {}),
232
279
  ...(projectId ? { project_id: projectId } : {}),
280
+ ...(parentId ? { parent_id: parentId } : {}),
233
281
  });
234
282
  if (!response.ok)
235
283
  return errorResult(doorFailureText("work_update_issue", response));
236
284
  const issue = response.body.issue;
237
- return textResult(`Updated ${issue?.identifier ?? ref} — ${issue?.title ?? ""}.`, { issue });
285
+ return textResult(`Updated ${issue?.identifier ?? ref} — ${issue?.title ?? ""}.`, { issue: issue ? withSizeLabel(issue) : issue });
238
286
  }));
239
287
  register("work_move_issue", {
240
288
  title: "Move a Tower issue",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/mcp",
3
- "version": "0.1.69",
3
+ "version": "0.1.70",
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",