@kammcs/buildit-mcp 0.0.0-stage → 0.2.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,292 @@
1
+ /**
2
+ * Setting up work, in the admin toolset: create_channel and create_project.
3
+ *
4
+ * Neither changes or removes anything that exists, so they apply directly
5
+ * (no plan): create_channel makes a new channel, and create_project turns a
6
+ * channel into a project from a template. Both need projects:admin and a
7
+ * token without project or channel limits, so they aren't listed for a
8
+ * limited token (`unlimitedOnly`). Both take an idempotency key, generated
9
+ * and returned when the agent gives none, so a retry can't create twice.
10
+ */
11
+ import { randomUUID } from 'node:crypto';
12
+ import { z } from 'zod';
13
+ import { ApiError } from '../api/client.js';
14
+ import { ToolInputError } from '../errors.js';
15
+ import { defineTool } from '../toolsets/registry.js';
16
+ import { sanitizeLabel, wrapUntrusted } from '../untrusted.js';
17
+ import { ChannelOut, channelOut } from './chat.js';
18
+ import { ChannelRefInput } from './pages.js';
19
+ import { ProjectOut, projectOut } from './projects.js';
20
+ import { IdempotencyKeyInput, NAME_CHARS, PersonSchema, person, personText, scopesOf, } from './shared.js';
21
+ /** The note for a token with limits: these tools need one without. */
22
+ const LIMITS_NOTE = 'Creating channels and projects needs a token without project or channel limits, so this token cannot do it. Ask the person for a token with projects:admin and no limits.';
23
+ const NEW_KEY_NOTE = 'Leave idempotency_key out to have a new one generated (or send a new random uuid).';
24
+ /** One entry of an error's details, if the details are an object. */
25
+ function detail(err, key) {
26
+ const d = err.details;
27
+ return d !== null && typeof d === 'object' && key in d
28
+ ? d[key]
29
+ : undefined;
30
+ }
31
+ /** The paths of a validation error's fields. */
32
+ function fieldPaths(err) {
33
+ const fields = detail(err, 'fields');
34
+ if (!Array.isArray(fields))
35
+ return [];
36
+ return fields.map((f) => f !== null && typeof f === 'object' && 'path' in f ? String(f.path) : '');
37
+ }
38
+ /** Calls the API; an error gets the tool's note for its code, when it has one. */
39
+ async function withNotes(call, note) {
40
+ try {
41
+ return await call();
42
+ }
43
+ catch (err) {
44
+ if (err instanceof ApiError) {
45
+ const text = note(err);
46
+ if (text !== undefined)
47
+ throw err.withNote(text);
48
+ }
49
+ throw err;
50
+ }
51
+ }
52
+ // ---------------------------------------------------------------------------
53
+ // create_channel
54
+ // ---------------------------------------------------------------------------
55
+ function channelNote(err) {
56
+ switch (err.code) {
57
+ case 'validation':
58
+ return fieldPaths(err).includes('name')
59
+ ? 'The name is taken: channel names are unique in the org, ignoring case. Ask the person for another name, or use the existing channel (list_channels lists the ones they belong to).'
60
+ : 'name is 1 to 100 characters, description at most 1000, members at most 50 people.';
61
+ case 'not_found':
62
+ return 'Nothing was created. Name each member by the email of an active person in the org (or their exact display name or id), then call create_channel again, without the unknown person or with their email.';
63
+ case 'ambiguous':
64
+ return 'Nothing was created. Name that person by their email.';
65
+ case 'outside_limits':
66
+ return LIMITS_NOTE;
67
+ case 'forbidden':
68
+ return detail(err, 'reason') === 'guest'
69
+ ? "Guests can't create channels. Nothing was created."
70
+ : 'The person may not create this channel. Nothing was created.';
71
+ case 'idempotency_conflict':
72
+ return `That key was used for something else, or for a channel with another name or visibility. ${NEW_KEY_NOTE}`;
73
+ default:
74
+ return undefined;
75
+ }
76
+ }
77
+ export const createChannelTool = defineTool({
78
+ name: 'create_channel',
79
+ toolset: 'admin',
80
+ title: 'Create a channel',
81
+ description: `Creates a channel in the org, as the person, who becomes its first member.
82
+ - visibility: "public" (the org's members, not guests, can find and join it) or "private" (only its members see it).
83
+ - name: 1 to 100 characters, unique in the org (ignoring case). description is optional.
84
+ - members: other people to add, by email (best), exact display name or id; active people of the org, guests included; at most 50. A first name alone ("Sam") is not enough: find_users in a project they share with the person gives their email. An unknown person fails the call and nothing is created.
85
+ - idempotency_key: retrying with the same key returns the first channel instead of creating a second, and adds listed people still missing; one is generated and returned if you leave it out.
86
+ To start a project, create the channel here, then call create_project with it. Needs a token without project or channel limits.
87
+ Returns the channel with its id, and its members.`,
88
+ scopes: scopesOf('create_channel'),
89
+ unlimitedOnly: true,
90
+ annotations: {
91
+ title: 'Create a channel',
92
+ readOnlyHint: false,
93
+ destructiveHint: false,
94
+ idempotentHint: false,
95
+ openWorldHint: false,
96
+ },
97
+ inputSchema: z.object({
98
+ name: z
99
+ .string()
100
+ .min(1)
101
+ .max(100)
102
+ .refine((s) => s.trim().length > 0, 'The name cannot be blank.')
103
+ .describe('The channel name, unique in the org.'),
104
+ visibility: z.enum(['public', 'private']).describe('public or private (ask the person).'),
105
+ description: z.string().max(1000).optional().describe('What the channel is for.'),
106
+ members: z
107
+ .array(z.string().min(1).max(320))
108
+ .max(50)
109
+ .optional()
110
+ .describe('People to add besides the person: emails (best), exact display names ("Sam Example", not "Sam") or ids.'),
111
+ idempotency_key: IdempotencyKeyInput.optional(),
112
+ }),
113
+ outputSchema: z.object({
114
+ channel: ChannelOut,
115
+ members: z.array(PersonSchema).describe("The channel's members after the call."),
116
+ created: z.boolean().describe('false: an earlier call with this idempotency_key created it.'),
117
+ idempotency_key: z.string(),
118
+ }),
119
+ async run(args, ctx) {
120
+ const key = args.idempotency_key ?? randomUUID();
121
+ const r = await withNotes(() => ctx.call('create_channel', {
122
+ body: {
123
+ name: args.name,
124
+ visibility: args.visibility,
125
+ ...(args.description !== undefined ? { description: args.description } : {}),
126
+ ...(args.members !== undefined && args.members.length > 0
127
+ ? { members: args.members }
128
+ : {}),
129
+ idempotency_key: key,
130
+ },
131
+ }), channelNote);
132
+ const channel = channelOut(r.channel);
133
+ const members = r.members.map(person);
134
+ const headline = r.created
135
+ ? `Created ${channel.visibility} channel ${channel.id}.`
136
+ : `Nothing new: channel ${channel.id} was already created with this idempotency_key; listed people who were missing were added.`;
137
+ const facts = wrapUntrusted([
138
+ `Name: ${channel.name}`,
139
+ `Members (${members.length}): ${members.map(personText).join(', ') || 'none'}`,
140
+ ].join('\n'), { source: 'channel', maxChars: 20_000 });
141
+ const next = channel.project === null
142
+ ? `Next: to make it a project, call create_project with channel="${channel.id}" and a key.`
143
+ : `It is the channel of project ${channel.project}.`;
144
+ return {
145
+ structured: { channel, members, created: r.created, idempotency_key: key },
146
+ text: [
147
+ headline,
148
+ facts,
149
+ ...(channel.description ? [channel.description] : []),
150
+ `idempotency_key: ${key}`,
151
+ next,
152
+ ].join('\n'),
153
+ };
154
+ },
155
+ });
156
+ // ---------------------------------------------------------------------------
157
+ // create_project
158
+ // ---------------------------------------------------------------------------
159
+ const TEMPLATES = ['software_scrum', 'software_kanban', 'design', 'ops', 'marketing'];
160
+ function projectNote(err) {
161
+ switch (err.code) {
162
+ case 'validation': {
163
+ const paths = fieldPaths(err);
164
+ if (paths.includes('key')) {
165
+ return 'The key is taken (another project uses it or used it before; keys stay reserved) or not 2 to 6 letters and digits starting with a letter. Ask the person for another key; list_projects shows the keys in use.';
166
+ }
167
+ if (paths.includes('channel')) {
168
+ return 'That channel is already a project (an archived one counts); list_projects shows the projects. For a new project, create a channel with create_channel first.';
169
+ }
170
+ if (paths.some((p) => p.startsWith('estimate_values'))) {
171
+ return 'estimate_values go only with estimate_scale "points": numbers from 0 to 9999.99, at most 30.';
172
+ }
173
+ return 'Check key, template and the estimate settings against the tool description, then retry.';
174
+ }
175
+ case 'not_found':
176
+ return 'The person must be a member of the channel; list_channels lists theirs, and create_channel makes a new one.';
177
+ case 'ambiguous':
178
+ return 'Name the channel by its id.';
179
+ case 'archived':
180
+ return 'Or pick another channel (create_channel makes a new one).';
181
+ case 'forbidden':
182
+ return detail(err, 'reason') === 'admins_only'
183
+ ? 'Only org admins can turn an org-wide channel into a project. Pick another channel, or create one with create_channel.'
184
+ : 'The person may not turn this channel into a project. Pick another channel, or create one with create_channel.';
185
+ case 'projects_off':
186
+ return 'An org admin can turn Projects on in buildIt.Social; create_channel works without it.';
187
+ case 'outside_limits':
188
+ return LIMITS_NOTE;
189
+ case 'idempotency_conflict':
190
+ return `That key was used for something else, or for another channel or project key. ${NEW_KEY_NOTE}`;
191
+ default:
192
+ return undefined;
193
+ }
194
+ }
195
+ const CreatedProjectOut = ProjectOut.extend({
196
+ template: z.string().describe('The template it started from.'),
197
+ channel: z.object({ id: z.string(), name: z.string() }),
198
+ });
199
+ export const createProjectTool = defineTool({
200
+ name: 'create_project',
201
+ toolset: 'admin',
202
+ title: 'Create a project',
203
+ description: `Turns a channel the person belongs to into a project, from a template: types, workflows, board, fields, labels and starter pages. The person becomes its project admin.
204
+ - The project's name is the channel's name; there is no separate project name. For a new project, call create_channel first, then this with that channel.
205
+ - channel: its name or id (list_channels); not archived, and not a project already.
206
+ - key: 2 to 6 characters, a letter then letters or digits, such as DEMO (any case; stored in upper case); items are numbered DEMO-1, DEMO-2. Agree it with the person: it can't easily be changed later, and a key in use or used before in the org is refused.
207
+ - template: software_scrum (default: sprints, story points), software_kanban (a board and releases), design, ops (requests and incidents) or marketing. sprints_enabled, releases_enabled and estimate_scale override the template's; estimate_values only with estimate_scale "points".
208
+ - idempotency_key: retrying with the same key returns the first project instead of creating a second; one is generated and returned if you leave it out.
209
+ Needs the org's Projects feature and a token without project or channel limits. Returns the project as list_projects shows it; then describe_project shows the template's configuration.`,
210
+ scopes: scopesOf('create_project'),
211
+ unlimitedOnly: true,
212
+ annotations: {
213
+ title: 'Create a project',
214
+ readOnlyHint: false,
215
+ destructiveHint: false,
216
+ idempotentHint: false,
217
+ openWorldHint: false,
218
+ },
219
+ inputSchema: z.object({
220
+ channel: ChannelRefInput,
221
+ key: z
222
+ .string()
223
+ .regex(/^[A-Za-z][A-Za-z0-9]{1,5}$/, 'A letter, then 1 to 5 letters or digits.')
224
+ .describe('The new project key, such as DEMO.'),
225
+ template: z.enum(TEMPLATES).optional().describe('Default software_scrum.'),
226
+ sprints_enabled: z.boolean().optional(),
227
+ releases_enabled: z.boolean().optional(),
228
+ estimate_scale: z.enum(['none', 'points', 'tshirt']).optional(),
229
+ estimate_values: z
230
+ .array(z.number().min(0).max(9999.99))
231
+ .min(1)
232
+ .max(30)
233
+ .optional()
234
+ .describe('The points allowed (default 1, 2, 3, 5, 8, 13).'),
235
+ idempotency_key: IdempotencyKeyInput.optional(),
236
+ }),
237
+ outputSchema: z.object({
238
+ project: CreatedProjectOut,
239
+ created: z.boolean().describe('false: an earlier call with this idempotency_key created it.'),
240
+ idempotency_key: z.string(),
241
+ }),
242
+ async run(args, ctx) {
243
+ if (args.estimate_values !== undefined &&
244
+ args.estimate_scale !== undefined &&
245
+ args.estimate_scale !== 'points') {
246
+ throw new ToolInputError('estimate_values go only with estimate_scale "points": drop one of them.');
247
+ }
248
+ const key = args.idempotency_key ?? randomUUID();
249
+ const r = await withNotes(() => ctx.call('create_project', {
250
+ body: {
251
+ channel: args.channel,
252
+ key: args.key,
253
+ ...(args.template !== undefined ? { template: args.template } : {}),
254
+ ...(args.sprints_enabled !== undefined
255
+ ? { sprints_enabled: args.sprints_enabled }
256
+ : {}),
257
+ ...(args.releases_enabled !== undefined
258
+ ? { releases_enabled: args.releases_enabled }
259
+ : {}),
260
+ ...(args.estimate_scale !== undefined ? { estimate_scale: args.estimate_scale } : {}),
261
+ ...(args.estimate_values !== undefined
262
+ ? { estimate_values: args.estimate_values }
263
+ : {}),
264
+ idempotency_key: key,
265
+ },
266
+ }), projectNote);
267
+ const p = r.project;
268
+ const project = {
269
+ ...projectOut(p),
270
+ template: sanitizeLabel(p.template_key, 60),
271
+ channel: { id: p.channel.id, name: sanitizeLabel(p.channel.name, NAME_CHARS) },
272
+ };
273
+ const headline = r.created
274
+ ? `Created project ${project.key} from the ${project.template} template, in channel ${project.channel.id}; the person is its project admin.`
275
+ : `Nothing new: project ${project.key} was already created with this idempotency_key.`;
276
+ const settings = [
277
+ `sprints ${project.sprints_enabled ? 'on' : 'off'}`,
278
+ `releases ${project.releases_enabled ? 'on' : 'off'}`,
279
+ `estimates ${project.estimate_scale}`,
280
+ ].join(', ');
281
+ return {
282
+ structured: { project, created: r.created, idempotency_key: key },
283
+ text: [
284
+ headline,
285
+ wrapUntrusted(`Name (the channel's): ${project.name}`, { source: 'project' }),
286
+ `Settings: ${settings}.`,
287
+ `idempotency_key: ${key}`,
288
+ `Next: describe_project with project="${project.key}" shows its types, workflows, fields and labels; create_item adds items (${project.key}-1, ...).`,
289
+ ].join('\n'),
290
+ };
291
+ },
292
+ });