@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,462 @@
1
+ /**
2
+ * Shapes and helpers shared by the item and comment tools.
3
+ *
4
+ * Every tool returns structured content (matching its outputSchema) and a
5
+ * short markdown summary. People-written text is never passed through as is:
6
+ *
7
+ * - short labels (names of people, the org, tokens, projects, channels,
8
+ * statuses, types, labels, fields, sprints, releases, and item titles in
9
+ * structured content) go through sanitizeLabel: one line, defused, capped
10
+ * (PERSON_NAME_CHARS for people, NAME_CHARS for other names);
11
+ * - free text (descriptions, comments, search snippets, history values) goes
12
+ * through wrapUntrusted, in structured content and in the text;
13
+ * - in the text, every label is inside an <untrusted_content> block too: a
14
+ * list is one block (wrapLines), and the names of one thing are a block of
15
+ * their own, so the server's own words are all that stands outside.
16
+ */
17
+ import { z } from 'zod';
18
+ import { OPERATIONS, PLAN_SCOPES, } from '../api/generated/operations.js';
19
+ import { ToolInputError } from '../errors.js';
20
+ import { sanitizeLabel, wrapUntrusted } from '../untrusted.js';
21
+ import { compareVersions } from '../version.js';
22
+ // ---------------------------------------------------------------------------
23
+ // Scopes and annotations from the contract
24
+ // ---------------------------------------------------------------------------
25
+ /**
26
+ * Every scope the given operations need, from the contract's
27
+ * x-buildit-required-scopes (or x-buildit-scope where that is absent): a
28
+ * tool needs all of them. Plan operations (create_plan, apply_plan) take
29
+ * the scopes of the plan's action: pass the actions to planScopesOf instead.
30
+ */
31
+ export function scopesOf(...ids) {
32
+ const out = new Set();
33
+ for (const id of ids) {
34
+ if (OPERATIONS[id].scope === 'plan_action') {
35
+ throw new Error(`${id} takes its plan action's scopes; use planScopesOf`);
36
+ }
37
+ for (const scope of OPERATIONS[id].requiredScopes)
38
+ out.add(scope);
39
+ }
40
+ return [...out];
41
+ }
42
+ /**
43
+ * Whether the API is at least `version`: true or false when /v1/meta has
44
+ * said (stdio reads it at startup), undefined when it hasn't (the API then
45
+ * answers for itself).
46
+ */
47
+ export function apiAtLeast(api, version) {
48
+ const known = api.served?.apiVersion;
49
+ return known === undefined ? undefined : compareVersions(known, version) >= 0;
50
+ }
51
+ /** Refuses `what` when the API is known to be older than `version`. */
52
+ export function requireApi(api, version, what) {
53
+ if (apiAtLeast(api, version) === false) {
54
+ throw new ToolInputError(`${what} needs buildIt.Social's agent API ${version} or newer, and this server runs ${api.served?.apiVersion ?? 'an older one'}. Leave it out; the person's buildIt.Social server needs an update for it.`);
55
+ }
56
+ }
57
+ /** Every scope the given plan actions need, from the contract's x-buildit-plan-required-scopes. */
58
+ export function planScopesOf(...actions) {
59
+ return [...new Set(actions.flatMap((a) => PLAN_SCOPES[a]))];
60
+ }
61
+ // ---------------------------------------------------------------------------
62
+ // Input fields
63
+ // ---------------------------------------------------------------------------
64
+ const ITEM_REF_RE = /^(#?[A-Za-z][A-Za-z0-9]{1,5}-[1-9][0-9]{0,8}|[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12})$/;
65
+ export const ItemRefInput = z
66
+ .string()
67
+ .regex(ITEM_REF_RE, 'An item key such as DEMO-12, or an item uuid.')
68
+ .describe('An item key such as DEMO-12 (any case, a leading # is fine) or the item uuid.');
69
+ export const ProjectKeyInput = z
70
+ .string()
71
+ .regex(/^[A-Za-z][A-Za-z0-9]{1,5}$/, 'A project key such as DEMO.')
72
+ .describe('A project key such as DEMO (any case). whoami and list_projects list them.');
73
+ export const UserRefInput = z
74
+ .string()
75
+ .min(1)
76
+ .max(320)
77
+ .describe('A person: "me", an email, or an exact display name, among the project\'s members (find_users lists them).');
78
+ /**
79
+ * How images and their layout are written in descriptions and pages (the
80
+ * contract's MarkdownDescription and PageBody), for the input fields.
81
+ */
82
+ export const IMAGE_LAYOUT_NOTE = 'Images: ![alt](file:<id> "40% wrap-left gap-m"); an optional title sets the layout: width 10%-100% in steps of 5, left|center|right|wrap-left|wrap-right, gap-s|gap-m|gap-l.';
83
+ /** The reminder for fields that rewrite Markdown. */
84
+ export const KEEP_IMAGE_TITLES = 'Keep image titles (layouts) as read.';
85
+ export const IdempotencyKeyInput = z
86
+ .uuid()
87
+ .describe('A random uuid you choose. Sending the same call again with it returns the first result instead of creating twice. If you leave it out, one is generated and returned, so you can retry safely with it.');
88
+ export const CursorInput = z
89
+ .string()
90
+ .min(1)
91
+ .max(2048)
92
+ .describe('The next_cursor of the previous page, to read the next one (same other arguments).');
93
+ export const limitInput = (max, def) => z.number().int().min(1).max(max).optional().describe(`Page size, 1 to ${max} (default ${def}).`);
94
+ /** Removes a leading # from an item key, keeps uuids as they are. */
95
+ export function itemRef(ref) {
96
+ return ref.startsWith('#') ? ref.slice(1) : ref;
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // Output shapes
100
+ // ---------------------------------------------------------------------------
101
+ /** The longest a person's display name is shown, in characters. */
102
+ export const PERSON_NAME_CHARS = 64;
103
+ /** The longest any other name is shown: the org, a token, project, channel, sprint, release, status, type, field or option. */
104
+ export const NAME_CHARS = 60;
105
+ const label = (value, max = 200) => sanitizeLabel(value, max);
106
+ const nullableLabel = (value, max = 200) => value === null || value === undefined || value === '' ? null : sanitizeLabel(value, max);
107
+ /** A person-written name other than a person's, flattened and capped (null stays null). */
108
+ export const nameLabel = (value) => nullableLabel(value, NAME_CHARS);
109
+ export const PersonSchema = z.object({
110
+ id: z.string(),
111
+ name: z.string(),
112
+ email: z.string().nullable(),
113
+ });
114
+ export function person(u) {
115
+ return {
116
+ id: u.id,
117
+ name: label(u.display_name, PERSON_NAME_CHARS),
118
+ email: nullableLabel(u.email, 320),
119
+ };
120
+ }
121
+ export function personText(p) {
122
+ if (p === null)
123
+ return 'nobody';
124
+ return p.email ? `${p.name} <${p.email}>` : p.name;
125
+ }
126
+ export const ItemOutSchema = z.object({
127
+ key: z.string(),
128
+ id: z.string(),
129
+ title: z.string().describe('People-written; flattened to one defused line.'),
130
+ project: z.string(),
131
+ type: z.string(),
132
+ level: z.string().describe('epic, standard or subtask.'),
133
+ status: z.string(),
134
+ status_category: z.string().describe('not_started, started, done or canceled.'),
135
+ priority: z.string(),
136
+ assignee: PersonSchema.nullable(),
137
+ parent: z.string().nullable().describe("The parent's key."),
138
+ labels: z.array(z.string()),
139
+ estimate: z.number().nullable(),
140
+ sprint: z.string().nullable(),
141
+ fix_release: z.string().nullable(),
142
+ start_date: z.string().nullable(),
143
+ due_date: z.string().nullable(),
144
+ updated_at: z.string(),
145
+ version: z.number().describe('Send as if_version to update_item or transition_item.'),
146
+ description_version: z.number().describe('Send with description_replace to update_item.'),
147
+ });
148
+ export function itemOut(i) {
149
+ return {
150
+ key: i.key,
151
+ id: i.id,
152
+ title: label(i.title, 300),
153
+ project: i.project.key,
154
+ type: label(i.type.name, 60),
155
+ level: i.type.level,
156
+ status: label(i.status.name, 60),
157
+ status_category: i.status.category,
158
+ priority: i.priority,
159
+ assignee: i.assignee ? person(i.assignee) : null,
160
+ parent: i.parent?.key ?? null,
161
+ labels: i.labels.map((l) => label(l.name, 40)),
162
+ estimate: i.estimate,
163
+ sprint: nameLabel(i.sprint?.name),
164
+ fix_release: nameLabel(i.fix_release?.name),
165
+ start_date: i.start_date,
166
+ due_date: i.due_date,
167
+ updated_at: i.updated_at,
168
+ version: i.version,
169
+ description_version: i.description_version,
170
+ };
171
+ }
172
+ export const CustomOutSchema = z.object({
173
+ field: z.string(),
174
+ kind: z.string(),
175
+ value: z.unknown().describe('Text and url values are wrapped as untrusted content.'),
176
+ });
177
+ function customValue(c) {
178
+ const v = c.value;
179
+ if (typeof v === 'string') {
180
+ return c.kind === 'text' || c.kind === 'url'
181
+ ? wrapUntrusted(v, { source: 'field', maxChars: 2000 })
182
+ : label(v, NAME_CHARS);
183
+ }
184
+ if (Array.isArray(v)) {
185
+ return v.map((x) => (typeof x === 'string' ? label(x, NAME_CHARS) : person(x)));
186
+ }
187
+ if (typeof v === 'object')
188
+ return person(v);
189
+ return v;
190
+ }
191
+ export function customOut(c) {
192
+ return { field: label(c.field, NAME_CHARS), kind: c.kind, value: customValue(c) };
193
+ }
194
+ /**
195
+ * A custom field as one line for the text output, to go inside a block
196
+ * with the item's other fields: text values flattened and cut (the
197
+ * structured content has them whole, wrapped).
198
+ */
199
+ export function customLine(c) {
200
+ const v = c.value;
201
+ let value;
202
+ if (typeof v === 'string') {
203
+ value = label(v, c.kind === 'text' || c.kind === 'url' ? 500 : NAME_CHARS);
204
+ }
205
+ else if (Array.isArray(v)) {
206
+ value =
207
+ v
208
+ .map((x) => typeof x === 'string' ? label(x, NAME_CHARS) : label(x.display_name, PERSON_NAME_CHARS))
209
+ .join(', ') || 'none';
210
+ }
211
+ else if (typeof v === 'object') {
212
+ value = label(v.display_name, PERSON_NAME_CHARS);
213
+ }
214
+ else {
215
+ value = String(v);
216
+ }
217
+ return `- ${label(c.field, NAME_CHARS)} (${c.kind}): ${value}`;
218
+ }
219
+ export const ItemFullSchema = ItemOutSchema.extend({
220
+ reporter: PersonSchema.nullable(),
221
+ initiative: z.string().nullable(),
222
+ rank: z.string(),
223
+ created_at: z.string(),
224
+ started_at: z.string().nullable(),
225
+ completed_at: z.string().nullable(),
226
+ canceled_at: z.string().nullable(),
227
+ description: z
228
+ .string()
229
+ .describe('Markdown, wrapped as untrusted content; empty when the item has none.'),
230
+ description_truncated: z
231
+ .boolean()
232
+ .describe('True when the description was cut; ask get_item with detail="full".'),
233
+ custom: z.array(CustomOutSchema),
234
+ });
235
+ /**
236
+ * An item with its description, cut at `maxDescription` characters. In full
237
+ * mode the API has sent everything, so a cut can't be read further here.
238
+ */
239
+ export function itemFull(i, maxDescription, full = false,
240
+ /** What the note on a cut description says to do, in place of the default. */
241
+ moreHint) {
242
+ const cut = i.description.length > maxDescription;
243
+ return {
244
+ ...itemOut(i),
245
+ reporter: i.reporter ? person(i.reporter) : null,
246
+ initiative: i.initiative?.key ?? null,
247
+ rank: i.rank,
248
+ created_at: i.created_at,
249
+ started_at: i.started_at,
250
+ completed_at: i.completed_at,
251
+ canceled_at: i.canceled_at,
252
+ // No description stays empty rather than becoming an empty block.
253
+ description: i.description.trim() === ''
254
+ ? ''
255
+ : wrapUntrusted(i.description, {
256
+ source: 'description',
257
+ maxChars: maxDescription,
258
+ moreHint: moreHint ??
259
+ (full
260
+ ? 'The rest is too long to show here; the person can read it in buildIt.Social.'
261
+ : `Call get_item with item="${i.key}" and detail="full" to read more.`),
262
+ }),
263
+ description_truncated: i.description_truncated || cut,
264
+ custom: i.custom.map(customOut),
265
+ };
266
+ }
267
+ export const SearchItemOutSchema = ItemOutSchema.extend({
268
+ match: z
269
+ .object({
270
+ score: z.number(),
271
+ title_highlight: z.string(),
272
+ snippet: z.string().nullable().describe('Wrapped as untrusted content.'),
273
+ })
274
+ .optional()
275
+ .describe('With a text query only.'),
276
+ description: z.string().optional().describe('With detail="full" only; wrapped, may be cut.'),
277
+ custom: z.array(CustomOutSchema).optional().describe('With detail="full" only.'),
278
+ link_counts: z
279
+ .object({
280
+ blocks: z.number(),
281
+ blocked_by: z.number(),
282
+ relates: z.number(),
283
+ duplicates: z.number(),
284
+ duplicated_by: z.number(),
285
+ blocked_by_open: z.number(),
286
+ })
287
+ .optional(),
288
+ activity_at: z.string().optional(),
289
+ });
290
+ export function searchItemOut(i, maxDescription) {
291
+ const out = itemOut(i);
292
+ if (i.match) {
293
+ out.match = {
294
+ score: i.match.score,
295
+ title_highlight: label(i.match.title_highlight, 400),
296
+ snippet: i.match.snippet === null
297
+ ? null
298
+ : wrapUntrusted(i.match.snippet, { source: 'snippet', maxChars: 500 }),
299
+ };
300
+ }
301
+ if (i.description !== undefined) {
302
+ out.description = wrapUntrusted(i.description, {
303
+ source: 'description',
304
+ maxChars: maxDescription,
305
+ moreHint: `Call get_item with item="${i.key}" and detail="full" to read all of it.`,
306
+ });
307
+ }
308
+ if (i.custom !== undefined)
309
+ out.custom = i.custom.map(customOut);
310
+ if (i.link_counts !== undefined) {
311
+ const c = i.link_counts;
312
+ out.link_counts = {
313
+ blocks: c.blocks,
314
+ blocked_by: c.blocked_by,
315
+ relates: c.relates,
316
+ duplicates: c.duplicates,
317
+ duplicated_by: c.duplicated_by,
318
+ blocked_by_open: c.blocked_by_open,
319
+ };
320
+ }
321
+ if (i.activity_at !== undefined)
322
+ out.activity_at = i.activity_at;
323
+ return out;
324
+ }
325
+ export const CommentOutSchema = z.object({
326
+ id: z.string(),
327
+ author: PersonSchema.nullable(),
328
+ body: z.string().describe('Plain text, wrapped as untrusted content.'),
329
+ mentions: z.array(z.string()).describe('Display names of the people mentioned.'),
330
+ created_at: z.string(),
331
+ edited_at: z.string().nullable(),
332
+ via_agent: z.string().nullable().describe('The token name, when an agent wrote it.'),
333
+ });
334
+ export function commentOut(c, maxChars, moreHint) {
335
+ const author = c.author ? person(c.author) : null;
336
+ const viaAgent = nameLabel(c.via_agent);
337
+ return {
338
+ id: c.id,
339
+ author,
340
+ // The author and the agent's token name are people-written too: they
341
+ // are attributes of the block, not words of the server.
342
+ body: wrapUntrusted(c.body, {
343
+ source: 'comment',
344
+ author: author?.name,
345
+ attributes: { via_agent: viaAgent },
346
+ maxChars,
347
+ ...(moreHint ? { moreHint } : {}),
348
+ }),
349
+ mentions: c.mentions.map((m) => label(m.display_name, PERSON_NAME_CHARS)),
350
+ created_at: c.created_at,
351
+ edited_at: c.edited_at,
352
+ via_agent: viaAgent,
353
+ };
354
+ }
355
+ /**
356
+ * A comment for the text output: a line of facts, then the wrapped body,
357
+ * whose tag names the author (and the agent, when one wrote it).
358
+ */
359
+ export function commentText(c) {
360
+ const facts = [
361
+ c.created_at,
362
+ ...(c.author ? [] : ['unknown author']),
363
+ ...(c.via_agent ? ['written through an agent'] : []),
364
+ ...(c.edited_at ? [`edited ${c.edited_at}`] : []),
365
+ `id ${c.id}`,
366
+ ];
367
+ return `- ${facts.join(' · ')}\n${c.body}`;
368
+ }
369
+ export const LinkOutSchema = z.object({
370
+ id: z.string().describe('The link id (link_items with action="remove" can take it).'),
371
+ kind: z.string().describe('blocks, blocked_by, relates, duplicates or duplicated_by.'),
372
+ key: z.string().nullable().describe("The other item's key; null when it can't be seen."),
373
+ title: z.string().nullable(),
374
+ status: z.string().nullable(),
375
+ });
376
+ export function linkOut(l) {
377
+ return {
378
+ id: l.id,
379
+ kind: l.kind,
380
+ key: l.item?.key ?? null,
381
+ title: l.item ? label(l.item.title, 300) : null,
382
+ status: l.item ? label(l.item.status.name, NAME_CHARS) : null,
383
+ };
384
+ }
385
+ export const HistoryOutSchema = z.object({
386
+ at: z.string(),
387
+ actor: z.string().nullable(),
388
+ via_agent: z.string().nullable(),
389
+ kind: z.string(),
390
+ field: z.string().nullable(),
391
+ old: z
392
+ .string()
393
+ .nullable()
394
+ .describe('The old value as recorded (ids), wrapped as untrusted content.'),
395
+ new: z
396
+ .string()
397
+ .nullable()
398
+ .describe('The new value as recorded (ids), wrapped as untrusted content.'),
399
+ old_label: z
400
+ .string()
401
+ .nullable()
402
+ .describe('The old value as people read it now (names, keys), flattened to one line.'),
403
+ new_label: z
404
+ .string()
405
+ .nullable()
406
+ .describe('The new value as people read it now (names, keys), flattened to one line.'),
407
+ });
408
+ function historyValue(v) {
409
+ if (v === undefined || v === null)
410
+ return null;
411
+ const text = typeof v === 'string' ? v : JSON.stringify(v);
412
+ return wrapUntrusted(text, { source: 'history', maxChars: 500, moreHint: 'Read the item.' });
413
+ }
414
+ export function historyOut(h) {
415
+ return {
416
+ at: h.at,
417
+ actor: h.actor ? label(h.actor.display_name, PERSON_NAME_CHARS) : null,
418
+ via_agent: nameLabel(h.via_agent),
419
+ kind: h.kind,
420
+ field: nameLabel(h.field),
421
+ old: historyValue(h.old),
422
+ new: historyValue(h.new),
423
+ old_label: nullableLabel(h.old_label, 300),
424
+ new_label: nullableLabel(h.new_label, 300),
425
+ };
426
+ }
427
+ // ---------------------------------------------------------------------------
428
+ // Text
429
+ // ---------------------------------------------------------------------------
430
+ /** One line about an item, for lists (it carries the title, so lists are wrapped). */
431
+ export function itemLine(i) {
432
+ const parts = [
433
+ i.key,
434
+ i.type,
435
+ `${i.status} (${i.status_category})`,
436
+ ...(i.priority !== 'none' ? [i.priority] : []),
437
+ i.assignee ? `@${i.assignee.name}` : 'unassigned',
438
+ ...(i.estimate !== null ? [`est ${i.estimate}`] : []),
439
+ ...(i.parent ? [`parent ${i.parent}`] : []),
440
+ ...(i.sprint ? [i.sprint] : []),
441
+ ...(i.due_date ? [`due ${i.due_date}`] : []),
442
+ ...(i.labels.length > 0 ? [`labels ${i.labels.join(', ')}`] : []),
443
+ ];
444
+ return `${parts.join(' · ')} — ${i.title}`;
445
+ }
446
+ /**
447
+ * Lines inside one untrusted block, for text that carries people-written
448
+ * titles or names (a list, or the names of one thing). The lines must not
449
+ * hold blocks themselves: flatten free text into them instead.
450
+ */
451
+ export function wrapLines(lines, source) {
452
+ return wrapUntrusted(lines.join('\n'), { source, maxChars: 100_000 });
453
+ }
454
+ /** The line that tells the agent how to read the next page. */
455
+ export function nextPageHint(tool, cursor) {
456
+ return cursor === null
457
+ ? 'This is the last page.'
458
+ : [
459
+ `More results: call ${tool} again with the same arguments and this cursor:`,
460
+ sanitizeLabel(cursor, 2048),
461
+ ].join('\n');
462
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * whoami: who the token acts as, where, and with what reach.
3
+ */
4
+ import { z } from 'zod';
5
+ import { OPERATIONS } from '../api/generated/operations.js';
6
+ import { defineTool } from '../toolsets/registry.js';
7
+ import { expandScopes } from '../toolsets/toolsets.js';
8
+ import { sanitizeLabel } from '../untrusted.js';
9
+ import { compareVersions, SERVER_NAME, SERVER_VERSION } from '../version.js';
10
+ import { NAME_CHARS, PERSON_NAME_CHARS, wrapLines } from './shared.js';
11
+ const MAX_PROJECTS_IN_TEXT = 50;
12
+ const OutputSchema = z.object({
13
+ user: z.object({
14
+ id: z.string(),
15
+ name: z.string().nullable(),
16
+ email: z.string().nullable(),
17
+ }),
18
+ org: z.object({
19
+ id: z.string(),
20
+ name: z.string().nullable(),
21
+ test: z
22
+ .boolean()
23
+ .nullable()
24
+ .describe('A test org (true); null when the API is older than 1.2.0.'),
25
+ }),
26
+ token: z.object({
27
+ name: z.string().nullable(),
28
+ expires_at: z.string().nullable(),
29
+ }),
30
+ scopes: z.array(z.string()).describe('As granted; a write scope also allows reading.'),
31
+ limits: z.object({
32
+ projects: z
33
+ .array(z.string())
34
+ .nullable()
35
+ .describe('Keys of the projects the token is limited to; null means every project.'),
36
+ channels: z
37
+ .array(z.string())
38
+ .nullable()
39
+ .describe('Names of the channels the token is limited to; null means every channel.'),
40
+ }),
41
+ projects: z.array(z.object({
42
+ key: z.string(),
43
+ name: z.string().nullable(),
44
+ id: z.string().nullable(),
45
+ })),
46
+ cannot: z.array(z.string()).describe("What the token can't do."),
47
+ features: z.object({
48
+ projects: z.boolean().describe('Whether Projects is enabled for the org.'),
49
+ }),
50
+ rate_limits: z.object({
51
+ requests_per_minute: z.number().describe("The token's requests a minute."),
52
+ user_requests_per_minute: z
53
+ .number()
54
+ .describe("The user's requests a minute, across all their tokens."),
55
+ writes_per_minute: z.number().describe("The token's writes a minute."),
56
+ writes_per_day: z.number().describe("The token's writes a day (UTC)."),
57
+ org_requests_per_minute: z
58
+ .number()
59
+ .describe("The org's requests a minute, across all its tokens."),
60
+ }),
61
+ server: z.object({
62
+ name: z.string(),
63
+ version: z.string(),
64
+ api_version: z.string().nullable(),
65
+ update_required: z.boolean(),
66
+ not_served: z
67
+ .array(z.string())
68
+ .nullable()
69
+ .describe("The contract's operations this API doesn't serve yet; null if it doesn't say."),
70
+ }),
71
+ });
72
+ /**
73
+ * What a token can't do, in words, from its scopes (with the ones they imply)
74
+ * and its limits. The API is the authority; this only tells the agent why a
75
+ * tool isn't listed, so it can tell the person instead of looking for one.
76
+ */
77
+ export function cannotDo(scopes, limits) {
78
+ const has = expandScopes(scopes);
79
+ const out = [];
80
+ if (!has.has('projects:read'))
81
+ out.push('read projects or items (needs projects:read)');
82
+ if (!has.has('projects:write')) {
83
+ out.push(`change items, comments, sprints or releases (${has.has('projects:read') ? 'it can only read; ' : ''}needs projects:write)`);
84
+ }
85
+ if (!has.has('projects:delete'))
86
+ out.push('delete, move or bulk-edit items (needs projects:delete)');
87
+ if (!has.has('projects:admin')) {
88
+ out.push('create channels or projects, or change workflows, work types, fields or labels (needs projects:admin)');
89
+ }
90
+ else if ((limits.projects ?? null) !== null || (limits.channels ?? null) !== null) {
91
+ out.push('create channels or projects (the token is limited to some projects or channels; that needs a token without limits)');
92
+ }
93
+ if (!has.has('pages:read'))
94
+ out.push('read pages (needs pages:read)');
95
+ else if (!has.has('pages:write'))
96
+ out.push('create or edit pages (needs pages:write)');
97
+ if (!has.has('chat:read'))
98
+ out.push('read channel messages (needs chat:read)');
99
+ return out;
100
+ }
101
+ const nullableLabel = (v, max) => v === undefined || v === null || v === '' ? null : sanitizeLabel(v, max);
102
+ export const whoamiTool = defineTool({
103
+ name: 'whoami',
104
+ toolset: 'items',
105
+ title: 'Who am I',
106
+ description: "Who the token acts as: the user, the org, the token's name, expiry, scopes and limits, what it can't do, and the projects in reach. Call it when you don't know the project keys, when no tool fits a task, or after an authorization error.",
107
+ scopes: [],
108
+ annotations: {
109
+ title: 'Who am I',
110
+ readOnlyHint: true,
111
+ idempotentHint: true,
112
+ openWorldHint: false,
113
+ },
114
+ inputSchema: z.object({}),
115
+ outputSchema: OutputSchema,
116
+ async run(_args, ctx) {
117
+ const options = ctx.apiOptions;
118
+ const [me, meta] = await Promise.all([
119
+ ctx.api.getMe(options),
120
+ ctx.api.getMeta(options).catch((err) => {
121
+ ctx.logger.debug('meta unavailable', { error: err });
122
+ return undefined;
123
+ }),
124
+ ]);
125
+ const minVersion = meta?.min_mcp_version;
126
+ const updateRequired = minVersion !== undefined && compareVersions(SERVER_VERSION, minVersion) < 0;
127
+ const structured = {
128
+ user: {
129
+ id: me.user.id,
130
+ name: nullableLabel(me.user.display_name, PERSON_NAME_CHARS),
131
+ email: nullableLabel(me.user.email, 320),
132
+ },
133
+ org: {
134
+ id: me.org.id,
135
+ name: nullableLabel(me.org.name, NAME_CHARS),
136
+ test: me.org.test ?? null,
137
+ },
138
+ token: {
139
+ name: nullableLabel(me.token.name, NAME_CHARS),
140
+ expires_at: me.token.expires_at,
141
+ },
142
+ scopes: [...me.token.scopes].sort(),
143
+ limits: {
144
+ projects: me.token.limits.projects?.map((p) => sanitizeLabel(p.key, 40)) ?? null,
145
+ channels: me.token.limits.channels?.map((c) => sanitizeLabel(c.name, NAME_CHARS)) ?? null,
146
+ },
147
+ cannot: cannotDo(me.token.scopes, me.token.limits),
148
+ projects: me.projects.map((p) => ({
149
+ key: sanitizeLabel(p.key, 40),
150
+ name: nullableLabel(p.name, NAME_CHARS),
151
+ id: p.id,
152
+ })),
153
+ features: { projects: me.features.projects },
154
+ rate_limits: {
155
+ requests_per_minute: me.rate_limits.requests_per_minute,
156
+ user_requests_per_minute: me.rate_limits.user_requests_per_minute,
157
+ writes_per_minute: me.rate_limits.writes_per_minute,
158
+ writes_per_day: me.rate_limits.writes_per_day,
159
+ org_requests_per_minute: me.rate_limits.org_requests_per_minute,
160
+ },
161
+ server: {
162
+ name: SERVER_NAME,
163
+ version: SERVER_VERSION,
164
+ api_version: meta?.api_version ?? null,
165
+ update_required: updateRequired,
166
+ not_served: meta?.operations
167
+ ? Object.keys(OPERATIONS)
168
+ .filter((id) => !meta.operations?.includes(id))
169
+ .sort()
170
+ : null,
171
+ },
172
+ };
173
+ const s = structured;
174
+ // The names of the user, the org, the token, the projects and the
175
+ // channels were written by people: one block holds them all.
176
+ const names = [
177
+ `User: ${s.user.name ?? s.user.id}${s.user.email ? ` (${s.user.email})` : ''}`,
178
+ `Org: ${s.org.name ?? s.org.id}`,
179
+ ...(s.token.name ? [`Token: ${s.token.name}`] : []),
180
+ ];
181
+ if (s.projects.length > 0) {
182
+ const shown = s.projects
183
+ .slice(0, MAX_PROJECTS_IN_TEXT)
184
+ .map((p) => (p.name ? `${p.key} (${p.name})` : p.key));
185
+ names.push(`Projects in reach: ${shown.join(', ')}`);
186
+ }
187
+ if (s.limits.channels !== null && s.limits.channels.length > 0) {
188
+ names.push(`Channels the token is limited to: ${s.limits.channels.join(', ')}`);
189
+ }
190
+ const lines = [
191
+ `Signed in to buildIt.Social with a personal access token${s.token.expires_at ? ` (expires ${s.token.expires_at})` : ''}. Who and where, as people named them:`,
192
+ wrapLines(names, 'names'),
193
+ `Scopes: ${s.scopes.length > 0 ? s.scopes.join(', ') : 'none'}.`,
194
+ `Limits: ${s.limits.projects === null
195
+ ? 'all projects'
196
+ : `only the projects ${s.limits.projects.join(', ') || '(none)'}`}; ${s.limits.channels === null
197
+ ? 'all channels the user can see'
198
+ : s.limits.channels.length === 0
199
+ ? 'no channels'
200
+ : `only the ${s.limits.channels.length} channel(s) listed above, among those the user can see`}.`,
201
+ ];
202
+ if (s.org.test === true) {
203
+ lines.push('This org is a test org: its data may be wiped and seeded by test tools.');
204
+ }
205
+ if (s.cannot.length > 0) {
206
+ lines.push(`This token can't: ${s.cannot.join('; ')}.`);
207
+ }
208
+ if (!s.features.projects) {
209
+ lines.push('Projects is not enabled for this org: project and item tools will not work.');
210
+ }
211
+ if (s.projects.length === 0) {
212
+ lines.push('Projects in reach: none.');
213
+ }
214
+ else {
215
+ const more = s.projects.length > MAX_PROJECTS_IN_TEXT
216
+ ? ` (the first ${MAX_PROJECTS_IN_TEXT} listed above; the rest in the structured result)`
217
+ : ' (listed above)';
218
+ lines.push(`Projects in reach: ${s.projects.length}${more}.`);
219
+ }
220
+ const r = s.rate_limits;
221
+ lines.push(`Rate limits for this token: ${r.requests_per_minute} calls and ${r.writes_per_minute} writes a minute, ${r.writes_per_day} writes a day. For your account, across all your tokens: ${r.user_requests_per_minute} calls a minute. For the whole org: ${r.org_requests_per_minute} calls a minute.`);
222
+ if (s.server.not_served !== null && s.server.not_served.length > 0) {
223
+ lines.push(`This buildIt.Social server (API ${s.server.api_version ?? 'unknown'}) doesn't serve these operations yet, so calls that need them are refused until it is updated: ${s.server.not_served.join(', ')}.`);
224
+ }
225
+ if (updateRequired) {
226
+ lines.push(`This buildit-mcp (${SERVER_VERSION}) is older than the API supports (${minVersion} or newer). Ask the person to update it.`);
227
+ }
228
+ return { structured, text: lines.join('\n') };
229
+ },
230
+ });