@kammcs/buildit-mcp 0.0.0-stage → 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.
@@ -0,0 +1,401 @@
1
+ /**
2
+ * Project tools: list_projects, describe_project, find_users.
3
+ */
4
+ import { z } from 'zod';
5
+ import { ApiError } from '../api/client.js';
6
+ import { defineTool } from '../toolsets/registry.js';
7
+ import { sanitizeLabel } from '../untrusted.js';
8
+ import { CursorInput, limitInput, NAME_CHARS, nextPageHint, PERSON_NAME_CHARS, ProjectKeyInput, scopesOf, wrapLines, } from './shared.js';
9
+ const label = (v, max = NAME_CHARS) => sanitizeLabel(v, max);
10
+ // ---------------------------------------------------------------------------
11
+ // list_projects
12
+ // ---------------------------------------------------------------------------
13
+ export const ProjectOut = z.object({
14
+ key: z.string(),
15
+ name: z.string(),
16
+ id: z.string(),
17
+ archived: z.boolean(),
18
+ sprints_enabled: z.boolean(),
19
+ releases_enabled: z.boolean(),
20
+ estimate_scale: z.string().describe('none, points or tshirt.'),
21
+ item_counts: z.object({
22
+ not_started: z.number(),
23
+ started: z.number(),
24
+ done: z.number(),
25
+ canceled: z.number(),
26
+ }),
27
+ });
28
+ /** A project as list_projects shows it; its name (the channel's) flattened to one line. */
29
+ export function projectOut(p) {
30
+ return {
31
+ key: p.key,
32
+ name: label(p.name),
33
+ id: p.id,
34
+ archived: p.archived,
35
+ sprints_enabled: p.sprints_enabled,
36
+ releases_enabled: p.releases_enabled,
37
+ estimate_scale: p.estimate_scale,
38
+ item_counts: {
39
+ not_started: p.item_counts.not_started,
40
+ started: p.item_counts.started,
41
+ done: p.item_counts.done,
42
+ canceled: p.item_counts.canceled,
43
+ },
44
+ };
45
+ }
46
+ export const listProjectsTool = defineTool({
47
+ name: 'list_projects',
48
+ toolset: 'items',
49
+ title: 'List projects',
50
+ description: 'Lists the projects the token can reach, with their keys (such as DEMO), names, settings (sprints, releases, estimate scale) and item counts by status category. Use it to find a project key; then describe_project for its statuses, types, labels, fields and members. Paged: pass next_cursor as cursor for more.',
51
+ scopes: scopesOf('list_projects'),
52
+ annotations: {
53
+ title: 'List projects',
54
+ readOnlyHint: true,
55
+ idempotentHint: true,
56
+ openWorldHint: false,
57
+ },
58
+ inputSchema: z.object({
59
+ include_archived: z.boolean().optional().describe('Also list archived projects.'),
60
+ cursor: CursorInput.optional(),
61
+ limit: limitInput(100, 25),
62
+ }),
63
+ outputSchema: z.object({
64
+ projects: z.array(ProjectOut),
65
+ next_cursor: z.string().nullable(),
66
+ }),
67
+ async run(args, ctx) {
68
+ const page = await ctx.call('list_projects', {
69
+ query: {
70
+ ...(args.include_archived ? { include_archived: 'true' } : {}),
71
+ ...(args.cursor ? { cursor: args.cursor } : {}),
72
+ ...(args.limit ? { limit: args.limit } : {}),
73
+ },
74
+ });
75
+ const projects = page.items.map(projectOut);
76
+ const lines = projects.map((p) => {
77
+ const c = p.item_counts;
78
+ return `- ${p.key}: ${p.name}${p.archived ? ' (archived)' : ''} · ${c.not_started} not started, ${c.started} started, ${c.done} done, ${c.canceled} canceled`;
79
+ });
80
+ const text = [
81
+ projects.length === 0 ? 'No projects in reach.' : `${projects.length} project(s):`,
82
+ ...(lines.length > 0 ? [wrapLines(lines, 'project_list')] : []),
83
+ nextPageHint('list_projects', page.next_cursor),
84
+ ].join('\n');
85
+ return { structured: { projects, next_cursor: page.next_cursor }, text };
86
+ },
87
+ });
88
+ // ---------------------------------------------------------------------------
89
+ // describe_project
90
+ // ---------------------------------------------------------------------------
91
+ const MemberOut = z.object({
92
+ id: z.string(),
93
+ name: z.string(),
94
+ email: z.string().nullable(),
95
+ role: z.string().describe('owner, admin, member or guest (in the org).'),
96
+ is_project_admin: z.boolean(),
97
+ });
98
+ function memberOut(m) {
99
+ return {
100
+ id: m.id,
101
+ name: label(m.display_name, PERSON_NAME_CHARS),
102
+ email: m.email === null ? null : label(m.email, 320),
103
+ role: m.role,
104
+ is_project_admin: m.is_project_admin,
105
+ };
106
+ }
107
+ function memberText(m) {
108
+ const who = m.email ? `${m.name} <${m.email}>` : m.name;
109
+ const role = [m.role, ...(m.is_project_admin ? ['project admin'] : [])].join(', ');
110
+ return `${who} (${role})`;
111
+ }
112
+ const TransitionOut = z.object({
113
+ to: z.string(),
114
+ required_fields: z.array(z.string()),
115
+ admins_only: z.boolean(),
116
+ });
117
+ const DescribeOut = z.object({
118
+ project: z.object({
119
+ key: z.string(),
120
+ name: z.string(),
121
+ id: z.string(),
122
+ archived: z.boolean(),
123
+ sprints_enabled: z.boolean(),
124
+ releases_enabled: z.boolean(),
125
+ estimate_scale: z.string(),
126
+ estimate_values: z
127
+ .array(z.number())
128
+ .describe('The allowed estimates; empty means any (points) or the t-shirt sizes.'),
129
+ default_type: z.string().nullable(),
130
+ time_zone: z.string(),
131
+ }),
132
+ types: z.array(z.object({
133
+ name: z.string(),
134
+ level: z.string().describe('epic, standard or subtask.'),
135
+ workflow: z.string(),
136
+ })),
137
+ workflows: z.array(z.object({
138
+ name: z.string(),
139
+ types: z.array(z.string()),
140
+ restrict_transitions: z
141
+ .boolean()
142
+ .describe('true: only the listed moves are allowed; false: any move.'),
143
+ statuses: z.array(z.object({
144
+ name: z.string(),
145
+ category: z.string(),
146
+ is_initial: z.boolean(),
147
+ board_column: z.string().nullable(),
148
+ allowed: z.array(TransitionOut).describe('Moves allowed from this status.'),
149
+ })),
150
+ })),
151
+ labels: z.array(z.string()),
152
+ fields: z.array(z.object({
153
+ name: z.string(),
154
+ kind: z.string(),
155
+ options: z.array(z.string()),
156
+ types: z.array(z.string()).nullable().describe('Types it applies to; null means all.'),
157
+ required_on_create: z.boolean(),
158
+ })),
159
+ members: z.array(MemberOut),
160
+ });
161
+ const MAX_MEMBERS_IN_TEXT = 40;
162
+ function describeOut(r) {
163
+ const p = r.project;
164
+ return {
165
+ project: {
166
+ key: p.key,
167
+ name: label(p.name),
168
+ id: p.id,
169
+ archived: p.archived,
170
+ sprints_enabled: p.sprints_enabled,
171
+ releases_enabled: p.releases_enabled,
172
+ estimate_scale: p.estimate_scale,
173
+ estimate_values: p.estimate_values,
174
+ default_type: p.default_type === null ? null : label(p.default_type, 60),
175
+ time_zone: label(p.time_zone, 60),
176
+ },
177
+ types: r.types.map((t) => ({
178
+ name: label(t.name, 60),
179
+ level: t.level,
180
+ workflow: label(t.workflow.name, 60),
181
+ })),
182
+ workflows: r.workflows.map((w) => ({
183
+ name: label(w.name, 60),
184
+ types: w.types.map((t) => label(t, 60)),
185
+ restrict_transitions: w.restrict_transitions,
186
+ statuses: w.statuses.map((s) => ({
187
+ name: label(s.name, 60),
188
+ category: s.category,
189
+ is_initial: s.is_initial,
190
+ board_column: s.board_column === null ? null : label(s.board_column, 60),
191
+ allowed: s.allowed.map((a) => ({
192
+ to: label(a.to, 60),
193
+ required_fields: a.required_fields.map((f) => label(f, 60)),
194
+ admins_only: a.admins_only,
195
+ })),
196
+ })),
197
+ })),
198
+ labels: r.labels.map((l) => label(l.name, 40)),
199
+ fields: r.fields.map((f) => ({
200
+ name: label(f.name, 60),
201
+ kind: f.kind,
202
+ options: f.options.map((o) => label(o.label)),
203
+ types: f.types === null ? null : f.types.map((t) => label(t, 60)),
204
+ required_on_create: f.required_on_create,
205
+ })),
206
+ members: r.members.map(memberOut),
207
+ };
208
+ }
209
+ /** "Done (needs assignee; admins only)" */
210
+ export function transitionText(a) {
211
+ const notes = [
212
+ ...(a.required_fields.length > 0 ? [`needs ${a.required_fields.join(', ')}`] : []),
213
+ ...(a.admins_only ? ['project admins only'] : []),
214
+ ];
215
+ return notes.length > 0 ? `${a.to} (${notes.join('; ')})` : a.to;
216
+ }
217
+ /**
218
+ * A workflow's statuses and moves, as lines. A workflow that restricts
219
+ * transitions lists the moves from each status. One that doesn't allows any
220
+ * move ("any status → any status"), so only the moves with rules (required
221
+ * fields, project admins only) are listed.
222
+ */
223
+ export function workflowLines(restrict, statuses) {
224
+ const head = (s) => `${s.name} [${s.category}${s.is_initial ? ', initial' : ''}]${s.id ? ` (id ${s.id})` : ''}`;
225
+ if (restrict) {
226
+ return [
227
+ 'Moves: only the ones listed below.',
228
+ ...statuses.map((s) => `- ${head(s)} -> ${s.allowed.map(transitionText).join('; ') || 'none'}`),
229
+ ];
230
+ }
231
+ const rules = statuses.flatMap((s) => s.allowed
232
+ .filter((a) => a.admins_only || a.required_fields.length > 0)
233
+ .map((a) => `${s.name} → ${transitionText(a)}`));
234
+ return [
235
+ 'Moves: any status → any status.',
236
+ ...statuses.map((s) => `- ${head(s)}`),
237
+ `Moves with rules: ${rules.join('; ') || 'none'}.`,
238
+ ];
239
+ }
240
+ function describeText(d) {
241
+ const p = d.project;
242
+ const estimates = p.estimate_scale === 'none'
243
+ ? 'no estimates'
244
+ : `estimates in ${p.estimate_scale}${p.estimate_values.length > 0 ? ` (${p.estimate_values.join(', ')})` : ''}`;
245
+ const lines = [
246
+ `Name: ${p.name}`,
247
+ `Settings: ${estimates}; sprints ${p.sprints_enabled ? 'on' : 'off'}; releases ${p.releases_enabled ? 'on' : 'off'}; default type ${p.default_type ?? 'none'}; time zone ${p.time_zone}.`,
248
+ `Types: ${d.types.map((t) => `${t.name} (${t.level}, workflow ${t.workflow})`).join('; ') || 'none'}.`,
249
+ ];
250
+ for (const w of d.workflows) {
251
+ lines.push(`Workflow ${w.name} (types: ${w.types.join(', ') || 'none'}):`);
252
+ lines.push(...workflowLines(w.restrict_transitions, w.statuses));
253
+ }
254
+ lines.push(`Labels: ${d.labels.join(', ') || 'none'}.`);
255
+ lines.push(`Custom fields: ${d.fields
256
+ .map((f) => {
257
+ const extra = [
258
+ f.kind,
259
+ ...(f.options.length > 0 ? [`options ${f.options.join(', ')}`] : []),
260
+ ...(f.required_on_create ? ['required on create'] : []),
261
+ ...(f.types ? [`types ${f.types.join(', ')}`] : []),
262
+ ];
263
+ return `${f.name} (${extra.join('; ')})`;
264
+ })
265
+ .join('; ') || 'none'}.`);
266
+ const shown = d.members.slice(0, MAX_MEMBERS_IN_TEXT).map(memberText);
267
+ const more = d.members.length > MAX_MEMBERS_IN_TEXT
268
+ ? `; and ${d.members.length - MAX_MEMBERS_IN_TEXT} more (use find_users)`
269
+ : '';
270
+ lines.push(`Members (${d.members.length}): ${shown.join('; ')}${more}.`);
271
+ // Every name here (statuses, types, labels, fields, people) was written by people.
272
+ return [
273
+ `Project ${p.key}${p.archived ? ' (archived)' : ''}: its configuration, with the names people gave it:`,
274
+ wrapLines(lines, 'project_config'),
275
+ ].join('\n');
276
+ }
277
+ async function readProject(ctx, key) {
278
+ return ctx.call('describe_project', { path: { key } });
279
+ }
280
+ export const describeProjectTool = defineTool({
281
+ name: 'describe_project',
282
+ toolset: 'items',
283
+ title: 'Describe a project',
284
+ description: "Describes one project: its types and levels and the workflow each follows; each workflow's statuses with the moves allowed from each status and the fields those moves require; labels; custom fields with their options; the estimate scale and allowed values; and the members, who can be assigned or mentioned. Call it for names you don't know before writing (reads don't need it; a refused move or unknown name lists what is allowed). Names ignore case.",
285
+ scopes: scopesOf('describe_project'),
286
+ annotations: {
287
+ title: 'Describe a project',
288
+ readOnlyHint: true,
289
+ idempotentHint: true,
290
+ openWorldHint: false,
291
+ },
292
+ inputSchema: z.object({ project: ProjectKeyInput }),
293
+ outputSchema: DescribeOut,
294
+ async run(args, ctx) {
295
+ const structured = describeOut(await readProject(ctx, args.project));
296
+ return { structured, text: describeText(structured) };
297
+ },
298
+ });
299
+ // ---------------------------------------------------------------------------
300
+ // find_users
301
+ // ---------------------------------------------------------------------------
302
+ function normalize(s) {
303
+ return s.normalize('NFKC').toLowerCase().trim();
304
+ }
305
+ /** An API from before the members route answers 404 with kind "route". */
306
+ function noMembersRoute(err) {
307
+ if (!(err instanceof ApiError) || err.code !== 'not_found')
308
+ return false;
309
+ const details = err.details;
310
+ return details?.kind === 'route';
311
+ }
312
+ export const findUsersTool = defineTool({
313
+ name: 'find_users',
314
+ toolset: 'items',
315
+ title: 'Find people in a project',
316
+ description: 'Finds people among a project\'s members, the people who can be assigned items or mentioned there: by part of a name or email (case-insensitive), or "me". Leave query out to list everyone, by display name, one page at a time (pass next_cursor as cursor for more). Returns names, emails, org roles and whether each is a project admin. Use the email (or "me") to assign or mention someone; a display name works only when it is unique, and this tells two people with the same name apart.',
317
+ scopes: scopesOf('list_project_members'),
318
+ annotations: {
319
+ title: 'Find people in a project',
320
+ readOnlyHint: true,
321
+ idempotentHint: true,
322
+ openWorldHint: false,
323
+ },
324
+ inputSchema: z.object({
325
+ project: ProjectKeyInput,
326
+ query: z
327
+ .string()
328
+ .max(100)
329
+ .optional()
330
+ .describe('Part of a display name or email, case-insensitive; "me" for yourself. Leave it out to list everyone.'),
331
+ cursor: CursorInput.optional(),
332
+ limit: limitInput(100, 25),
333
+ }),
334
+ outputSchema: z.object({
335
+ project: z.string(),
336
+ users: z.array(MemberOut),
337
+ next_cursor: z.string().nullable(),
338
+ }),
339
+ async run(args, ctx) {
340
+ const query = args.query?.trim() ?? '';
341
+ const limit = args.limit ?? 25;
342
+ let me;
343
+ if (normalize(query) === 'me')
344
+ me = (await ctx.api.getMe(ctx.apiOptions)).user;
345
+ const q = me ? (me.email ?? me.display_name) : query;
346
+ let members;
347
+ let next;
348
+ let fallback = false;
349
+ try {
350
+ const page = await ctx.call('list_project_members', {
351
+ path: { key: args.project },
352
+ query: {
353
+ ...(q !== '' ? { q: q.slice(0, 100) } : {}),
354
+ ...(args.cursor ? { cursor: args.cursor } : {}),
355
+ limit,
356
+ },
357
+ });
358
+ members = page.items;
359
+ next = page.next_cursor;
360
+ }
361
+ catch (err) {
362
+ if (!noMembersRoute(err))
363
+ throw err;
364
+ // An older API: filter describe_project's members here, in one page.
365
+ fallback = true;
366
+ const needle = normalize(q);
367
+ members = (await readProject(ctx, args.project)).members.filter((m) => needle === '' ||
368
+ normalize(m.display_name).includes(needle) ||
369
+ (m.email !== null && normalize(m.email).includes(needle)));
370
+ next = null;
371
+ }
372
+ if (me) {
373
+ const id = me.id;
374
+ members = members.filter((m) => m.id === id);
375
+ next = null;
376
+ }
377
+ const users = members.slice(0, limit).map(memberOut);
378
+ const key = args.project.toUpperCase();
379
+ const header = users.length === 0
380
+ ? `No member of ${key} matches${query ? ` "${sanitizeLabel(query, 100)}"` : ''}${args.cursor ? ' in this page' : ''}.`
381
+ : `${users.length} member(s) of ${key}${query ? ` matching "${sanitizeLabel(query, 100)}"` : ''}:`;
382
+ const lines = [
383
+ header,
384
+ ...(users.length > 0
385
+ ? [
386
+ wrapLines(users.map((u) => `- ${memberText(u)}`), 'user_list'),
387
+ ]
388
+ : []),
389
+ ];
390
+ if (fallback && members.length > users.length) {
391
+ lines.push(`Showing ${users.length} of ${members.length}; narrow the query or raise limit.`);
392
+ }
393
+ else {
394
+ lines.push(nextPageHint('find_users', next));
395
+ }
396
+ return {
397
+ structured: { project: key, users, next_cursor: next },
398
+ text: lines.join('\n'),
399
+ };
400
+ },
401
+ });