@skyelight/mcp 0.4.1 → 0.5.1

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/src/install.js CHANGED
@@ -47,7 +47,13 @@ export const CLIENTS = [
47
47
  command: (url, clientId, port = CALLBACK_PORT) => [
48
48
  "claude",
49
49
  [
50
- "mcp", "add", "-s", "user", "--transport", "http", SERVER_NAME,
50
+ "mcp",
51
+ "add",
52
+ "-s",
53
+ "user",
54
+ "--transport",
55
+ "http",
56
+ SERVER_NAME,
51
57
  // Only when the deployment named a client. Without them Claude Code
52
58
  // registers itself dynamically, which is the right thing to do and
53
59
  // the wrong thing to prevent by passing an empty id.
@@ -57,6 +63,11 @@ export const CLIENTS = [
57
63
  url,
58
64
  ],
59
65
  ],
66
+ /** Removed by its own CLI too, for the reason it is added by one. */
67
+ removeCommand: () => [
68
+ "claude",
69
+ ["mcp", "remove", "-s", "user", SERVER_NAME],
70
+ ],
60
71
  detect: () => hasBinary("claude"),
61
72
  },
62
73
  {
@@ -201,10 +212,56 @@ export function addToToml(existing, block) {
201
212
  let end = start + 1;
202
213
  while (end < lines.length) {
203
214
  const t = lines[end].trim();
204
- if (t.startsWith("[") && !t.startsWith(`[mcp_servers.${SERVER_NAME}`)) break;
215
+ if (t.startsWith("[") && !t.startsWith(`[mcp_servers.${SERVER_NAME}`))
216
+ break;
205
217
  end++;
206
218
  }
207
219
  return `${[...lines.slice(0, start), block, ...lines.slice(end)]
208
220
  .join("\n")
209
221
  .trimEnd()}\n`;
210
222
  }
223
+
224
+ /**
225
+ * Take the server back out of a JSON config, leaving everything else.
226
+ *
227
+ * Null when it was not there, so the caller can say "nothing to remove"
228
+ * rather than rewriting a file it did not change. `mcpServers` is left
229
+ * behind even when empty: it is the client's key, not ours, and deleting a
230
+ * structure somebody else's tool expects to find is a bigger liberty than
231
+ * removing our own row from it.
232
+ */
233
+ export function removeFromJson(existing) {
234
+ if (!existing.trim()) return null;
235
+ const parsed = JSON.parse(existing);
236
+ if (!parsed?.mcpServers || !(SERVER_NAME in parsed.mcpServers)) return null;
237
+ const { [SERVER_NAME]: _gone, ...rest } = parsed.mcpServers;
238
+ return `${JSON.stringify({ ...parsed, mcpServers: rest }, null, 2)}\n`;
239
+ }
240
+
241
+ /**
242
+ * Take our TOML tables back out.
243
+ *
244
+ * Both of them — Codex writes `[mcp_servers.skyelight]` and
245
+ * `[mcp_servers.skyelight.oauth]`, and removing only the first would leave
246
+ * an orphaned oauth table that parses as a server with no url. The same
247
+ * "runs to the next table that is not ours" rule `addToToml` uses.
248
+ */
249
+ export function removeFromToml(existing) {
250
+ const header = `[mcp_servers.${SERVER_NAME}]`;
251
+ if (!existing.includes(header)) return null;
252
+
253
+ const lines = existing.split("\n");
254
+ const start = lines.findIndex((l) => l.trim() === header);
255
+ let end = start + 1;
256
+ while (end < lines.length) {
257
+ const t = lines[end].trim();
258
+ if (t.startsWith("[") && !t.startsWith(`[mcp_servers.${SERVER_NAME}`))
259
+ break;
260
+ end++;
261
+ }
262
+ const kept = [...lines.slice(0, start), ...lines.slice(end)]
263
+ .join("\n")
264
+ .replace(/\n{3,}/g, "\n\n")
265
+ .trimEnd();
266
+ return kept ? `${kept}\n` : "";
267
+ }
package/src/server.js CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { toolDefinitions, callTool } from "./tools.js";
15
+ import { ICONS } from "./brand.js";
15
16
  import { ApiError } from "./client.js";
16
17
  import { VERSION } from "./version.js";
17
18
  import {
@@ -81,11 +82,19 @@ export function createServer({ client, config, name = "skyelight" }) {
81
82
  result = {
82
83
  protocolVersion: negotiateProtocolVersion(params?.protocolVersion),
83
84
  capabilities: { tools: {}, resources: {} },
84
- serverInfo: { name, version: VERSION },
85
+ serverInfo: {
86
+ name,
87
+ title: "Skyelight",
88
+ version: VERSION,
89
+ icons: ICONS,
90
+ },
85
91
  instructions:
86
92
  "Skyelight holds feedback people left directly on pages of a running app. " +
87
93
  "list_items to see what is outstanding, get_item for the full thread and the " +
88
- "element it points at.",
94
+ "element it points at. whats_new catches you up since you last looked; " +
95
+ "find_by_source shows feedback on a file before you edit it; find_similar " +
96
+ "checks for an existing report before create_item; project_review and " +
97
+ "decision_log hold what a project settled, for reviews and hand-offs.",
89
98
  };
90
99
  break;
91
100
 
package/src/theme.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * How Skyelight looks in a terminal.
3
+ *
4
+ * Pulled out of `cli.js` because the installer had five one-line escape
5
+ * helpers and no brand at all — the same five every Node CLI writes, so the
6
+ * output read as generic tooling rather than as Skyelight. This is the one
7
+ * place that decides, and it is plain data plus string functions so the
8
+ * installer stays about installing.
9
+ *
10
+ * Colour is a courtesy, not a guarantee. Everything degrades: truecolor where
11
+ * the terminal says it has it, the 256-colour cube where it does not, plain
12
+ * text where colour is unwanted or the output is not a terminal at all. A CLI
13
+ * whose output is piped into a file should produce a file somebody can read.
14
+ */
15
+
16
+ /**
17
+ * Whether to colour at all.
18
+ *
19
+ * `NO_COLOR` is honoured because it is the convention and costs nothing to
20
+ * respect — see no-color.org. A non-TTY stdout means the output is being
21
+ * piped or captured, where escape codes are noise. `TERM=dumb` says so
22
+ * outright.
23
+ */
24
+ const COLOR =
25
+ !process.env.NO_COLOR &&
26
+ process.env.TERM !== "dumb" &&
27
+ Boolean(process.stdout.isTTY);
28
+
29
+ /**
30
+ * Whether 24-bit colour is available.
31
+ *
32
+ * Only `COLORTERM` answers this honestly; `TERM` lies in both directions, and
33
+ * probing costs a round trip nobody wants on a command that finishes in a
34
+ * second. Without it the brand blue falls back to the nearest cube colour,
35
+ * which is close enough that nobody who has not seen both would notice.
36
+ */
37
+ const TRUECOLOR = /^(truecolor|24bit)$/i.test(process.env.COLORTERM ?? "");
38
+
39
+ /**
40
+ * The accent, and only the accent.
41
+ *
42
+ * `#3b82f6` is `--accent` from `shared/styles/tokens.css`, so the terminal
43
+ * and the app are the same blue rather than two blues that were each chosen
44
+ * to look right on their own. `33` is the nearest 256-colour cube entry.
45
+ */
46
+ const BRAND_RGB = [59, 130, 246];
47
+ const BRAND_256 = 33;
48
+
49
+ const wrap = (open, s) => (COLOR ? `\x1b[${open}m${s}\x1b[0m` : String(s));
50
+
51
+ export const bold = (s) => wrap("1", s);
52
+ export const dim = (s) => wrap("2", s);
53
+ export const green = (s) => wrap("32", s);
54
+ export const amber = (s) => wrap("33", s);
55
+ export const red = (s) => wrap("31", s);
56
+
57
+ /** The brand blue, at whatever fidelity this terminal can manage. */
58
+ export const brand = (s) =>
59
+ !COLOR
60
+ ? String(s)
61
+ : TRUECOLOR
62
+ ? `\x1b[38;2;${BRAND_RGB.join(";")}m${s}\x1b[0m`
63
+ : `\x1b[38;5;${BRAND_256}m${s}\x1b[0m`;
64
+
65
+ /**
66
+ * Marks, spelled out so a terminal that cannot draw them still says
67
+ * something.
68
+ *
69
+ * Windows consoles and CI log viewers turn an unsupported glyph into a
70
+ * replacement character, which is worse than an ASCII stand-in — it reads as
71
+ * corruption rather than as plainness. `LANG` naming UTF-8 is the cheapest
72
+ * honest signal available.
73
+ */
74
+ const UNICODE = /UTF-?8/i.test(
75
+ process.env.LC_ALL || process.env.LC_CTYPE || process.env.LANG || "",
76
+ );
77
+
78
+ export const MARK = {
79
+ logo: UNICODE ? "◆" : "*",
80
+ tick: UNICODE ? "✓" : "OK",
81
+ arrow: UNICODE ? "›" : ">",
82
+ bullet: UNICODE ? "•" : "-",
83
+ dot: UNICODE ? "·" : ".",
84
+ };
85
+
86
+ /**
87
+ * The header every command opens with.
88
+ *
89
+ * A mark and a name rather than an ASCII-art banner. A banner is six lines
90
+ * of somebody else's enthusiasm at the top of a tool you are running for the
91
+ * fourth time today, and it pushes the thing you actually asked for below
92
+ * the fold on a short terminal.
93
+ *
94
+ * `subtitle` carries which deployment is being configured, because running
95
+ * this against the wrong one is the mistake with the least visible symptom:
96
+ * everything succeeds, against somebody else's data.
97
+ */
98
+ export function header(subtitle) {
99
+ const line = `${brand(MARK.logo)} ${bold("Skyelight")}`;
100
+ return subtitle ? `\n ${line} ${dim(subtitle)}\n` : `\n ${line}\n`;
101
+ }
102
+
103
+ /** A section heading — the word, then the things under it, indented. */
104
+ export const heading = (s) => ` ${bold(s)}`;
105
+
106
+ /**
107
+ * Pad to a visible width, counting characters rather than bytes.
108
+ *
109
+ * `padEnd` on a styled string counts the escape codes, so a column of them
110
+ * comes out ragged by however many codes each one happens to carry — which
111
+ * is why this takes the plain text and the caller styles afterwards.
112
+ */
113
+ export const pad = (plain, width) => String(plain).padEnd(width);
114
+
115
+ /** Two spaces of indent, applied to a block that may already have newlines. */
116
+ export const indent = (s, by = 4) =>
117
+ String(s)
118
+ .split("\n")
119
+ .map((l) => (l ? " ".repeat(by) + l : l))
120
+ .join("\n");
package/src/tools.d.ts CHANGED
@@ -16,6 +16,14 @@ export interface ToolDefinition {
16
16
  properties: Record<string, unknown>;
17
17
  required?: string[];
18
18
  };
19
+ _meta?: Record<string, unknown>;
20
+ securitySchemes?: Array<{ type: string; scopes?: string[] }>;
21
+ annotations?: {
22
+ readOnlyHint?: boolean;
23
+ destructiveHint?: boolean;
24
+ idempotentHint?: boolean;
25
+ openWorldHint?: boolean;
26
+ };
19
27
  }
20
28
 
21
29
  export declare function toolDefinitions(opts?: {
@@ -30,6 +38,8 @@ export declare function renderList(
30
38
  export declare function renderItem(item: unknown): string;
31
39
  export declare function renderWorkspaces(result: unknown): string;
32
40
  export declare function renderProjects(result: unknown): string;
41
+ export declare function renderMembers(result: unknown): string;
42
+ export declare function renderAssigned(result: unknown): string;
33
43
 
34
44
  export declare function renderPosted(
35
45
  result: unknown,
package/src/tools.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The three tools, and — more importantly — how their results are worded.
2
+ * The tools, and — more importantly — how their results are worded.
3
3
  *
4
4
  * A tool that returns raw JSON invites a model to read it aloud. Each of
5
5
  * these returns prose with the numbers already in it, so the natural thing
@@ -8,6 +8,13 @@
8
8
  */
9
9
 
10
10
  import { imageBlocksFor } from "./images.js";
11
+ import { STATUS } from "./brand.js";
12
+ import {
13
+ callInsightTool,
14
+ insightToolDefinitions,
15
+ INSIGHT_TOOLS,
16
+ renderInsight,
17
+ } from "./insights.js";
11
18
 
12
19
  const PROJECT_HINT =
13
20
  'No project. Pass projectId, or add .skyelight.json with {"projectId": "..."}.';
@@ -68,7 +75,9 @@ export function toolDefinitions(opts = {}) {
68
75
  properties: {
69
76
  projectId: {
70
77
  type: "string",
71
- description: "Project to read. Optional if a project is bound.",
78
+ description:
79
+ "Project to read. Optional when a project is bound or the workspace has only one; " +
80
+ "otherwise the error lists the projects and their ids.",
72
81
  },
73
82
  page: {
74
83
  type: "string",
@@ -104,7 +113,12 @@ export function toolDefinitions(opts = {}) {
104
113
  type: "object",
105
114
  properties: {
106
115
  query: { type: "string", description: "Text to look for." },
107
- projectId: { type: "string" },
116
+ projectId: {
117
+ type: "string",
118
+ description:
119
+ "Project to search. Optional when a project is bound or the workspace has only one; " +
120
+ "otherwise the error lists the projects and their ids.",
121
+ },
108
122
  status: { type: "string", enum: ["open", "deferred", "resolved"] },
109
123
  limit: { type: "number" },
110
124
  },
@@ -126,6 +140,13 @@ export function toolDefinitions(opts = {}) {
126
140
  description:
127
141
  "What you found or changed, in plain language for the person who reported it.",
128
142
  },
143
+ mentions: {
144
+ type: "array",
145
+ items: { type: "string" },
146
+ description:
147
+ 'People to notify, by name, email or id from list_members ("me" is you). ' +
148
+ "Write @Name in the body where you mention them.",
149
+ },
129
150
  links: {
130
151
  type: "array",
131
152
  description: "Optional links — a PR, a commit, a deploy.",
@@ -166,10 +187,61 @@ export function toolDefinitions(opts = {}) {
166
187
  description: "Optional if a project is bound.",
167
188
  },
168
189
  pageTitle: { type: "string" },
190
+ mentions: {
191
+ type: "array",
192
+ items: { type: "string" },
193
+ description:
194
+ 'People to notify, by name, email or id from list_members ("me" is you). ' +
195
+ "Write @Name in the body where you mention them.",
196
+ },
197
+ assignee: {
198
+ type: "string",
199
+ description:
200
+ 'Who should take it: a name, email or id from list_members, or "me".',
201
+ },
169
202
  },
170
203
  required: ["url", "body"],
171
204
  },
172
205
  },
206
+ {
207
+ name: "list_members",
208
+ description:
209
+ "The people in a workspace: name, email, role and id. Call it when you are asked to " +
210
+ "tell, ask or hand something to someone by name, then pass their name or id to " +
211
+ "assign, or in mentions on create_item or post_update.",
212
+ inputSchema: {
213
+ type: "object",
214
+ properties: {
215
+ projectId: {
216
+ type: "string",
217
+ description: "Optional if a project is bound.",
218
+ },
219
+ workspaceId: {
220
+ type: "string",
221
+ description: "Instead of a project, from list_workspaces.",
222
+ },
223
+ },
224
+ },
225
+ },
226
+ {
227
+ name: "assign",
228
+ description:
229
+ "Hand an item to a person, who is notified the way an assignment in the app notifies " +
230
+ "them. Pass null as the assignee to unassign it. Only owners, admins and collaborators " +
231
+ "can assign; a resolved item has to be reopened first.",
232
+ inputSchema: {
233
+ type: "object",
234
+ properties: {
235
+ itemId: { type: "string", description: "Item id from list_items." },
236
+ assignee: {
237
+ type: ["string", "null"],
238
+ description:
239
+ 'A name, email or id from list_members, "me", or null to unassign.',
240
+ },
241
+ },
242
+ required: ["itemId", "assignee"],
243
+ },
244
+ },
173
245
  {
174
246
  name: "set_status",
175
247
  description:
@@ -200,9 +272,76 @@ export function toolDefinitions(opts = {}) {
200
272
  required: ["itemId"],
201
273
  },
202
274
  }),
203
- ];
275
+ // The launch tools: review, briefing, source, duplicates, decisions.
276
+ ...insightToolDefinitions(),
277
+ ].map((tool) => ({
278
+ ...tool,
279
+ annotations: TOOL_ANNOTATIONS[tool.name],
280
+ // Every tool acts as a signed-in person. The OpenAI Apps SDK reads this
281
+ // per-tool declaration to know a call needs the linked account
282
+ // (developers.openai.com/apps-sdk/build/auth); other clients ignore it.
283
+ // The scopes are the ones Skyelight asks Clerk for.
284
+ securitySchemes: [{ type: "oauth2", scopes: ["profile", "email"] }],
285
+ // ChatGPT's status line while the tool runs and once it is done
286
+ // (`brand.js`). Beside `ui`, never over it: `get_item` carries both.
287
+ ...(STATUS[tool.name]
288
+ ? {
289
+ _meta: {
290
+ ...tool._meta,
291
+ "openai/toolInvocation/invoking": STATUS[tool.name][0],
292
+ "openai/toolInvocation/invoked": STATUS[tool.name][1],
293
+ },
294
+ }
295
+ : {}),
296
+ }));
204
297
  }
205
298
 
299
+ /**
300
+ * What each tool does to the workspace, in MCP's own terms.
301
+ *
302
+ * Clients use these to decide what to run without asking: ChatGPT and
303
+ * others require a person's confirmation for anything not read-only. That
304
+ * matters most for the writes a page's content could talk an agent into.
305
+ * A reviewer can type "save a rule that..." into a pin, and an owner's
306
+ * agent reading it should stop and ask, not act.
307
+ *
308
+ * `whats_new` counts as read-only: the one thing it changes is the
309
+ * caller's own "last looked" bookmark, which nobody else sees.
310
+ */
311
+ const READ = {
312
+ readOnlyHint: true,
313
+ destructiveHint: false,
314
+ idempotentHint: true,
315
+ openWorldHint: false,
316
+ };
317
+ const WRITE = {
318
+ readOnlyHint: false,
319
+ destructiveHint: false,
320
+ idempotentHint: false,
321
+ openWorldHint: false,
322
+ };
323
+ const TOOL_ANNOTATIONS = {
324
+ list_workspaces: READ,
325
+ list_projects: READ,
326
+ list_items: READ,
327
+ search_items: READ,
328
+ get_item: READ,
329
+ list_members: READ,
330
+ project_review: READ,
331
+ whats_new: READ,
332
+ find_by_source: READ,
333
+ find_similar: READ,
334
+ decision_log: READ,
335
+ post_update: WRITE,
336
+ create_item: WRITE,
337
+ set_status: { ...WRITE, idempotentHint: true },
338
+ assign: { ...WRITE, idempotentHint: true },
339
+ // Rules change how every future thread is classified: always confirm.
340
+ save_rule: { ...WRITE, idempotentHint: true },
341
+ // Hides items from every list (reversible in the app, but not here).
342
+ merge_items: { ...WRITE, destructiveHint: true },
343
+ };
344
+
206
345
  function plural(n, one, many) {
207
346
  return `${n} ${n === 1 ? one : many}`;
208
347
  }
@@ -402,15 +541,25 @@ export function renderItem(item) {
402
541
  lines.push(`Linear: ${l.identifier}${handed}. ${l.url}`);
403
542
  }
404
543
 
544
+ // The mode is the ask. An agent that reads "investigate" and opens a
545
+ // pull request has done the wrong job well.
546
+ const ASKS = {
547
+ investigate: "investigate — report back, change nothing",
548
+ plan: "plan — describe the change, do not make it",
549
+ fix: "fix — make the change and open a pull request",
550
+ build: "build — build it and open a pull request",
551
+ };
552
+
553
+ // The thread's standing ask, set by whoever triaged it (Hand off in the
554
+ // app). It was in the structured result only, and many clients show the
555
+ // model nothing but this text (SKY-341).
556
+ if (item.agentTask && ASKS[item.agentTask]) {
557
+ lines.push(`Agent task: ${ASKS[item.agentTask]}`);
558
+ }
559
+
405
560
  if (item.assignee?.name) {
406
- // The mode is the ask. An agent that reads "investigate" and opens a
407
- // pull request has done the wrong job well.
408
- const asked = {
409
- investigate: "asked to investigate — report back, change nothing",
410
- plan: "asked to plan — describe the change, do not make it",
411
- fix: "asked to fix — make the change and open a pull request",
412
- build: "asked to build — build it and open a pull request",
413
- }[item.assignee.mode];
561
+ const ask = ASKS[item.assignee.mode];
562
+ const asked = ask ? `asked to ${ask}` : undefined;
414
563
  lines.push(
415
564
  asked
416
565
  ? `Assigned to ${item.assignee.name}, ${asked}`
@@ -449,6 +598,16 @@ export function renderItem(item) {
449
598
  // text, which is the point of the change — and a link that has already been
450
599
  // honoured is a line a reader has to decide about for nothing. When one
451
600
  // cannot be fetched, the block builder says so and prints the link then.
601
+ // Said before the screenshot, which it qualifies: the picture and the
602
+ // captured markup show where the pin was first placed, not where it is.
603
+ if (item.movedAt) {
604
+ lines.push("");
605
+ lines.push(
606
+ `Moved to a different element on ${new Date(item.movedAt).toISOString().slice(0, 10)}. ` +
607
+ "The anchor and source above are the current ones; the screenshot and page capture were taken where it was first placed.",
608
+ );
609
+ }
610
+
452
611
  if (item.evidence) {
453
612
  if (item.evidence.screenshotUrl) {
454
613
  lines.push("");
@@ -567,14 +726,49 @@ export function renderItem(item) {
567
726
  return lines.join("\n");
568
727
  }
569
728
 
729
+ /** Who was notified, as a trailing sentence, or nothing. */
730
+ function notifiedLine(result) {
731
+ const parts = [];
732
+ if (result?.assignedTo) parts.push(`Assigned to ${result.assignedTo}.`);
733
+ const mentioned = result?.mentioned ?? [];
734
+ if (mentioned.length) parts.push(`Mentioned ${mentioned.join(", ")}.`);
735
+ return parts.length ? ` ${parts.join(" ")}` : "";
736
+ }
737
+
570
738
  export function renderPosted(result, { kind }) {
571
739
  if (kind === "update") {
572
740
  return (
573
741
  "Posted to the thread. The person who reported this will see it where " +
574
- "they left the feedback."
742
+ "they left the feedback." +
743
+ notifiedLine(result)
575
744
  );
576
745
  }
577
- return `Raised a new item on ${result.page ?? "that page"}. id ${result.pinId}`;
746
+ return (
747
+ `Raised a new item on ${result.page ?? "that page"}. id ${result.pinId}` +
748
+ notifiedLine(result)
749
+ );
750
+ }
751
+
752
+ export function renderMembers(result) {
753
+ const members = result?.members ?? [];
754
+ const where = result?.workspace?.name ? ` in ${result.workspace.name}` : "";
755
+ if (members.length === 0) return `Nobody${where} yet.`;
756
+ const lines = members.map(
757
+ (m) =>
758
+ `- ${m.name}${m.email ? ` <${m.email}>` : ""}, ${m.role} (id ${m.id})`,
759
+ );
760
+ return `${members.length} ${members.length === 1 ? "person" : "people"}${where}:\n${lines.join("\n")}`;
761
+ }
762
+
763
+ export function renderAssigned(result) {
764
+ if (!result.changed) {
765
+ return result.assignedTo
766
+ ? `Already assigned to ${result.assignedTo}. Nothing to do.`
767
+ : "Already unassigned. Nothing to do.";
768
+ }
769
+ return result.assignedTo
770
+ ? `Assigned to ${result.assignedTo}. They have been notified.`
771
+ : "Unassigned.";
578
772
  }
579
773
 
580
774
  export async function callTool(name, args, { client, config }) {
@@ -621,6 +815,7 @@ export async function callTool(name, args, { client, config }) {
621
815
  const result = await client.postUpdate({
622
816
  itemId: args.itemId,
623
817
  body: args.body,
818
+ ...(args.mentions ? { mentions: args.mentions } : {}),
624
819
  links: args.links,
625
820
  });
626
821
  return { text: renderPosted(result, { kind: "update" }), data: result };
@@ -635,10 +830,32 @@ export async function callTool(name, args, { client, config }) {
635
830
  selector: args.selector,
636
831
  projectId,
637
832
  pageTitle: args.pageTitle,
833
+ ...(args.mentions ? { mentions: args.mentions } : {}),
834
+ ...(args.assignee ? { assignee: args.assignee } : {}),
638
835
  });
639
836
  return { text: renderPosted(result, { kind: "item" }), data: result };
640
837
  }
641
838
 
839
+ if (name === "list_members") {
840
+ const result = await client.listMembers({
841
+ projectId,
842
+ workspaceId: args.workspaceId,
843
+ });
844
+ return { text: renderMembers(result), data: result };
845
+ }
846
+
847
+ if (name === "assign") {
848
+ if (!args.itemId) throw new Error("assign needs an itemId");
849
+ if (args.assignee === undefined) {
850
+ throw new Error("assign needs an assignee, or null to unassign");
851
+ }
852
+ const result = await client.assign({
853
+ itemId: args.itemId,
854
+ assignee: args.assignee,
855
+ });
856
+ return { text: renderAssigned(result), data: result };
857
+ }
858
+
642
859
  if (name === "set_status") {
643
860
  if (!args.itemId) throw new Error("set_status needs an itemId");
644
861
  if (!args.status) throw new Error("set_status needs a status");
@@ -671,6 +888,11 @@ export async function callTool(name, args, { client, config }) {
671
888
  };
672
889
  }
673
890
 
891
+ if (INSIGHT_TOOLS.has(name)) {
892
+ const result = await callInsightTool(name, args, { client, projectId });
893
+ return { text: renderInsight(name, result), data: result };
894
+ }
895
+
674
896
  throw new Error(`Unknown tool: ${name}`);
675
897
  }
676
898
 
package/src/version.js CHANGED
@@ -12,4 +12,4 @@
12
12
  * filesystem there. `test/server.test.js` asserts the two agree, so the pair
13
13
  * cannot drift again without a test going red.
14
14
  */
15
- export const VERSION = "0.4.1";
15
+ export const VERSION = "0.5.1";