@skyelight/mcp 0.1.0

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/tools.js ADDED
@@ -0,0 +1,500 @@
1
+ /**
2
+ * The three tools, and — more importantly — how their results are worded.
3
+ *
4
+ * A tool that returns raw JSON invites a model to read it aloud. Each of
5
+ * these returns prose with the numbers already in it, so the natural thing
6
+ * for a model to do is summarise rather than dump. The structured payload is
7
+ * still attached for anything that wants to compute on it.
8
+ */
9
+
10
+ const PROJECT_HINT =
11
+ 'No project. Pass projectId, add .skyelight.json with {"projectId": "..."}, ' +
12
+ "or use a project-bound API key.";
13
+
14
+ /**
15
+ * @param opts.uiResourceUri When set, `get_item` advertises a `ui://`
16
+ * resource the host can render as a card. Omitted for clients that cannot
17
+ * render one — the text result is complete on its own, so the card is
18
+ * strictly additive and its absence costs nothing (SKY-279).
19
+ */
20
+ export function toolDefinitions(opts = {}) {
21
+ const withUi = (tool) =>
22
+ opts.uiResourceUri
23
+ ? {
24
+ ...tool,
25
+ _meta: {
26
+ ui: {
27
+ resourceUri: opts.uiResourceUri,
28
+ // The model calls it, and the card calls back into it.
29
+ visibility: ["model", "app"],
30
+ },
31
+ },
32
+ }
33
+ : tool;
34
+
35
+ return [
36
+ {
37
+ name: "list_workspaces",
38
+ description:
39
+ "Workspaces you can reach, with the role you hold in each. Use when " +
40
+ "you do not know what exists yet, or to find which workspace a " +
41
+ "project belongs to.",
42
+ inputSchema: { type: "object", properties: {} },
43
+ },
44
+ {
45
+ name: "list_projects",
46
+ description:
47
+ "Projects you can reach, newest activity first, across every " +
48
+ "workspace unless one is named. Start here when no project is bound " +
49
+ "— `list_items` needs a project id and this is where one comes from.",
50
+ inputSchema: {
51
+ type: "object",
52
+ properties: {
53
+ workspaceId: {
54
+ type: "string",
55
+ description: "Narrow to one workspace. Optional.",
56
+ },
57
+ },
58
+ },
59
+ },
60
+ {
61
+ name: "list_items",
62
+ description:
63
+ "List feedback items (bugs, ideas, comments) people left on a Skyelight project. " +
64
+ "Returns a summary of the project plus matching items. Start here to see what is outstanding.",
65
+ inputSchema: {
66
+ type: "object",
67
+ properties: {
68
+ projectId: {
69
+ type: "string",
70
+ description: "Project to read. Optional if a project is bound.",
71
+ },
72
+ page: {
73
+ type: "string",
74
+ description: 'Only items on one page, e.g. "/checkout".',
75
+ },
76
+ type: {
77
+ type: "string",
78
+ description: 'Item type, e.g. "bug", "idea", "feedback".',
79
+ },
80
+ status: {
81
+ type: "string",
82
+ enum: ["open", "deferred", "resolved"],
83
+ description:
84
+ 'Defaults to everything. "deferred" is work someone put off ' +
85
+ "until later — outstanding, but deliberately not now.",
86
+ },
87
+ assignee: {
88
+ type: "string",
89
+ description: 'A user id, or "none" for unassigned items.',
90
+ },
91
+ limit: { type: "number", description: "Max items (default 50)." },
92
+ },
93
+ },
94
+ },
95
+ {
96
+ name: "search_items",
97
+ description:
98
+ "Find items whose thread mentions some text. Searches replies as well as the original " +
99
+ "message, so an item someone already diagnosed in a reply still turns up.",
100
+ inputSchema: {
101
+ type: "object",
102
+ properties: {
103
+ query: { type: "string", description: "Text to look for." },
104
+ projectId: { type: "string" },
105
+ status: { type: "string", enum: ["open", "deferred", "resolved"] },
106
+ limit: { type: "number" },
107
+ },
108
+ required: ["query"],
109
+ },
110
+ },
111
+ {
112
+ name: "post_update",
113
+ description:
114
+ "Report back on an item: what you found, what you changed, a link to the PR. Posts as a " +
115
+ "reply on the thread the feedback was left on, so the person who reported it sees the " +
116
+ "answer where they asked. Use this when you finish work on an item, and when you get stuck.",
117
+ inputSchema: {
118
+ type: "object",
119
+ properties: {
120
+ itemId: { type: "string", description: "Item id from list_items." },
121
+ body: {
122
+ type: "string",
123
+ description:
124
+ "What you found or changed, in plain language for the person who reported it.",
125
+ },
126
+ links: {
127
+ type: "array",
128
+ description: "Optional links — a PR, a commit, a deploy.",
129
+ items: {
130
+ type: "object",
131
+ properties: {
132
+ label: { type: "string" },
133
+ url: { type: "string" },
134
+ },
135
+ required: ["url"],
136
+ },
137
+ },
138
+ },
139
+ required: ["itemId", "body"],
140
+ },
141
+ },
142
+ {
143
+ name: "create_item",
144
+ description:
145
+ "Open a new thread on a page, as the person whose connection you are using. " +
146
+ "Only available on a person's own connection — an agent reporting what it found " +
147
+ "or changed uses post_update on the thread it was working, so the answer lands " +
148
+ "where the question was asked.",
149
+ inputSchema: {
150
+ type: "object",
151
+ properties: {
152
+ url: {
153
+ type: "string",
154
+ description: "Full URL of the page this is about.",
155
+ },
156
+ body: { type: "string", description: "What you want to say." },
157
+ selector: {
158
+ type: "string",
159
+ description: "CSS selector for the element, if there is one.",
160
+ },
161
+ projectId: {
162
+ type: "string",
163
+ description: "Optional if a project is bound.",
164
+ },
165
+ pageTitle: { type: "string" },
166
+ },
167
+ required: ["url", "body"],
168
+ },
169
+ },
170
+ {
171
+ name: "set_status",
172
+ description:
173
+ "Move an item to open, deferred or resolved. Use it to close the loop after you have " +
174
+ "fixed something and said so \u2014 post_update first, so the person who reported it " +
175
+ "reads what changed, then set_status. \"deferred\" is for work that is real but not now.",
176
+ inputSchema: {
177
+ type: "object",
178
+ properties: {
179
+ itemId: { type: "string", description: "Item id from list_items." },
180
+ status: { type: "string", enum: ["open", "deferred", "resolved"] },
181
+ },
182
+ required: ["itemId", "status"],
183
+ },
184
+ },
185
+ withUi({
186
+ name: "get_item",
187
+ description:
188
+ "Everything needed to work one item: the whole thread in order, the page and URL it is " +
189
+ "on, and the anchor identifying the element the person pointed at.",
190
+ inputSchema: {
191
+ type: "object",
192
+ properties: {
193
+ itemId: { type: "string", description: "Item id from list_items." },
194
+ },
195
+ required: ["itemId"],
196
+ },
197
+ }),
198
+ ];
199
+ }
200
+
201
+ function plural(n, one, many) {
202
+ return `${n} ${n === 1 ? one : many}`;
203
+ }
204
+
205
+ /** Top entries of a count map, worded for a sentence. */
206
+ function topBreakdown(counts, limit = 3) {
207
+ const entries = Object.entries(counts ?? {}).sort((a, b) => b[1] - a[1]);
208
+ if (entries.length === 0) return "";
209
+ return entries
210
+ .slice(0, limit)
211
+ .map(([k, n]) => `${k} (${n})`)
212
+ .join(", ");
213
+ }
214
+
215
+ /**
216
+ * Prose, like every other renderer here: a model handed an array reads the
217
+ * array out, and a model handed a sentence summarises.
218
+ */
219
+ export function renderWorkspaces(result) {
220
+ const { workspaces, total } = result;
221
+ if (total === 0) {
222
+ return "You are not a member of any workspace yet.";
223
+ }
224
+
225
+ const lines = [`${plural(total, "workspace", "workspaces")} you can reach:`];
226
+ lines.push("");
227
+ for (const w of workspaces) {
228
+ const projects = plural(w.projectCount, "project", "projects");
229
+ lines.push(` ${w.name} — ${w.role}, ${projects}`);
230
+ lines.push(` ${w.id}`);
231
+ }
232
+ return lines.join("\n");
233
+ }
234
+
235
+ export function renderProjects(result) {
236
+ const { projects, total } = result;
237
+ if (total === 0) {
238
+ return "No projects you can reach. Create one in the Skyelight web app.";
239
+ }
240
+
241
+ const spans = new Set(projects.map((p) => p.workspace.id)).size > 1;
242
+ const lines = [`${plural(total, "project", "projects")} you can reach:`];
243
+ lines.push("");
244
+ for (const p of projects) {
245
+ // The workspace is only worth naming when there is more than one in
246
+ // play; repeating it down a single-workspace list is noise.
247
+ lines.push(` ${p.name}${spans ? ` (${p.workspace.name})` : ""}`);
248
+ lines.push(` ${p.id}${p.repo ? ` — ${p.repo}` : ""}`);
249
+ }
250
+ lines.push("");
251
+ lines.push(
252
+ "Pass one of these ids as projectId, or write it to .skyelight.json in " +
253
+ "the repo so it is the default from now on.",
254
+ );
255
+ return lines.join("\n");
256
+ }
257
+
258
+ export function renderList(result, { searched } = {}) {
259
+ const { project, summary, items, matched, truncated } = result;
260
+ const lines = [];
261
+
262
+ lines.push(
263
+ `${project.name}: ${plural(summary.total, "item", "items")} total — ` +
264
+ `${summary.open} open, ${summary.deferred ?? 0} deferred, ` +
265
+ `${summary.resolved} resolved, ${summary.unassigned} unassigned.`,
266
+ );
267
+
268
+ const byType = topBreakdown(summary.byType);
269
+ if (byType) lines.push(`By type: ${byType}.`);
270
+ const byPage = topBreakdown(summary.byPage);
271
+ if (byPage) lines.push(`Busiest pages: ${byPage}.`);
272
+
273
+ lines.push("");
274
+
275
+ if (items.length === 0) {
276
+ lines.push(
277
+ searched
278
+ ? `Nothing matched "${searched}".`
279
+ : "Nothing matched those filters.",
280
+ );
281
+ return lines.join("\n");
282
+ }
283
+
284
+ lines.push(
285
+ truncated
286
+ ? `Showing ${items.length} of ${matched} matches — narrow the filters to see the rest:`
287
+ : `${plural(items.length, "match", "matches")}:`,
288
+ );
289
+
290
+ for (const i of items) {
291
+ const bits = [i.type ?? "unsorted", i.status, i.page];
292
+ if (i.assignee) bits.push(`assigned to ${i.assignee}`);
293
+ else bits.push("unassigned");
294
+ if (i.replyCount > 0) bits.push(plural(i.replyCount, "reply", "replies"));
295
+ lines.push(`- ${i.excerpt}`);
296
+ lines.push(` ${bits.join(" · ")} — id ${i.id}`);
297
+ }
298
+
299
+ lines.push("");
300
+ lines.push(
301
+ "Use get_item with an id for the full thread and the element it points at.",
302
+ );
303
+ return lines.join("\n");
304
+ }
305
+
306
+ export function renderItem(item) {
307
+ const lines = [];
308
+ lines.push(
309
+ `${item.type ?? "unsorted"} · ${item.status} · ${item.page}` +
310
+ (item.project ? ` · ${item.project.name}` : ""),
311
+ );
312
+ lines.push(`URL: ${item.url}`);
313
+ // The repo, when the project names one. Stated plainly and early: it is
314
+ // the difference between an agent knowing where to work and inferring it
315
+ // from a URL, which is how work lands in the wrong codebase.
316
+ const repo = item.project && item.project.repo;
317
+ if (repo) {
318
+ const where = [repo.url];
319
+ if (repo.branch) where.push(`branch ${repo.branch}`);
320
+ if (repo.pathPrefix) where.push(`under ${repo.pathPrefix}`);
321
+ lines.push(`Code: ${where.join(", ")}`);
322
+ }
323
+ if (item.assignee?.name) {
324
+ // The mode is the ask. An agent that reads "investigate" and opens a
325
+ // pull request has done the wrong job well.
326
+ const asked = {
327
+ investigate: "asked to investigate — report back, change nothing",
328
+ plan: "asked to plan — describe the change, do not make it",
329
+ fix: "asked to fix — make the change and open a pull request",
330
+ build: "asked to build — build it and open a pull request",
331
+ }[item.assignee.mode];
332
+ lines.push(
333
+ asked
334
+ ? `Assigned to ${item.assignee.name}, ${asked}`
335
+ : `Assigned to: ${item.assignee.name}`,
336
+ );
337
+ }
338
+ lines.push("");
339
+
340
+ lines.push(`${item.thread.root.author} wrote:`);
341
+ lines.push(item.thread.root.content);
342
+
343
+ if (item.thread.replies.length > 0) {
344
+ lines.push("");
345
+ lines.push(`${plural(item.thread.replies.length, "reply", "replies")}:`);
346
+ for (const r of item.thread.replies) {
347
+ lines.push(`- ${r.author}: ${r.content}`);
348
+ }
349
+ }
350
+
351
+ // Stated before the anchor: a screenshot answers "what did this look like"
352
+ // in one step, where a selector needs the page opened first.
353
+ if (item.evidence) {
354
+ if (item.evidence.screenshotUrl) {
355
+ lines.push("");
356
+ lines.push(`Screenshot: ${item.evidence.screenshotUrl}`);
357
+ } else if (item.evidence.expired) {
358
+ lines.push("");
359
+ lines.push(
360
+ "Screenshot: expired and deleted under this workspace's retention policy.",
361
+ );
362
+ }
363
+ }
364
+
365
+ lines.push("");
366
+ lines.push("They were pointing at:");
367
+ // elementText first: it is what a person would have named, and the one
368
+ // part of an anchor that stays true when the markup moves.
369
+ if (item.anchor.elementText) {
370
+ lines.push(` the element reading "${item.anchor.elementText}"`);
371
+ }
372
+ if (item.anchor.selectedText) {
373
+ lines.push(` with "${item.anchor.selectedText}" selected`);
374
+ }
375
+ lines.push(` selector: ${item.anchor.selector}`);
376
+ lines.push(
377
+ ` seen at ${item.anchor.viewport.width}x${item.anchor.viewport.height}`,
378
+ );
379
+
380
+ // Last and on its own line, because it is the line that changes what the
381
+ // reader does next: everything above describes the page, this names the
382
+ // file. Absent unless the build stamped it, and silent when it is — an
383
+ // agent that reads "no source" learns nothing it can act on.
384
+ if (item.source) {
385
+ lines.push("");
386
+ const where = item.source.line
387
+ ? `${item.source.file}, line ${item.source.line}`
388
+ : item.source.file;
389
+ // The build too, when the page carried one. Which commit was on the
390
+ // screen is the difference between a live bug and a stale pin — and the
391
+ // branch is what makes that commit findable when the page was a preview
392
+ // of work that has not landed on the main line.
393
+ const from = [];
394
+ if (item.source.build) from.push(`build ${item.source.build}`);
395
+ if (item.source.branch) from.push(`branch ${item.source.branch}`);
396
+ lines.push(
397
+ from.length
398
+ ? `Written by ${where}, in ${from.join(" on ")}.`
399
+ : `Written by ${where}.`,
400
+ );
401
+ }
402
+
403
+ return lines.join("\n");
404
+ }
405
+
406
+ export function renderPosted(result, { kind }) {
407
+ if (kind === "update") {
408
+ return (
409
+ "Posted to the thread. The person who reported this will see it where " +
410
+ "they left the feedback."
411
+ );
412
+ }
413
+ return `Raised a new item on ${result.page ?? "that page"}. id ${result.pinId}`;
414
+ }
415
+
416
+ export async function callTool(name, args, { client, config }) {
417
+ const projectId = args.projectId ?? config.projectId ?? undefined;
418
+
419
+ if (name === "list_workspaces") {
420
+ const result = await client.listWorkspaces();
421
+ return { text: renderWorkspaces(result), data: result };
422
+ }
423
+
424
+ if (name === "list_projects") {
425
+ const result = await client.listProjects({
426
+ workspaceId: args.workspaceId,
427
+ });
428
+ return { text: renderProjects(result), data: result };
429
+ }
430
+
431
+ if (name === "list_items") {
432
+ const result = await client.listItems({
433
+ projectId,
434
+ page: args.page,
435
+ type: args.type,
436
+ status: args.status,
437
+ assignee: args.assignee,
438
+ limit: args.limit,
439
+ });
440
+ return { text: renderList(result), data: result };
441
+ }
442
+
443
+ if (name === "search_items") {
444
+ if (!args.query?.trim()) throw new Error("search_items needs a query");
445
+ const result = await client.listItems({
446
+ projectId,
447
+ q: args.query,
448
+ status: args.status,
449
+ limit: args.limit,
450
+ });
451
+ return { text: renderList(result, { searched: args.query }), data: result };
452
+ }
453
+
454
+ if (name === "post_update") {
455
+ if (!args.itemId) throw new Error("post_update needs an itemId");
456
+ if (!args.body?.trim()) throw new Error("post_update needs a body");
457
+ const result = await client.postUpdate({
458
+ itemId: args.itemId,
459
+ body: args.body,
460
+ links: args.links,
461
+ });
462
+ return { text: renderPosted(result, { kind: "update" }), data: result };
463
+ }
464
+
465
+ if (name === "create_item") {
466
+ if (!args.url) throw new Error("create_item needs a url");
467
+ if (!args.body?.trim()) throw new Error("create_item needs a body");
468
+ const result = await client.createItem({
469
+ url: args.url,
470
+ body: args.body,
471
+ selector: args.selector,
472
+ projectId,
473
+ pageTitle: args.pageTitle,
474
+ });
475
+ return { text: renderPosted(result, { kind: "item" }), data: result };
476
+ }
477
+
478
+ if (name === "set_status") {
479
+ if (!args.itemId) throw new Error("set_status needs an itemId");
480
+ if (!args.status) throw new Error("set_status needs a status");
481
+ const res = await client.setStatus({
482
+ itemId: args.itemId,
483
+ status: args.status,
484
+ });
485
+ if (res?.error) throw new Error(res.error);
486
+ return res.changed
487
+ ? `Moved to ${res.status}.`
488
+ : `Already ${res.status} — nothing to do.`;
489
+ }
490
+
491
+ if (name === "get_item") {
492
+ if (!args.itemId) throw new Error("get_item needs an itemId");
493
+ const item = await client.getItem(args.itemId);
494
+ return { text: renderItem(item), data: item };
495
+ }
496
+
497
+ throw new Error(`Unknown tool: ${name}`);
498
+ }
499
+
500
+ export { PROJECT_HINT };