@hanhnd/agent-kit 1.0.43 → 1.0.44

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Integration Tools - Bitbucket, Jira, Confluence
3
- * Tools: kit_get_bitbucket_pr, kit_jira_get_ticket, kit_confluence_get_page
3
+ * Tools: kit_get_bitbucket_pr, kit_create_bitbucket_pr, kit_find_bitbucket_reviewers, kit_get_bitbucket_pr_comments, kit_jira_get_ticket, kit_confluence_get_page
4
4
  */
5
5
  import { writeFileSync } from 'fs';
6
6
  import { z } from 'zod';
@@ -8,7 +8,7 @@ import { getCredential } from '../services/integration/credentials.js';
8
8
  import { mcpText } from '../utils/utils.js';
9
9
  import { adfToMarkdown } from '../services/integration/parser/adf.js';
10
10
  import { storageToMarkdown } from '../services/integration/parser/storage.js';
11
- import { sanitize, sanitizeOutput } from '../core/security/index.js';
11
+ import { sanitizeOutput } from '../core/security/index.js';
12
12
  /**
13
13
  * Payload size (chars) at or above which a body is spilled to a temp file
14
14
  * instead of inlined into the agent context.
@@ -24,6 +24,56 @@ const BitbucketPrSchema = z.object({
24
24
  source: z.object({ branch: z.object({ name: z.string() }) }),
25
25
  destination: z.object({ branch: z.object({ name: z.string() }) }),
26
26
  });
27
+ // Response of POST /pullrequests (201). Permissive beyond what the output renders.
28
+ const BitbucketCreatedPrSchema = z.object({
29
+ id: z.number(),
30
+ title: z.string(),
31
+ state: z.string().optional(),
32
+ draft: z.boolean().optional(),
33
+ source: z.object({ branch: z.object({ name: z.string() }) }).optional(),
34
+ destination: z.object({ branch: z.object({ name: z.string() }) }).optional(),
35
+ reviewers: z.array(z.object({ display_name: z.string().optional() }).passthrough()).optional(),
36
+ links: z
37
+ .object({ html: z.object({ href: z.string() }).optional() })
38
+ .passthrough()
39
+ .optional(),
40
+ });
41
+ const BitbucketCommentSchema = z
42
+ .object({
43
+ id: z.number(),
44
+ created_on: z.string().optional(),
45
+ updated_on: z.string().optional(),
46
+ content: z.object({ raw: z.string().nullable().optional() }).passthrough().optional(),
47
+ user: z.object({ display_name: z.string().optional() }).passthrough().nullable().optional(),
48
+ deleted: z.boolean().optional(),
49
+ pending: z.boolean().optional(),
50
+ parent: z.object({ id: z.number() }).passthrough().nullable().optional(),
51
+ inline: z
52
+ .object({
53
+ path: z.string(),
54
+ from: z.number().nullable().optional(),
55
+ to: z.number().nullable().optional(),
56
+ start_from: z.number().nullable().optional(),
57
+ start_to: z.number().nullable().optional(),
58
+ })
59
+ .passthrough()
60
+ .nullable()
61
+ .optional(),
62
+ resolution: z
63
+ .object({
64
+ type: z.string().optional(),
65
+ user: z.object({ display_name: z.string().optional() }).passthrough().nullable().optional(),
66
+ created_on: z.string().optional(),
67
+ })
68
+ .passthrough()
69
+ .nullable()
70
+ .optional(),
71
+ })
72
+ .passthrough();
73
+ const BitbucketCommentPageSchema = z.object({
74
+ values: z.array(BitbucketCommentSchema),
75
+ next: z.string().optional(),
76
+ });
27
77
  // MEDIUM 2: Jira ticket schema for runtime validation
28
78
  // ADF (Atlassian Document Format) can have many nested content types
29
79
  // We use a more permissive schema that accepts any ADF structure
@@ -89,6 +139,32 @@ const ConfluencePageSchema = z.object({
89
139
  .optional(),
90
140
  _links: z.object({ base: z.string().optional(), webui: z.string().optional() }).passthrough().optional(),
91
141
  });
142
+ /**
143
+ * API token scopes each tool needs, from Atlassian's OpenAPI specs (`x-atlassian-oauth2-scopes`).
144
+ * Used for 401/403 hints; mcp/README.md lists the same scopes for setup.
145
+ */
146
+ export const REQUIRED_SCOPES = {
147
+ // The PR diff endpoint redirects to the repository diff, which needs read:repository.
148
+ kit_get_bitbucket_pr: ['read:pullrequest:bitbucket', 'read:repository:bitbucket'],
149
+ kit_get_bitbucket_pr_comments: ['read:pullrequest:bitbucket'],
150
+ // read:workspace resolves reviewer names; read:user skips the author (optional — degrades, never fails).
151
+ kit_create_bitbucket_pr: [
152
+ 'read:pullrequest:bitbucket',
153
+ 'write:pullrequest:bitbucket',
154
+ 'read:workspace:bitbucket',
155
+ 'read:user:bitbucket',
156
+ ],
157
+ kit_find_bitbucket_reviewers: ['read:workspace:bitbucket', 'read:user:bitbucket'],
158
+ kit_jira_get_ticket: ['read:jira-work'],
159
+ kit_confluence_get_page: ['read:page:confluence'],
160
+ };
161
+ /** Error text for a tool, with its required scopes appended on 401/403 unless a scope is already named. */
162
+ function errorText(error, tool) {
163
+ const message = error instanceof Error ? error.message : String(error);
164
+ if (!/\((401|403)\)/.test(message) || message.includes(':bitbucket') || message.includes('scope'))
165
+ return message;
166
+ return `${message}\n\nCheck the API token has these scopes: ${REQUIRED_SCOPES[tool].join(', ')}.`;
167
+ }
92
168
  function buildBasicAuth(emailVar, tokenVar) {
93
169
  const email = getCredential(emailVar);
94
170
  const token = getCredential(tokenVar);
@@ -110,8 +186,15 @@ export function buildAtlassianContext(product) {
110
186
  throw new Error('Missing ATLASSIAN_CLOUD_ID');
111
187
  return { auth: buildBasicAuth('ATLASSIAN_USER_EMAIL', PRODUCT_TOKEN_KEY[product]), cloudId };
112
188
  }
113
- async function callRestApi(url, auth, accept = 'application/json') {
114
- const resp = await fetch(url, { headers: { Authorization: auth, Accept: accept } });
189
+ async function callRestApi(url, auth, accept = 'application/json', body) {
190
+ const headers = { Authorization: auth, Accept: accept };
191
+ const init = { headers };
192
+ if (body !== undefined) {
193
+ init.method = 'POST';
194
+ headers['Content-Type'] = 'application/json';
195
+ init.body = JSON.stringify(body);
196
+ }
197
+ const resp = await fetch(url, init);
115
198
  if (resp.status === 401)
116
199
  throw new Error(`❌ Auth failed (401): ${url}`);
117
200
  if (resp.status === 403)
@@ -119,9 +202,27 @@ async function callRestApi(url, auth, accept = 'application/json') {
119
202
  if (resp.status === 404)
120
203
  throw new Error(`❌ Not found: ${url}`);
121
204
  if (!resp.ok)
122
- throw new Error(`❌ API error ${resp.status}: ${await resp.text()}`);
205
+ throw new Error(`❌ API error ${resp.status}: ${extractApiError(await resp.text())}`);
123
206
  return accept === 'text/plain' ? resp.text() : resp.json();
124
207
  }
208
+ /** Bitbucket errors arrive as `{ type: 'error', error: { message, detail?, fields? } }`; surface the readable parts. */
209
+ function extractApiError(text) {
210
+ try {
211
+ const err = JSON.parse(text).error;
212
+ if (err && typeof err.message === 'string') {
213
+ const parts = [err.message];
214
+ if (typeof err.detail === 'string' && err.detail)
215
+ parts.push(err.detail);
216
+ if (err.fields && typeof err.fields === 'object')
217
+ parts.push(JSON.stringify(err.fields));
218
+ return parts.join('\n');
219
+ }
220
+ }
221
+ catch {
222
+ // Not JSON — fall through to the raw body.
223
+ }
224
+ return text;
225
+ }
125
226
  const CONFLUENCE_INPUT_FORMS = [
126
227
  'Accepted input forms:',
127
228
  ' • https://<site>.atlassian.net/wiki/spaces/<SPACEKEY>/pages/<pageId>/<Title>',
@@ -221,7 +322,434 @@ ${labels.length > 0 ? labels.join(', ') : 'None'}`;
221
322
  return mcpText(sanitizeOutput(output));
222
323
  }
223
324
  catch (error) {
224
- return mcpText(`Error: ${error instanceof Error ? error.message : String(error)}`);
325
+ return mcpText(`Error: ${errorText(error, 'kit_confluence_get_page')}`);
326
+ }
327
+ }
328
+ const BITBUCKET_API = 'https://api.bitbucket.org/2.0';
329
+ // Dot-only slugs ("." / "..") would let the request path climb out of /repositories/.
330
+ const BITBUCKET_SLUG = /^(?!\.+$)[A-Za-z0-9._-]+$/;
331
+ /** Safety cap on followed pagination pages (100 items each). */
332
+ export const MAX_PAGES = 20;
333
+ const MISSING_WORKSPACE = `❌ workspace is required. Pass it as a parameter or set BITBUCKET_DEFAULT_WORKSPACE in your MCP env config.`;
334
+ function checkSlugs(ws, repo) {
335
+ if (!BITBUCKET_SLUG.test(ws))
336
+ return `❌ Invalid workspace slug: ${ws}`;
337
+ if (!BITBUCKET_SLUG.test(repo))
338
+ return `❌ Invalid repo slug: ${repo}`;
339
+ return undefined;
340
+ }
341
+ /** Resolve a PR URL, or a numeric PR id plus workspace/repoSlug, to its coordinates. */
342
+ export function resolveBitbucketPr(input, workspace, repoSlug) {
343
+ const trimmed = input.trim();
344
+ let ws;
345
+ let repo;
346
+ let prId;
347
+ const urlMatch = trimmed.match(/bitbucket\.org\/([^/]+)\/([^/]+)\/pull-requests\/(\d+)/);
348
+ if (urlMatch) {
349
+ ws = urlMatch[1];
350
+ repo = urlMatch[2];
351
+ prId = parseInt(urlMatch[3], 10);
352
+ }
353
+ else if (/^\d+$/.test(trimmed)) {
354
+ prId = parseInt(trimmed, 10);
355
+ ws = workspace || getCredential('BITBUCKET_DEFAULT_WORKSPACE');
356
+ repo = repoSlug;
357
+ }
358
+ if (!ws)
359
+ return { ok: false, error: MISSING_WORKSPACE };
360
+ if (!repo || !prId) {
361
+ return { ok: false, error: `❌ Could not parse PR URL. Expected: bitbucket.org/{ws}/{repo}/pull-requests/{id}` };
362
+ }
363
+ const slugError = checkSlugs(ws, repo);
364
+ if (slugError)
365
+ return { ok: false, error: slugError };
366
+ return { ok: true, ws, repo, prId };
367
+ }
368
+ /** Resolve a repo URL, `workspace/repo`, or a bare repo slug (plus workspace or default) to its coordinates. */
369
+ export function resolveBitbucketRepo(repository, workspace) {
370
+ const trimmed = repository.trim().replace(/\.git$/, '');
371
+ let ws;
372
+ let repo;
373
+ const urlMatch = trimmed.match(/bitbucket\.org[/:]([^/]+)\/([^/?#]+)/);
374
+ const pairMatch = trimmed.match(/^([^/]+)\/([^/]+)$/);
375
+ if (urlMatch) {
376
+ ws = urlMatch[1];
377
+ repo = urlMatch[2];
378
+ }
379
+ else if (pairMatch) {
380
+ ws = pairMatch[1];
381
+ repo = pairMatch[2];
382
+ }
383
+ else if (trimmed) {
384
+ ws = workspace || getCredential('BITBUCKET_DEFAULT_WORKSPACE');
385
+ repo = trimmed;
386
+ }
387
+ if (!repo) {
388
+ return { ok: false, error: `❌ repository is required: a Bitbucket repo URL, "workspace/repo", or a repo slug.` };
389
+ }
390
+ if (!ws)
391
+ return { ok: false, error: MISSING_WORKSPACE };
392
+ const slugError = checkSlugs(ws, repo);
393
+ if (slugError)
394
+ return { ok: false, error: slugError };
395
+ return { ok: true, ws, repo };
396
+ }
397
+ /** Bitbucket expects reviewer UUIDs wrapped in braces: `{xxxxxxxx-...}`. */
398
+ function normalizeReviewerUuid(raw) {
399
+ const bare = raw.trim().replace(/^\{|\}$/g, '');
400
+ return /^[0-9a-fA-F-]{36}$/.test(bare) ? `{${bare}}` : undefined;
401
+ }
402
+ const BitbucketMembersPageSchema = z.object({
403
+ values: z.array(z
404
+ .object({
405
+ user: z
406
+ .object({ uuid: z.string(), display_name: z.string().optional(), nickname: z.string().optional() })
407
+ .passthrough(),
408
+ })
409
+ .passthrough()),
410
+ next: z.string().optional(),
411
+ });
412
+ /** Lowercase and strip diacritics so "Dũng" matches "Dung" and "Đức" matches "Duc". */
413
+ export function normalizeName(value) {
414
+ return value.normalize('NFD').replace(/\p{M}/gu, '').replace(/đ/gi, 'd').toLowerCase().replace(/\s+/g, ' ').trim();
415
+ }
416
+ function nameTokens(value) {
417
+ return normalizeName(value)
418
+ .split(/[^\p{L}\p{N}]+/u)
419
+ .filter(Boolean);
420
+ }
421
+ /**
422
+ * Match reviewer queries against workspace members. A query matches a member by exact
423
+ * display name / nickname first; failing that, when every word of the query is a whole
424
+ * word of the member's name ("Dung" → "Nguyen Tien Dung"). More than one hit is ambiguous —
425
+ * never guessed. UUID queries must be resolved by the caller before this runs.
426
+ */
427
+ export function matchReviewers(queries, members) {
428
+ const result = { resolved: [], ambiguous: [], unmatched: [] };
429
+ for (const query of queries) {
430
+ const q = normalizeName(query);
431
+ const qTokens = nameTokens(query);
432
+ if (qTokens.length === 0)
433
+ continue;
434
+ let hits = members.filter((m) => normalizeName(m.displayName) === q || (m.nickname !== undefined && normalizeName(m.nickname) === q));
435
+ if (hits.length === 0) {
436
+ hits = members.filter((m) => {
437
+ const words = new Set([...nameTokens(m.displayName), ...nameTokens(m.nickname ?? '')]);
438
+ return qTokens.every((t) => words.has(t));
439
+ });
440
+ }
441
+ if (hits.length === 1)
442
+ result.resolved.push({ query, member: hits[0] });
443
+ else if (hits.length > 1)
444
+ result.ambiguous.push({ query, candidates: hits });
445
+ else
446
+ result.unmatched.push(query);
447
+ }
448
+ return result;
449
+ }
450
+ async function fetchWorkspaceMembers(ws, auth) {
451
+ let url = `${BITBUCKET_API}/workspaces/${ws}/members?pagelen=100`;
452
+ const members = [];
453
+ let pages = 0;
454
+ while (url && pages < MAX_PAGES) {
455
+ let data;
456
+ try {
457
+ data = await callRestApi(url, auth);
458
+ }
459
+ catch (error) {
460
+ const message = error instanceof Error ? error.message : String(error);
461
+ throw new Error(`${message}\n\nLooking up reviewers by name lists workspace members — the API token needs the read:workspace:bitbucket scope.`);
462
+ }
463
+ const parseResult = BitbucketMembersPageSchema.safeParse(data);
464
+ if (!parseResult.success)
465
+ throw new Error(`❌ Invalid workspace members response: ${parseResult.error.message}`);
466
+ for (const { user } of parseResult.data.values) {
467
+ members.push({ uuid: user.uuid, displayName: user.display_name ?? '', nickname: user.nickname });
468
+ }
469
+ pages++;
470
+ url = parseResult.data.next?.startsWith(`${BITBUCKET_API}/`) ? parseResult.data.next : undefined;
471
+ }
472
+ return members;
473
+ }
474
+ /** UUID of the token owner, or undefined when the token lacks read:user:bitbucket. */
475
+ async function fetchCurrentUserUuid(auth) {
476
+ try {
477
+ const data = (await callRestApi(`${BITBUCKET_API}/user`, auth));
478
+ return typeof data.uuid === 'string' ? data.uuid : undefined;
479
+ }
480
+ catch {
481
+ return undefined;
482
+ }
483
+ }
484
+ /** Resolve names, nicknames or UUIDs to workspace members. Members are fetched only when a name is given. */
485
+ async function lookupReviewers(ws, auth, queries) {
486
+ const byUuid = [];
487
+ const names = [];
488
+ for (const query of queries) {
489
+ const uuid = normalizeReviewerUuid(query);
490
+ if (uuid)
491
+ byUuid.push({ uuid, displayName: uuid });
492
+ else if (query.trim())
493
+ names.push(query);
494
+ }
495
+ const [members, selfUuid] = await Promise.all([
496
+ names.length > 0 ? fetchWorkspaceMembers(ws, auth) : Promise.resolve([]),
497
+ fetchCurrentUserUuid(auth),
498
+ ]);
499
+ const match = matchReviewers(names, members);
500
+ const seen = new Set();
501
+ const reviewers = [];
502
+ let skippedSelf = false;
503
+ for (const member of [...byUuid, ...match.resolved.map((r) => r.member)]) {
504
+ const key = member.uuid.toLowerCase();
505
+ if (seen.has(key))
506
+ continue;
507
+ seen.add(key);
508
+ if (selfUuid && key === selfUuid.toLowerCase()) {
509
+ skippedSelf = true;
510
+ continue;
511
+ }
512
+ reviewers.push(member);
513
+ }
514
+ return { reviewers, skippedSelf, ambiguous: match.ambiguous, unmatched: match.unmatched };
515
+ }
516
+ function describeMember(member) {
517
+ return `${member.displayName}${member.nickname ? ` (@${member.nickname})` : ''} ${member.uuid}`;
518
+ }
519
+ /** Markdown lines for names that did not resolve to exactly one member. */
520
+ function describeReviewerProblems(lookup) {
521
+ const lines = [];
522
+ for (const { query, candidates } of lookup.ambiguous) {
523
+ lines.push(`- ❓ "${query}" matches ${candidates.length} members — use a fuller name or a UUID:`);
524
+ for (const candidate of candidates.slice(0, 10))
525
+ lines.push(` - ${describeMember(candidate)}`);
526
+ if (candidates.length > 10)
527
+ lines.push(` - …and ${candidates.length - 10} more`);
528
+ }
529
+ for (const query of lookup.unmatched)
530
+ lines.push(`- ❌ "${query}" matches no workspace member`);
531
+ return lines;
532
+ }
533
+ /**
534
+ * Handler for kit_find_bitbucket_reviewers: dry-run of the reviewer resolution that
535
+ * kit_create_bitbucket_pr performs, so callers can verify names before pushing anything.
536
+ * Never rejects — every failure path resolves to an actionable mcpText message.
537
+ */
538
+ export async function handleFindBitbucketReviewers(args) {
539
+ try {
540
+ const ws = args.workspace?.trim() || getCredential('BITBUCKET_DEFAULT_WORKSPACE');
541
+ if (!ws)
542
+ return mcpText(MISSING_WORKSPACE);
543
+ if (!BITBUCKET_SLUG.test(ws))
544
+ return mcpText(`❌ Invalid workspace slug: ${ws}`);
545
+ if (args.names.every((name) => !name.trim()))
546
+ return mcpText('❌ names is required.');
547
+ const auth = buildBasicAuth('BITBUCKET_USER_EMAIL', 'BITBUCKET_API_TOKEN');
548
+ const lookup = await lookupReviewers(ws, auth, args.names);
549
+ const lines = [`## Reviewer lookup (${ws})`];
550
+ for (const member of lookup.reviewers)
551
+ lines.push(`- ✅ ${describeMember(member)}`);
552
+ if (lookup.skippedSelf)
553
+ lines.push('- ⏭️ You (the PR author) were named — skipped, Bitbucket rejects self-review');
554
+ lines.push(...describeReviewerProblems(lookup));
555
+ return mcpText(lines.join('\n'));
556
+ }
557
+ catch (error) {
558
+ return mcpText(errorText(error, 'kit_find_bitbucket_reviewers'));
559
+ }
560
+ }
561
+ /**
562
+ * Handler for kit_create_bitbucket_pr. Exported so it is unit testable without a transport.
563
+ * Never rejects — every failure path resolves to an actionable mcpText message.
564
+ */
565
+ export async function handleCreateBitbucketPr(args) {
566
+ try {
567
+ const ref = resolveBitbucketRepo(args.repository, args.workspace);
568
+ if (!ref.ok)
569
+ return mcpText(ref.error);
570
+ const title = args.title.trim();
571
+ const sourceBranch = args.sourceBranch.trim();
572
+ if (!title)
573
+ return mcpText('❌ title is required.');
574
+ if (!sourceBranch)
575
+ return mcpText('❌ sourceBranch is required.');
576
+ const auth = buildBasicAuth('BITBUCKET_USER_EMAIL', 'BITBUCKET_API_TOKEN');
577
+ // Resolve every reviewer before creating anything: one bad name aborts with no PR made.
578
+ let reviewerUuids = [];
579
+ let skippedSelf = false;
580
+ if (args.reviewers && args.reviewers.some((r) => r.trim())) {
581
+ const lookup = await lookupReviewers(ref.ws, auth, args.reviewers);
582
+ const problems = describeReviewerProblems(lookup);
583
+ if (problems.length > 0) {
584
+ return mcpText(`❌ PR not created — some reviewers did not resolve to exactly one member:\n${problems.join('\n')}`);
585
+ }
586
+ reviewerUuids = lookup.reviewers.map((member) => member.uuid);
587
+ skippedSelf = lookup.skippedSelf;
588
+ }
589
+ // Omitted destination defaults to the repository's main branch (Bitbucket behaviour).
590
+ const body = { title, source: { branch: { name: sourceBranch } } };
591
+ const destinationBranch = args.destinationBranch?.trim();
592
+ if (destinationBranch)
593
+ body.destination = { branch: { name: destinationBranch } };
594
+ if (args.description)
595
+ body.description = args.description;
596
+ if (args.closeSourceBranch !== undefined)
597
+ body.close_source_branch = args.closeSourceBranch;
598
+ if (args.draft !== undefined)
599
+ body.draft = args.draft;
600
+ if (reviewerUuids.length > 0)
601
+ body.reviewers = reviewerUuids.map((uuid) => ({ uuid }));
602
+ const url = `${BITBUCKET_API}/repositories/${ref.ws}/${ref.repo}/pullrequests`;
603
+ const jsonData = await callRestApi(url, auth, 'application/json', body);
604
+ const parseResult = BitbucketCreatedPrSchema.safeParse(jsonData);
605
+ if (!parseResult.success) {
606
+ return mcpText(`⚠️ PR was likely created, but the response could not be parsed: ${parseResult.error.message}`);
607
+ }
608
+ const pr = parseResult.data;
609
+ const reviewers = pr.reviewers?.map((r) => r.display_name).filter(Boolean) ?? [];
610
+ const lines = [
611
+ `✅ Created PR #${pr.id}: ${pr.title}`,
612
+ `**URL:** ${pr.links?.html?.href ?? `https://bitbucket.org/${ref.ws}/${ref.repo}/pull-requests/${pr.id}`}`,
613
+ `**Branch:** ${pr.source?.branch.name ?? sourceBranch} → ${pr.destination?.branch.name ?? destinationBranch ?? '(main branch)'}`,
614
+ `**State:** ${pr.state ?? 'OPEN'}${pr.draft ? ' (draft)' : ''}`,
615
+ ];
616
+ if (reviewers.length > 0)
617
+ lines.push(`**Reviewers:** ${reviewers.join(', ')}`);
618
+ if (skippedSelf)
619
+ lines.push('⏭️ You (the PR author) were named as a reviewer — skipped, Bitbucket rejects self-review.');
620
+ return mcpText(lines.join('\n'));
621
+ }
622
+ catch (error) {
623
+ return mcpText(errorText(error, 'kit_create_bitbucket_pr'));
624
+ }
625
+ }
626
+ function describeInlineAnchor(inline) {
627
+ if (inline.to) {
628
+ return inline.start_to && inline.start_to !== inline.to
629
+ ? `lines ${inline.start_to}–${inline.to}`
630
+ : `line ${inline.to}`;
631
+ }
632
+ if (inline.from) {
633
+ return inline.start_from && inline.start_from !== inline.from
634
+ ? `old lines ${inline.start_from}–${inline.from}`
635
+ : `old line ${inline.from}`;
636
+ }
637
+ return 'file';
638
+ }
639
+ function renderComment(comment, depth, isRoot) {
640
+ const indent = ' '.repeat(depth);
641
+ const meta = [`#${comment.id}`, comment.user?.display_name || 'Unknown', comment.created_on?.slice(0, 10) ?? ''];
642
+ if (isRoot && comment.inline)
643
+ meta.push(describeInlineAnchor(comment.inline));
644
+ if (comment.pending)
645
+ meta.push('pending (unpublished)');
646
+ if (isRoot) {
647
+ meta.push(comment.resolution
648
+ ? `✅ resolved${comment.resolution.user?.display_name ? ` by ${comment.resolution.user.display_name}` : ''}`
649
+ : '🟡 open');
650
+ }
651
+ const header = `${indent}${depth > 0 ? '↳ ' : '- '}**${meta.filter(Boolean).join(' · ')}**`;
652
+ const raw = comment.deleted ? '_(deleted)_' : comment.content?.raw?.trim() || '_(empty)_';
653
+ const body = raw
654
+ .split('\n')
655
+ .map((line) => `${indent} > ${line}`)
656
+ .join('\n');
657
+ return `${header}\n${body}`;
658
+ }
659
+ /**
660
+ * Handler for kit_get_bitbucket_pr_comments. Follows `next` links (pagelen=100) up to
661
+ * MAX_PAGES, then renders threads: general comments first, inline ones grouped by file.
662
+ * Never rejects — every failure path resolves to an actionable mcpText message.
663
+ */
664
+ export async function handleGetBitbucketPrComments(args) {
665
+ try {
666
+ const ref = resolveBitbucketPr(args.input, args.workspace, args.repoSlug);
667
+ if (!ref.ok)
668
+ return mcpText(ref.error);
669
+ const includeResolved = args.includeResolved ?? true;
670
+ const includeDeleted = args.includeDeleted ?? false;
671
+ const auth = buildBasicAuth('BITBUCKET_USER_EMAIL', 'BITBUCKET_API_TOKEN');
672
+ let url = `${BITBUCKET_API}/repositories/${ref.ws}/${ref.repo}/pullrequests/${ref.prId}/comments?pagelen=100`;
673
+ const comments = [];
674
+ let pages = 0;
675
+ while (url && pages < MAX_PAGES) {
676
+ const parseResult = BitbucketCommentPageSchema.safeParse(await callRestApi(url, auth));
677
+ if (!parseResult.success) {
678
+ return mcpText(`❌ Invalid Bitbucket comments response: ${parseResult.error.message}`);
679
+ }
680
+ comments.push(...parseResult.data.values);
681
+ pages++;
682
+ // Never forward credentials to a host other than the Bitbucket API.
683
+ url = parseResult.data.next?.startsWith(`${BITBUCKET_API}/`) ? parseResult.data.next : undefined;
684
+ }
685
+ const truncated = Boolean(url);
686
+ const byId = new Map(comments.map((c) => [c.id, c]));
687
+ const children = new Map();
688
+ const roots = [];
689
+ for (const comment of comments) {
690
+ const parentId = comment.parent?.id;
691
+ // A reply whose parent was not returned is promoted to a root so it is never lost.
692
+ if (parentId !== undefined && byId.has(parentId)) {
693
+ children.set(parentId, [...(children.get(parentId) ?? []), comment]);
694
+ }
695
+ else {
696
+ roots.push(comment);
697
+ }
698
+ }
699
+ const renderThread = (comment, depth) => {
700
+ const replies = (children.get(comment.id) ?? []).flatMap((child) => renderThread(child, depth + 1));
701
+ // Skip a deleted comment unless asked, but keep it as a stub when live replies hang off it.
702
+ if (comment.deleted && !includeDeleted && replies.length === 0)
703
+ return [];
704
+ return [renderComment(comment, depth, depth === 0), ...replies];
705
+ };
706
+ const visibleRoots = roots.filter((root) => includeResolved || !root.resolution);
707
+ const general = [];
708
+ const inlineByFile = new Map();
709
+ for (const root of visibleRoots) {
710
+ const rendered = renderThread(root, 0);
711
+ if (rendered.length === 0)
712
+ continue;
713
+ if (root.inline) {
714
+ inlineByFile.set(root.inline.path, [...(inlineByFile.get(root.inline.path) ?? []), ...rendered]);
715
+ }
716
+ else {
717
+ general.push(...rendered);
718
+ }
719
+ }
720
+ const liveRoots = roots.filter((root) => !root.deleted);
721
+ const unresolved = liveRoots.filter((root) => !root.resolution).length;
722
+ const summary = `${comments.length} comments in ${liveRoots.length} threads · ${unresolved} open · ${liveRoots.length - unresolved} resolved`;
723
+ let output = `## PR #${ref.prId} comments\n${summary}${includeResolved ? '' : ' (resolved threads hidden)'}`;
724
+ if (truncated) {
725
+ output += `\n\n⚠️ Stopped after ${MAX_PAGES} pages (${comments.length} comments); later comments are not shown.`;
726
+ }
727
+ if (general.length === 0 && inlineByFile.size === 0) {
728
+ output += '\n\nNo comments to show.';
729
+ }
730
+ else {
731
+ if (general.length > 0)
732
+ output += `\n\n### General\n${general.join('\n')}`;
733
+ if (inlineByFile.size > 0) {
734
+ output += '\n\n### Inline';
735
+ for (const [path, rendered] of inlineByFile)
736
+ output += `\n\n#### \`${path}\`\n${rendered.join('\n')}`;
737
+ }
738
+ }
739
+ if (output.length >= LARGE_PAYLOAD_THRESHOLD) {
740
+ const filePath = `/tmp/kit-pr-${ref.prId}-comments-${Date.now()}.md`;
741
+ try {
742
+ writeFileSync(filePath, sanitizeOutput(output), 'utf8');
743
+ return mcpText(`## PR #${ref.prId} comments\n${summary}\n\nComments are large (${output.length} chars). Full markdown written to: \`${filePath}\`. Read this file before proceeding.`);
744
+ }
745
+ catch {
746
+ // Fall through and inline.
747
+ }
748
+ }
749
+ return mcpText(sanitizeOutput(output));
750
+ }
751
+ catch (error) {
752
+ return mcpText(errorText(error, 'kit_get_bitbucket_pr_comments'));
225
753
  }
226
754
  }
227
755
  export function registerIntegrationTools(server) {
@@ -241,28 +769,10 @@ export function registerIntegrationTools(server) {
241
769
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
242
770
  }, async ({ input, workspace, repoSlug, includeDiff }) => {
243
771
  try {
244
- let ws;
245
- let repo;
246
- let prId;
247
- const urlMatch = input.match(/bitbucket\.org\/([^/]+)\/([^/]+)\/pull-requests\/(\d+)/);
248
- if (urlMatch) {
249
- ws = urlMatch[1];
250
- repo = urlMatch[2];
251
- prId = parseInt(urlMatch[3], 10);
252
- }
253
- else if (input.match(/^\d+$/)) {
254
- prId = parseInt(input, 10);
255
- ws = workspace || getCredential('BITBUCKET_DEFAULT_WORKSPACE');
256
- repo = repoSlug;
257
- }
258
- if (!ws) {
259
- return mcpText(`❌ workspace is required. Pass it as a parameter or set BITBUCKET_DEFAULT_WORKSPACE in your MCP env config.`);
260
- }
261
- if (!repo || !prId) {
262
- return mcpText(`❌ Could not parse PR URL. Expected: bitbucket.org/{ws}/{repo}/pull-requests/{id}`);
263
- }
264
- const safeWs = sanitize(ws);
265
- const safeRepo = sanitize(repo);
772
+ const ref = resolveBitbucketPr(input, workspace, repoSlug);
773
+ if (!ref.ok)
774
+ return mcpText(ref.error);
775
+ const { ws: safeWs, repo: safeRepo, prId } = ref;
266
776
  const auth = buildBasicAuth('BITBUCKET_USER_EMAIL', 'BITBUCKET_API_TOKEN');
267
777
  const prUrl = `https://api.bitbucket.org/2.0/repositories/${safeWs}/${safeRepo}/pullrequests/${prId}`;
268
778
  const jsonData = await callRestApi(prUrl, auth);
@@ -297,9 +807,57 @@ ${pr.description || 'No description'}`;
297
807
  return mcpText(output);
298
808
  }
299
809
  catch (error) {
300
- return mcpText(error instanceof Error ? error.message : String(error));
810
+ return mcpText(errorText(error, 'kit_get_bitbucket_pr'));
301
811
  }
302
812
  });
813
+ // TOOL: CREATE BITBUCKET PR
814
+ server.registerTool('kit_create_bitbucket_pr', {
815
+ title: 'Create Bitbucket PR',
816
+ description: 'Create a Bitbucket Cloud pull request from an already-pushed source branch. If destinationBranch is omitted, Bitbucket targets the repository main branch. Returns the new PR id and URL.',
817
+ inputSchema: {
818
+ repository: z
819
+ .string()
820
+ .describe('Bitbucket repo URL, "workspace/repo", or a repo slug (uses workspace / BITBUCKET_DEFAULT_WORKSPACE)'),
821
+ workspace: z.string().optional().describe('Bitbucket workspace slug, used when repository is a bare slug'),
822
+ title: z.string().describe('PR title'),
823
+ sourceBranch: z.string().describe('Source branch name (must already exist on the remote)'),
824
+ destinationBranch: z.string().optional().describe('Destination branch name; defaults to the repo main branch'),
825
+ description: z.string().optional().describe('PR description (markdown)'),
826
+ closeSourceBranch: z.boolean().optional().describe('Close the source branch when the PR is merged'),
827
+ draft: z.boolean().optional().describe('Create the PR as a draft'),
828
+ reviewers: z
829
+ .array(z.string())
830
+ .optional()
831
+ .describe('Reviewers as names, nicknames or account UUIDs, e.g. ["Dien", "Nguyen Tien Dung"]. Matching is case- and accent-insensitive; a name that matches no member or several aborts with no PR created. The PR author is skipped.'),
832
+ },
833
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
834
+ }, async (args) => handleCreateBitbucketPr(args));
835
+ // TOOL: FIND BITBUCKET REVIEWERS
836
+ server.registerTool('kit_find_bitbucket_reviewers', {
837
+ title: 'Find Bitbucket Reviewers',
838
+ description: 'Resolve reviewer names, nicknames or UUIDs to Bitbucket workspace members — the same matching kit_create_bitbucket_pr uses. Reports matched, ambiguous (with candidates) and unknown names, so reviewers can be verified before pushing or creating a PR.',
839
+ inputSchema: {
840
+ names: z.array(z.string()).describe('Names, nicknames or account UUIDs, e.g. ["Dien", "Dung"]'),
841
+ workspace: z.string().optional().describe('Bitbucket workspace slug (defaults to BITBUCKET_DEFAULT_WORKSPACE)'),
842
+ },
843
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
844
+ }, async (args) => handleFindBitbucketReviewers(args));
845
+ // TOOL: GET BITBUCKET PR COMMENTS
846
+ server.registerTool('kit_get_bitbucket_pr_comments', {
847
+ title: 'Get Bitbucket PR Comments',
848
+ description: 'Get all comments on a Bitbucket PR as threads: general comments, then inline comments grouped by file with line anchors and resolved/open status. Accepts a full PR URL or a numeric PR ID with workspace + repoSlug.',
849
+ inputSchema: {
850
+ input: z.string().describe('Bitbucket PR URL or numeric PR ID'),
851
+ workspace: z
852
+ .string()
853
+ .optional()
854
+ .describe('Bitbucket workspace slug (required for numeric ID if BITBUCKET_DEFAULT_WORKSPACE not set)'),
855
+ repoSlug: z.string().optional().describe('Bitbucket repo slug (required for numeric ID)'),
856
+ includeResolved: z.boolean().optional().default(true).describe('Include resolved threads'),
857
+ includeDeleted: z.boolean().optional().default(false).describe('Include deleted comments'),
858
+ },
859
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
860
+ }, async (args) => handleGetBitbucketPrComments(args));
303
861
  // TOOL: JIRA GET TICKET
304
862
  server.registerTool('kit_jira_get_ticket', {
305
863
  title: 'Get Jira Ticket',
@@ -341,7 +899,7 @@ ${ticket.fields.labels?.join(', ') || 'None'}`;
341
899
  return mcpText(output);
342
900
  }
343
901
  catch (error) {
344
- return mcpText(`Error: ${error instanceof Error ? error.message : String(error)}`);
902
+ return mcpText(`Error: ${errorText(error, 'kit_jira_get_ticket')}`);
345
903
  }
346
904
  });
347
905
  // TOOL: CONFLUENCE GET PAGE