@hanhnd/agent-kit 1.0.42 → 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/confluence.test.d.ts +1 -0
- package/dist/mcp/confluence.test.js +258 -0
- package/dist/mcp/integration.d.ts +154 -2
- package/dist/mcp/integration.js +751 -36
- package/dist/mcp/jira.test.d.ts +1 -0
- package/dist/mcp/jira.test.js +37 -0
- package/dist/mcp/registration.test.js +46 -1
- package/dist/server.js +1108 -48
- package/dist/services/integration/credentials.d.ts +1 -1
- package/dist/services/integration/credentials.js +1 -1
- package/dist/services/integration/parser/storage.d.ts +18 -0
- package/dist/services/integration/parser/storage.js +508 -0
- package/dist/services/integration/parser/storage.test.d.ts +1 -0
- package/dist/services/integration/parser/storage.test.js +211 -0
- package/package.json +1 -1
package/dist/mcp/integration.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Integration Tools - Bitbucket, Jira
|
|
3
|
-
* Tools: kit_get_bitbucket_pr, kit_jira_get_ticket
|
|
2
|
+
* Integration Tools - Bitbucket, Jira, Confluence
|
|
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';
|
|
7
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
|
-
import {
|
|
10
|
+
import { storageToMarkdown } from '../services/integration/parser/storage.js';
|
|
11
|
+
import { sanitizeOutput } from '../core/security/index.js';
|
|
12
|
+
/**
|
|
13
|
+
* Payload size (chars) at or above which a body is spilled to a temp file
|
|
14
|
+
* instead of inlined into the agent context.
|
|
15
|
+
*/
|
|
16
|
+
export const LARGE_PAYLOAD_THRESHOLD = 50_000;
|
|
11
17
|
// Zod schema for Bitbucket PR REST API response
|
|
12
18
|
const BitbucketPrSchema = z.object({
|
|
13
19
|
id: z.number(),
|
|
@@ -18,6 +24,56 @@ const BitbucketPrSchema = z.object({
|
|
|
18
24
|
source: z.object({ branch: z.object({ name: z.string() }) }),
|
|
19
25
|
destination: z.object({ branch: z.object({ name: z.string() }) }),
|
|
20
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
|
+
});
|
|
21
77
|
// MEDIUM 2: Jira ticket schema for runtime validation
|
|
22
78
|
// ADF (Atlassian Document Format) can have many nested content types
|
|
23
79
|
// We use a more permissive schema that accepts any ADF structure
|
|
@@ -55,6 +111,60 @@ const JiraTicketSchema = z.object({
|
|
|
55
111
|
errorMessages: z.array(z.string()).optional(),
|
|
56
112
|
fields: JiraFieldsSchema,
|
|
57
113
|
});
|
|
114
|
+
// Confluence Cloud REST v2 page schema. Deliberately permissive: only the fields the
|
|
115
|
+
// output renders are required, so an additive Atlassian change cannot break the tool.
|
|
116
|
+
const ConfluencePageSchema = z.object({
|
|
117
|
+
id: z.string(),
|
|
118
|
+
title: z.string(),
|
|
119
|
+
status: z.string().optional(),
|
|
120
|
+
spaceId: z.string().optional(),
|
|
121
|
+
version: z
|
|
122
|
+
.object({
|
|
123
|
+
number: z.number().optional(),
|
|
124
|
+
createdAt: z.string().optional(),
|
|
125
|
+
message: z.string().optional(),
|
|
126
|
+
minorEdit: z.boolean().optional(),
|
|
127
|
+
authorId: z.string().optional(),
|
|
128
|
+
})
|
|
129
|
+
.optional(),
|
|
130
|
+
body: z
|
|
131
|
+
.object({
|
|
132
|
+
storage: z.object({ value: z.string().optional() }).passthrough().optional(),
|
|
133
|
+
})
|
|
134
|
+
.passthrough()
|
|
135
|
+
.optional(),
|
|
136
|
+
labels: z
|
|
137
|
+
.object({ results: z.array(z.object({ name: z.string() }).passthrough()).optional() })
|
|
138
|
+
.passthrough()
|
|
139
|
+
.optional(),
|
|
140
|
+
_links: z.object({ base: z.string().optional(), webui: z.string().optional() }).passthrough().optional(),
|
|
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
|
+
}
|
|
58
168
|
function buildBasicAuth(emailVar, tokenVar) {
|
|
59
169
|
const email = getCredential(emailVar);
|
|
60
170
|
const token = getCredential(tokenVar);
|
|
@@ -62,16 +172,586 @@ function buildBasicAuth(emailVar, tokenVar) {
|
|
|
62
172
|
throw new Error(`Missing ${emailVar} or ${tokenVar}`);
|
|
63
173
|
return 'Basic ' + Buffer.from(`${email}:${token}`).toString('base64');
|
|
64
174
|
}
|
|
65
|
-
|
|
66
|
-
|
|
175
|
+
// Jira and Confluence share one site and one account, so they share ATLASSIAN_CLOUD_ID
|
|
176
|
+
// and ATLASSIAN_USER_EMAIL — but Atlassian issues scoped API tokens per app, so each
|
|
177
|
+
// product carries its own token.
|
|
178
|
+
const PRODUCT_TOKEN_KEY = {
|
|
179
|
+
jira: 'JIRA_API_TOKEN',
|
|
180
|
+
confluence: 'CONFLUENCE_API_TOKEN',
|
|
181
|
+
};
|
|
182
|
+
/** Resolve the shared cloud id plus the product's basic-auth header (shared email, own token). */
|
|
183
|
+
export function buildAtlassianContext(product) {
|
|
184
|
+
const cloudId = getCredential('ATLASSIAN_CLOUD_ID');
|
|
185
|
+
if (!cloudId)
|
|
186
|
+
throw new Error('Missing ATLASSIAN_CLOUD_ID');
|
|
187
|
+
return { auth: buildBasicAuth('ATLASSIAN_USER_EMAIL', PRODUCT_TOKEN_KEY[product]), cloudId };
|
|
188
|
+
}
|
|
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);
|
|
67
198
|
if (resp.status === 401)
|
|
68
199
|
throw new Error(`❌ Auth failed (401): ${url}`);
|
|
200
|
+
if (resp.status === 403)
|
|
201
|
+
throw new Error(`❌ Access denied (403): ${url}\n\nYou lack permission for this resource, or it is restricted.`);
|
|
69
202
|
if (resp.status === 404)
|
|
70
203
|
throw new Error(`❌ Not found: ${url}`);
|
|
71
204
|
if (!resp.ok)
|
|
72
|
-
throw new Error(`❌ API error ${resp.status}: ${await resp.text()}`);
|
|
205
|
+
throw new Error(`❌ API error ${resp.status}: ${extractApiError(await resp.text())}`);
|
|
73
206
|
return accept === 'text/plain' ? resp.text() : resp.json();
|
|
74
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
|
+
}
|
|
226
|
+
const CONFLUENCE_INPUT_FORMS = [
|
|
227
|
+
'Accepted input forms:',
|
|
228
|
+
' • https://<site>.atlassian.net/wiki/spaces/<SPACEKEY>/pages/<pageId>/<Title>',
|
|
229
|
+
' • https://<site>.atlassian.net/pages/viewpage.action?pageId=<pageId>',
|
|
230
|
+
' • a bare numeric page ID (e.g. 123456789)',
|
|
231
|
+
].join('\n');
|
|
232
|
+
/**
|
|
233
|
+
* Resolve a user-supplied Confluence reference to a numeric page id.
|
|
234
|
+
*
|
|
235
|
+
* The blog branch MUST stay ahead of numeric extraction: a blog URL carries date and
|
|
236
|
+
* post-id segments that generic extraction would misread as a page id, silently
|
|
237
|
+
* fetching the wrong content.
|
|
238
|
+
*/
|
|
239
|
+
export function resolveConfluencePageId(input) {
|
|
240
|
+
const trimmed = input.trim();
|
|
241
|
+
if (!trimmed)
|
|
242
|
+
return { kind: 'unknown' };
|
|
243
|
+
if (/\/blog(?:posts?)?\//.test(trimmed))
|
|
244
|
+
return { kind: 'blog' };
|
|
245
|
+
if (/\/wiki\/x\/[A-Za-z0-9]+/.test(trimmed))
|
|
246
|
+
return { kind: 'tiny' };
|
|
247
|
+
const spacesPageMatch = trimmed.match(/\/wiki\/spaces\/[^/]+\/pages\/(\d+)(?:[/?#]|$)/);
|
|
248
|
+
if (spacesPageMatch)
|
|
249
|
+
return { kind: 'id', pageId: spacesPageMatch[1] };
|
|
250
|
+
const queryMatch = trimmed.match(/[?&]pageId=(\d+)(?:[&#]|$)/);
|
|
251
|
+
if (queryMatch)
|
|
252
|
+
return { kind: 'id', pageId: queryMatch[1] };
|
|
253
|
+
if (/^\d+$/.test(trimmed))
|
|
254
|
+
return { kind: 'id', pageId: trimmed };
|
|
255
|
+
return { kind: 'unknown' };
|
|
256
|
+
}
|
|
257
|
+
function buildConfluenceHeader(page, absoluteUrl) {
|
|
258
|
+
const webui = page._links?.webui ?? '';
|
|
259
|
+
const spaceKey = webui.match(/\/spaces\/([^/]+)\//)?.[1];
|
|
260
|
+
const lines = [`## 📄 ${page.title}`, '', `**Status:** ${page.status || 'Unknown'}`];
|
|
261
|
+
if (spaceKey)
|
|
262
|
+
lines.push(`**Space:** ${spaceKey}`);
|
|
263
|
+
lines.push(`**Version:** ${page.version?.number ?? 'Unknown'}`);
|
|
264
|
+
lines.push(`**Last updated:** ${page.version?.createdAt || 'Unknown'}`);
|
|
265
|
+
lines.push(`**URL:** ${absoluteUrl || 'Unknown'}`);
|
|
266
|
+
return lines.join('\n');
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Handler for kit_confluence_get_page. Exported (rather than living inside the
|
|
270
|
+
* registerTool closure) so behaviour is unit testable without a transport or network.
|
|
271
|
+
* Never rejects — every failure path resolves to an actionable mcpText message.
|
|
272
|
+
*/
|
|
273
|
+
export async function handleConfluenceGetPage(args) {
|
|
274
|
+
try {
|
|
275
|
+
const resolution = resolveConfluencePageId(args.input);
|
|
276
|
+
if (resolution.kind === 'tiny') {
|
|
277
|
+
return mcpText(`❌ Confluence tiny links cannot be resolved without following a redirect.\n\nOpen the link in a browser and pass the full page URL instead.\n\n${CONFLUENCE_INPUT_FORMS}`);
|
|
278
|
+
}
|
|
279
|
+
if (resolution.kind === 'blog') {
|
|
280
|
+
return mcpText(`❌ Confluence blog posts are not supported — they live on a different endpoint than pages.\n\n${CONFLUENCE_INPUT_FORMS}`);
|
|
281
|
+
}
|
|
282
|
+
if (resolution.kind === 'unknown') {
|
|
283
|
+
return mcpText(`❌ Could not extract a Confluence page ID from: ${args.input}\n\n${CONFLUENCE_INPUT_FORMS}`);
|
|
284
|
+
}
|
|
285
|
+
const { auth, cloudId } = buildAtlassianContext('confluence');
|
|
286
|
+
const url = `https://api.atlassian.com/ex/confluence/${cloudId}/wiki/api/v2/pages/${resolution.pageId}?body-format=storage&include-labels=true&include-version=true`;
|
|
287
|
+
const jsonData = await callRestApi(url, auth);
|
|
288
|
+
const parseResult = ConfluencePageSchema.safeParse(jsonData);
|
|
289
|
+
if (!parseResult.success) {
|
|
290
|
+
return mcpText(`❌ Invalid Confluence response format: ${parseResult.error.message}`);
|
|
291
|
+
}
|
|
292
|
+
const page = parseResult.data;
|
|
293
|
+
const base = page._links?.base ?? '';
|
|
294
|
+
const webui = page._links?.webui ?? '';
|
|
295
|
+
const absoluteUrl = base && webui ? `${base}${webui}` : base || webui;
|
|
296
|
+
const markdown = storageToMarkdown(page.body?.storage?.value);
|
|
297
|
+
let contentSection;
|
|
298
|
+
if (!markdown.trim()) {
|
|
299
|
+
contentSection = `⚠️ Confluence returned no readable body for this page (status: ${page.status || 'Unknown'}). Open ${absoluteUrl || 'the page in Confluence'} to view it directly.`;
|
|
300
|
+
}
|
|
301
|
+
else if (markdown.length >= LARGE_PAYLOAD_THRESHOLD) {
|
|
302
|
+
const filePath = `/tmp/kit-confluence-${resolution.pageId}-${Date.now()}.md`;
|
|
303
|
+
try {
|
|
304
|
+
writeFileSync(filePath, sanitizeOutput(markdown), 'utf8');
|
|
305
|
+
contentSection = `Content is large (${markdown.length} chars). Full markdown written to: \`${filePath}\`. Read this file before proceeding.`;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
contentSection = `⚠️ Could not write page content to a temp file. Showing inline (may be very large).\n\n${markdown}`;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
contentSection = markdown;
|
|
313
|
+
}
|
|
314
|
+
const labels = page.labels?.results?.map((label) => label.name).filter((name) => name.length > 0) ?? [];
|
|
315
|
+
const output = `${buildConfluenceHeader(page, absoluteUrl)}
|
|
316
|
+
|
|
317
|
+
### Content
|
|
318
|
+
${contentSection}
|
|
319
|
+
|
|
320
|
+
### Labels
|
|
321
|
+
${labels.length > 0 ? labels.join(', ') : 'None'}`;
|
|
322
|
+
return mcpText(sanitizeOutput(output));
|
|
323
|
+
}
|
|
324
|
+
catch (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'));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
75
755
|
export function registerIntegrationTools(server) {
|
|
76
756
|
// TOOL: GET BITBUCKET PR
|
|
77
757
|
server.registerTool('kit_get_bitbucket_pr', {
|
|
@@ -89,28 +769,10 @@ export function registerIntegrationTools(server) {
|
|
|
89
769
|
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
90
770
|
}, async ({ input, workspace, repoSlug, includeDiff }) => {
|
|
91
771
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
if (urlMatch) {
|
|
97
|
-
ws = urlMatch[1];
|
|
98
|
-
repo = urlMatch[2];
|
|
99
|
-
prId = parseInt(urlMatch[3], 10);
|
|
100
|
-
}
|
|
101
|
-
else if (input.match(/^\d+$/)) {
|
|
102
|
-
prId = parseInt(input, 10);
|
|
103
|
-
ws = workspace || getCredential('BITBUCKET_DEFAULT_WORKSPACE');
|
|
104
|
-
repo = repoSlug;
|
|
105
|
-
}
|
|
106
|
-
if (!ws) {
|
|
107
|
-
return mcpText(`❌ workspace is required. Pass it as a parameter or set BITBUCKET_DEFAULT_WORKSPACE in your MCP env config.`);
|
|
108
|
-
}
|
|
109
|
-
if (!repo || !prId) {
|
|
110
|
-
return mcpText(`❌ Could not parse PR URL. Expected: bitbucket.org/{ws}/{repo}/pull-requests/{id}`);
|
|
111
|
-
}
|
|
112
|
-
const safeWs = sanitize(ws);
|
|
113
|
-
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;
|
|
114
776
|
const auth = buildBasicAuth('BITBUCKET_USER_EMAIL', 'BITBUCKET_API_TOKEN');
|
|
115
777
|
const prUrl = `https://api.bitbucket.org/2.0/repositories/${safeWs}/${safeRepo}/pullrequests/${prId}`;
|
|
116
778
|
const jsonData = await callRestApi(prUrl, auth);
|
|
@@ -128,8 +790,7 @@ ${pr.description || 'No description'}`;
|
|
|
128
790
|
if (includeDiff) {
|
|
129
791
|
const diffUrl = `https://api.bitbucket.org/2.0/repositories/${safeWs}/${safeRepo}/pullrequests/${prId}/diff`;
|
|
130
792
|
const diff = (await callRestApi(diffUrl, auth, 'text/plain'));
|
|
131
|
-
|
|
132
|
-
if (diff.length < DIFF_FILE_THRESHOLD) {
|
|
793
|
+
if (diff.length < LARGE_PAYLOAD_THRESHOLD) {
|
|
133
794
|
output += `\n\n### Diff\n\`\`\`diff\n${diff}\n\`\`\``;
|
|
134
795
|
}
|
|
135
796
|
else {
|
|
@@ -146,9 +807,57 @@ ${pr.description || 'No description'}`;
|
|
|
146
807
|
return mcpText(output);
|
|
147
808
|
}
|
|
148
809
|
catch (error) {
|
|
149
|
-
return mcpText(error
|
|
810
|
+
return mcpText(errorText(error, 'kit_get_bitbucket_pr'));
|
|
150
811
|
}
|
|
151
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));
|
|
152
861
|
// TOOL: JIRA GET TICKET
|
|
153
862
|
server.registerTool('kit_jira_get_ticket', {
|
|
154
863
|
title: 'Get Jira Ticket',
|
|
@@ -163,10 +872,7 @@ ${pr.description || 'No description'}`;
|
|
|
163
872
|
if (!safeTicketId) {
|
|
164
873
|
return mcpText(`❌ Invalid ticket ID format: ${ticketId}\n\nExpected format: PROJ-123`);
|
|
165
874
|
}
|
|
166
|
-
const cloudId =
|
|
167
|
-
if (!cloudId)
|
|
168
|
-
throw new Error('Missing ATLASSIAN_CLOUD_ID');
|
|
169
|
-
const auth = buildBasicAuth('ATLASSIAN_USER_EMAIL', 'ATLASSIAN_API_TOKEN');
|
|
875
|
+
const { auth, cloudId } = buildAtlassianContext('jira');
|
|
170
876
|
const url = `https://api.atlassian.com/ex/jira/${cloudId}/rest/api/3/issue/${safeTicketId}`;
|
|
171
877
|
const jsonData = await callRestApi(url, auth);
|
|
172
878
|
const parseResult = JiraTicketSchema.safeParse(jsonData);
|
|
@@ -193,7 +899,16 @@ ${ticket.fields.labels?.join(', ') || 'None'}`;
|
|
|
193
899
|
return mcpText(output);
|
|
194
900
|
}
|
|
195
901
|
catch (error) {
|
|
196
|
-
return mcpText(`Error: ${error
|
|
902
|
+
return mcpText(`Error: ${errorText(error, 'kit_jira_get_ticket')}`);
|
|
197
903
|
}
|
|
198
904
|
});
|
|
905
|
+
// TOOL: CONFLUENCE GET PAGE
|
|
906
|
+
server.registerTool('kit_confluence_get_page', {
|
|
907
|
+
title: 'Get Confluence Page',
|
|
908
|
+
description: 'Get a Confluence page as markdown using the Atlassian REST API. Accepts a full Confluence page URL or a numeric page ID.',
|
|
909
|
+
inputSchema: {
|
|
910
|
+
input: z.string().describe('Confluence page URL or numeric page ID'),
|
|
911
|
+
},
|
|
912
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
913
|
+
}, async ({ input }) => handleConfluenceGetPage({ input }));
|
|
199
914
|
}
|