@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.
- package/README.md +23 -0
- package/dist/mcp/bitbucket.test.d.ts +1 -0
- package/dist/mcp/bitbucket.test.js +335 -0
- package/dist/mcp/integration.d.ts +111 -1
- package/dist/mcp/integration.js +588 -30
- package/dist/mcp/registration.test.js +35 -1
- package/dist/server.js +495 -32
- package/package.json +1 -1
package/dist/mcp/integration.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
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
|
|
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
|
|
902
|
+
return mcpText(`Error: ${errorText(error, 'kit_jira_get_ticket')}`);
|
|
345
903
|
}
|
|
346
904
|
});
|
|
347
905
|
// TOOL: CONFLUENCE GET PAGE
|