@stubbedev/atlassian-mcp 0.2.6 → 0.2.8

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.
@@ -4,7 +4,6 @@
4
4
  "title": "Atlassian MCP Config",
5
5
  "description": "Configuration file for the atlassian-mcp MCP server",
6
6
  "type": "object",
7
- "required": ["jira", "bitbucket"],
8
7
  "additionalProperties": false,
9
8
  "properties": {
10
9
  "$schema": {
package/dist/bitbucket.js CHANGED
@@ -37,7 +37,9 @@ function branchDisplayId(branch) {
37
37
  function formatDate(ms) {
38
38
  return new Date(ms).toISOString().slice(0, 10);
39
39
  }
40
- function formatCommentThread(comment, indent = '') {
40
+ function formatCommentThread(comment, indent = '', depth = 0) {
41
+ if (depth > 20)
42
+ return [`${indent}... (deeply nested replies omitted)`];
41
43
  const author = comment.author?.displayName ?? comment.author?.name ?? 'Unknown';
42
44
  const date = comment.createdDate ? ` (${formatDate(comment.createdDate)})` : '';
43
45
  const state = comment.state ?? 'OPEN';
@@ -51,7 +53,7 @@ function formatCommentThread(comment, indent = '') {
51
53
  ];
52
54
  if (comment.comments && comment.comments.length > 0) {
53
55
  for (const reply of comment.comments) {
54
- lines.push(...formatCommentThread(reply, `${indent} `));
56
+ lines.push(...formatCommentThread(reply, `${indent} `, depth + 1));
55
57
  }
56
58
  }
57
59
  return lines;
@@ -181,7 +183,7 @@ function validateCommentText(textValue) {
181
183
  export class BitbucketClient {
182
184
  baseUrl;
183
185
  headers;
184
- currentUserCache;
186
+ currentUsernameCache;
185
187
  constructor(baseUrl, token) {
186
188
  this.baseUrl = baseUrl.replace(/\/$/, '');
187
189
  this.headers = {
@@ -190,6 +192,22 @@ export class BitbucketClient {
190
192
  Accept: 'application/json',
191
193
  };
192
194
  }
195
+ /** Returns the slug/username of the authenticated user via the X-AUSERNAME response header. */
196
+ async getCurrentUsername() {
197
+ if (this.currentUsernameCache)
198
+ return this.currentUsernameCache;
199
+ const url = `${this.baseUrl}/rest/api/1.0/application-properties`;
200
+ const res = await fetch(url, { method: 'GET', headers: this.headers });
201
+ const username = res.headers.get('X-AUSERNAME');
202
+ if (!username)
203
+ throw new Error('Could not determine current Bitbucket user. Check token permissions.');
204
+ this.currentUsernameCache = username;
205
+ return username;
206
+ }
207
+ /** Returns a URL-safe `/projects/.../repos/...` prefix for REST paths. */
208
+ rp(projectKey, repoSlug) {
209
+ return `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}`;
210
+ }
193
211
  pullRequestUrl(projectKey, repoSlug, prId, pr) {
194
212
  const apiUrl = pr?.links?.self?.[0]?.href?.trim();
195
213
  if (apiUrl) {
@@ -232,7 +250,7 @@ export class BitbucketClient {
232
250
  }
233
251
  async request(method, path, body) {
234
252
  const url = `${this.baseUrl}/rest/api/1.0${path}`;
235
- const opts = { method, headers: this.headers };
253
+ const opts = { method, headers: this.headers, signal: AbortSignal.timeout(30_000) };
236
254
  if (body !== undefined)
237
255
  opts.body = JSON.stringify(body);
238
256
  const res = await fetch(url, opts);
@@ -248,6 +266,7 @@ export class BitbucketClient {
248
266
  const res = await fetch(url, {
249
267
  method: 'GET',
250
268
  headers: { Authorization: this.headers.Authorization },
269
+ signal: AbortSignal.timeout(30_000),
251
270
  });
252
271
  if (!res.ok) {
253
272
  const errText = await res.text();
@@ -258,7 +277,7 @@ export class BitbucketClient {
258
277
  }
259
278
  async requestBuildStatus(method, path, body) {
260
279
  const url = `${this.baseUrl}/rest/build-status/1.0${path}`;
261
- const opts = { method, headers: this.headers };
280
+ const opts = { method, headers: this.headers, signal: AbortSignal.timeout(30_000) };
262
281
  if (body !== undefined)
263
282
  opts.body = JSON.stringify(body);
264
283
  const res = await fetch(url, opts);
@@ -271,60 +290,23 @@ export class BitbucketClient {
271
290
  }
272
291
  return res.status === 204 ? null : res.json();
273
292
  }
274
- normalizeIdentity(value) {
275
- return (value ?? '').trim().toLowerCase();
276
- }
277
- async getCurrentUser() {
278
- if (this.currentUserCache)
279
- return this.currentUserCache;
280
- const me = await this.request('GET', '/users/~self');
281
- if (!me) {
282
- throw new Error('Could not determine current Bitbucket user identity.');
283
- }
284
- this.currentUserCache = me;
285
- return me;
286
- }
287
- async assertOwnComment(comment) {
288
- const me = await this.getCurrentUser();
289
- const commentAuthorName = this.normalizeIdentity(comment.author?.name);
290
- const commentAuthorDisplayName = this.normalizeIdentity(comment.author?.displayName);
291
- const meName = this.normalizeIdentity(me.name);
292
- const meSlug = this.normalizeIdentity(me.slug);
293
- const meDisplayName = this.normalizeIdentity(me.displayName);
294
- const hasStrongCommentIdentity = commentAuthorName.length > 0;
295
- const hasStrongUserIdentity = meName.length > 0 || meSlug.length > 0;
296
- const matchesByName = commentAuthorName.length > 0 && (commentAuthorName === meName || commentAuthorName === meSlug);
297
- const matchesByDisplayNameFallback = !hasStrongCommentIdentity
298
- && !hasStrongUserIdentity
299
- && commentAuthorDisplayName.length > 0
300
- && commentAuthorDisplayName === meDisplayName;
301
- if (matchesByName || matchesByDisplayNameFallback) {
302
- return;
303
- }
304
- throw new Error(`You can only edit your own Bitbucket comments. Comment #${comment.id} is authored by ${comment.author?.displayName ?? comment.author?.name ?? 'another user'}.`);
305
- }
306
293
  /** Returns true if the given remote URL belongs to this Bitbucket instance. */
307
294
  isRemoteForThisInstance(remoteUrl) {
308
295
  return this.remoteMatchesInstance(remoteUrl);
309
296
  }
310
- // Used internally by context tools — finds the open PR for a given source branch
297
+ // Used internally by context tools — finds the open PR for a given source branch.
298
+ // Uses the `at` filter to avoid paginating all open PRs.
311
299
  async findOpenPrForBranch(projectKey, repoSlug, branch) {
312
- const targetBranch = branchDisplayId(branch);
313
- let start = 0;
314
- while (true) {
315
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests?state=OPEN&limit=50&start=${start}`);
316
- const match = data?.values.find((pr) => branchDisplayId(pr.fromRef.displayId) === targetBranch) ?? null;
317
- if (match)
318
- return match;
319
- if (!data || data.isLastPage || data.nextPageStart === undefined)
320
- return null;
321
- start = data.nextPageStart;
322
- }
300
+ const atRef = encodeURIComponent(toBranchRef(branch));
301
+ const encodedProject = encodeURIComponent(projectKey);
302
+ const encodedRepo = encodeURIComponent(repoSlug);
303
+ const data = await this.request('GET', `/projects/${encodedProject}/repos/${encodedRepo}/pull-requests?state=OPEN&direction=OUTGOING&at=${atRef}&limit=1`);
304
+ return data?.values[0] ?? null;
323
305
  }
324
306
  // Fallback: search branches matching filterText and check each for an open PR.
325
307
  // Used when exact branch name lookup yields no result (e.g. LLM provides a partial branch name).
326
308
  async findOpenPrByBranchFilter(projectKey, repoSlug, filterText) {
327
- const branches = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/branches?limit=25&filterText=${encodeURIComponent(filterText)}`);
309
+ const branches = await this.request('GET', `${this.rp(projectKey, repoSlug)}/branches?limit=25&filterText=${encodeURIComponent(filterText)}`);
328
310
  if (!branches?.values?.length)
329
311
  return null;
330
312
  for (const b of branches.values) {
@@ -338,7 +320,7 @@ export class BitbucketClient {
338
320
  const { limit = 50, start = 0 } = args;
339
321
  const qs = `?limit=${limit}&start=${start}`;
340
322
  const path = args.projectKey
341
- ? `/projects/${args.projectKey}/repos${qs}`
323
+ ? `/projects/${encodeURIComponent(args.projectKey)}/repos${qs}`
342
324
  : `/repos${qs}`;
343
325
  const data = await this.request('GET', path);
344
326
  if (!data || data.values.length === 0)
@@ -356,7 +338,7 @@ export class BitbucketClient {
356
338
  }
357
339
  if (searchText)
358
340
  qs.set('filterText', searchText);
359
- const path = `/projects/${projectKey}/repos/${repoSlug}/pull-requests?${qs}`;
341
+ const path = `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}/pull-requests?${qs}`;
360
342
  const data = await this.request('GET', path);
361
343
  if (!data || data.values.length === 0)
362
344
  return text(`No ${state} pull requests found.`);
@@ -365,21 +347,22 @@ export class BitbucketClient {
365
347
  }
366
348
  async myPrs(args) {
367
349
  const { limit = 25, start = 0, role } = args;
368
- const qs = new URLSearchParams({ limit: String(limit), start: String(start) });
350
+ const userSlug = await this.getCurrentUsername();
351
+ const qs = new URLSearchParams({ limit: String(limit), start: String(start), state: 'OPEN' });
369
352
  if (role)
370
- qs.set('role', role);
371
- const data = await this.request('GET', `/inbox/pull-requests?${qs}`);
353
+ qs.set('role', role.toUpperCase());
354
+ const data = await this.request('GET', `/users/${encodeURIComponent(userSlug)}/pull-requests?${qs}`);
372
355
  if (!data || data.values.length === 0)
373
- return text('No pull requests in your inbox.');
356
+ return text('No pull requests found.');
374
357
  const lines = data.values.map((pr) => {
375
358
  const repo = `${pr.toRef.repository.project.key}/${pr.toRef.repository.slug}`;
376
359
  return `#${pr.id} [${pr.state}] ${pr.title} | ${repo} | ${pr.fromRef.displayId} → ${pr.toRef.displayId}`;
377
360
  });
378
- return text(`${data.values.length} PR(s) in your inbox${pageHint(data)}:\n${lines.join('\n')}`);
361
+ return text(`${data.values.length} PR(s)${pageHint(data)}:\n${lines.join('\n')}`);
379
362
  }
380
363
  async getPullRequest(args) {
381
364
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
382
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
365
+ const data = await this.request('GET', `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}/pull-requests/${args.prId}`);
383
366
  if (!data)
384
367
  return text('Pull request not found.');
385
368
  const reviewers = data.reviewers
@@ -418,28 +401,12 @@ export class BitbucketClient {
418
401
  throw new Error(`No open PR found for branch "${branchDisplayId(branch)}".`);
419
402
  prId = found.id;
420
403
  }
421
- const [pr, me] = await Promise.all([
422
- this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}`),
423
- this.getCurrentUser().catch(() => null),
424
- ]);
404
+ const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}`);
425
405
  if (!pr)
426
406
  return text('Pull request not found.');
427
407
  const sections = [];
428
408
  const reviewers = pr.reviewers.map((r) => `${r.user.displayName}${r.approved ? ' ✓' : ''}`).join(', ');
429
409
  const url = pr.links?.self?.[0]?.href;
430
- // Prefer slug (canonical unique identifier), fall back to username (name field).
431
- // Display name is deliberately excluded — it is not unique.
432
- const meSlug = this.normalizeIdentity(me?.slug);
433
- const meName = this.normalizeIdentity(me?.name);
434
- const authorSlug = this.normalizeIdentity(pr.author.user.slug);
435
- const authorName = this.normalizeIdentity(pr.author.user.name);
436
- const isAuthor = me
437
- ? (meSlug && authorSlug ? meSlug === authorSlug : false) ||
438
- (meName && authorName ? meName === authorName : false)
439
- : false;
440
- const viewingAs = me
441
- ? `Viewing as: ${me.displayName} (${isAuthor ? 'you are the author' : 'you are a reviewer'})`
442
- : '';
443
410
  const header = [
444
411
  `PR #${pr.id}: ${pr.title}`,
445
412
  `State: ${pr.state}`,
@@ -447,7 +414,6 @@ export class BitbucketClient {
447
414
  `Branch: ${pr.fromRef.displayId} → ${pr.toRef.displayId}`,
448
415
  `Reviewers: ${reviewers || 'None'}`,
449
416
  url ? `URL: ${url}` : '',
450
- viewingAs,
451
417
  '',
452
418
  'Description:',
453
419
  pr.description ?? '(no description)',
@@ -470,7 +436,7 @@ export class BitbucketClient {
470
436
  if (includeCommits) {
471
437
  const commitsLimit = args.commitsLimit ?? 25;
472
438
  const commitsStart = args.commitsStart ?? 0;
473
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/commits?limit=${commitsLimit}&start=${commitsStart}`);
439
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/commits?limit=${commitsLimit}&start=${commitsStart}`);
474
440
  if (!data || data.values.length === 0) {
475
441
  sections.push('Commits:\n(no commits found)');
476
442
  }
@@ -489,7 +455,7 @@ export class BitbucketClient {
489
455
  }
490
456
  if (commentsSeverity === 'BLOCKER') {
491
457
  const qs = new URLSearchParams({ limit: String(commentsLimit), start: String(commentsStart), state: commentsState });
492
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/blocker-comments?${qs}`);
458
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/blocker-comments?${qs}`);
493
459
  if (!data || data.values.length === 0) {
494
460
  sections.push(`Comments:\n(no ${commentsState} BLOCKER comments)`);
495
461
  }
@@ -499,7 +465,7 @@ export class BitbucketClient {
499
465
  }
500
466
  }
501
467
  else {
502
- const activityData = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/activities?limit=${commentsLimit}&start=${commentsStart}`);
468
+ const activityData = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/activities?limit=${commentsLimit}&start=${commentsStart}`);
503
469
  const comments = uniqueCommentsFromActivities(activityData?.values ?? []).filter((comment) => {
504
470
  const matchesState = commentMatchesState(comment, commentsState);
505
471
  return matchesState && commentMatchesSeverity(comment, commentsSeverity);
@@ -515,14 +481,14 @@ export class BitbucketClient {
515
481
  }
516
482
  }
517
483
  if (includeDiff) {
518
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/diff`);
484
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/diff`);
519
485
  sections.push(`Diff:\n${data ? formatDiff(data, args.diffMaxChars ?? 8000) : '(no diff found)'}`);
520
486
  }
521
487
  return text(sections.join('\n\n'));
522
488
  }
523
489
  async getPrDiff(args) {
524
490
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
525
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/diff`);
491
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/diff`);
526
492
  if (!data)
527
493
  return text('No diff found.');
528
494
  return text(formatDiff(data));
@@ -531,7 +497,7 @@ export class BitbucketClient {
531
497
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
532
498
  const limit = args.limit ?? 25;
533
499
  const start = args.start ?? 0;
534
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/commits?limit=${limit}&start=${start}`);
500
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/commits?limit=${limit}&start=${start}`);
535
501
  if (!data || data.values.length === 0)
536
502
  return text('No commits found.');
537
503
  const lines = data.values.map((c) => `${c.displayId} ${formatDate(c.authorTimestamp)} ${c.author.name}: ${c.message.split('\n')[0]}`);
@@ -549,15 +515,18 @@ export class BitbucketClient {
549
515
  const url = this.pullRequestUrl(projectKey, repoSlug, existing.id, existing);
550
516
  return text(`Open PR already exists for branch "${sourceBranchName}": #${existing.id} "${existing.title}"\n${url}`);
551
517
  }
552
- const { title, description, toBranch = 'master', reviewers = [] } = args;
518
+ const { title, description, reviewers = [] } = args;
519
+ const toRef = args.toBranch
520
+ ? toBranchRef(args.toBranch)
521
+ : await this.getDefaultBranchRef(projectKey, repoSlug);
553
522
  const body = {
554
523
  title,
555
524
  description: description ?? '',
556
525
  fromRef: { id: toBranchRef(sourceBranch), repository: { slug: repoSlug, project: { key: projectKey } } },
557
- toRef: { id: toBranchRef(toBranch), repository: { slug: repoSlug, project: { key: projectKey } } },
526
+ toRef: { id: toRef, repository: { slug: repoSlug, project: { key: projectKey } } },
558
527
  reviewers: reviewers.map((name) => ({ user: { name } })),
559
528
  };
560
- const data = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/pull-requests`, body);
529
+ const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests`, body);
561
530
  if (!data)
562
531
  return text('Pull request created.');
563
532
  const url = this.pullRequestUrl(projectKey, repoSlug, data.id, data);
@@ -571,7 +540,7 @@ export class BitbucketClient {
571
540
  && args.reviewers === undefined) {
572
541
  throw new Error('At least one field is required: title, description, toBranch, or reviewers');
573
542
  }
574
- const existing = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
543
+ const existing = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
575
544
  if (!existing)
576
545
  throw new Error(`PR #${args.prId} not found.`);
577
546
  const buildBody = (pr) => {
@@ -595,16 +564,16 @@ export class BitbucketClient {
595
564
  };
596
565
  let updated;
597
566
  try {
598
- updated = await this.request('PUT', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`, buildBody(existing));
567
+ updated = await this.request('PUT', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`, buildBody(existing));
599
568
  }
600
569
  catch (error) {
601
570
  const message = error instanceof Error ? error.message : String(error);
602
571
  if (!message.includes('Bitbucket 409'))
603
572
  throw error;
604
- const latest = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
573
+ const latest = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
605
574
  if (!latest)
606
575
  throw error;
607
- updated = await this.request('PUT', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`, buildBody(latest));
576
+ updated = await this.request('PUT', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`, buildBody(latest));
608
577
  }
609
578
  if (!updated)
610
579
  return text(`Updated PR #${args.prId}.`);
@@ -670,7 +639,7 @@ export class BitbucketClient {
670
639
  }
671
640
  async approvePr(args) {
672
641
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
673
- const data = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/approve`);
642
+ const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/approve`);
674
643
  const url = this.pullRequestUrl(projectKey, repoSlug, args.prId);
675
644
  if (!data)
676
645
  return text(`Approved PR #${args.prId}.\n${url}`);
@@ -678,25 +647,25 @@ export class BitbucketClient {
678
647
  }
679
648
  async unapprovePr(args) {
680
649
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
681
- await this.request('DELETE', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/approve`);
650
+ await this.request('DELETE', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/approve`);
682
651
  return text(`Approval removed from PR #${args.prId}.\n${this.pullRequestUrl(projectKey, repoSlug, args.prId)}`);
683
652
  }
684
653
  async declinePr(args) {
685
654
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
686
- const pr = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
655
+ const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
687
656
  if (!pr)
688
657
  throw new Error(`PR #${args.prId} not found.`);
689
658
  const body = { version: pr.version };
690
659
  if (args.message)
691
660
  body.message = args.message;
692
- const data = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/decline`, body);
661
+ const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/decline`, body);
693
662
  if (!data)
694
663
  return text(`Declined PR #${args.prId}.\n${this.pullRequestUrl(projectKey, repoSlug, args.prId)}`);
695
664
  return text(`Declined PR #${data.id}: "${data.title}".\n${this.pullRequestUrl(projectKey, repoSlug, data.id, data)}`);
696
665
  }
697
666
  async mergePr(args) {
698
667
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
699
- const pr = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
668
+ const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
700
669
  if (!pr)
701
670
  throw new Error(`PR #${args.prId} not found.`);
702
671
  const body = { version: pr.version };
@@ -704,13 +673,13 @@ export class BitbucketClient {
704
673
  body.strategyId = args.mergeStrategy;
705
674
  if (args.message)
706
675
  body.message = args.message;
707
- const data = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/merge`, body);
676
+ const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/merge`, body);
708
677
  if (!data)
709
678
  return text(`Merged PR #${args.prId}.\n${this.pullRequestUrl(projectKey, repoSlug, args.prId)}`);
710
679
  return text(`Merged PR #${data.id}: "${data.title}" (${data.fromRef.displayId} → ${data.toRef.displayId}).\n${this.pullRequestUrl(projectKey, repoSlug, data.id, data)}`);
711
680
  }
712
681
  async getDefaultBranchRef(projectKey, repoSlug) {
713
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/default-branch`);
682
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/default-branch`);
714
683
  if (data?.displayId)
715
684
  return `refs/heads/${data.displayId}`;
716
685
  // Fallback: detect from local git
@@ -722,7 +691,7 @@ export class BitbucketClient {
722
691
  async createBranch(args) {
723
692
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
724
693
  const startPoint = args.startPoint ?? await this.getDefaultBranchRef(projectKey, repoSlug);
725
- const data = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/branches`, { name: args.branchName, startPoint });
694
+ const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/branches`, { name: args.branchName, startPoint });
726
695
  if (!data)
727
696
  return text(`Branch "${args.branchName}" created.`);
728
697
  return text(`Created branch "${data.displayId}" at ${data.latestCommit.slice(0, 8)} in ${projectKey}/${repoSlug}.`);
@@ -732,7 +701,7 @@ export class BitbucketClient {
732
701
  const qs = new URLSearchParams({ limit: String(args.limit ?? 25), start: String(args.start ?? 0) });
733
702
  if (args.filter)
734
703
  qs.set('filterText', args.filter);
735
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/branches?${qs}`);
704
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/branches?${qs}`);
736
705
  if (!data || data.values.length === 0)
737
706
  return text('No branches found.');
738
707
  const lines = data.values.map((b) => `${b.displayId}${b.isDefault ? ' (default)' : ''} — ${b.latestCommit.slice(0, 8)}`);
@@ -742,7 +711,7 @@ export class BitbucketClient {
742
711
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
743
712
  const qs = args.ref ? `?at=${encodeURIComponent(args.ref)}` : '';
744
713
  const encodedPath = args.path.split('/').map(encodeURIComponent).join('/');
745
- const content = await this.requestText(`/projects/${projectKey}/repos/${repoSlug}/raw/${encodedPath}${qs}`);
714
+ const content = await this.requestText(`${this.rp(projectKey, repoSlug)}/raw/${encodedPath}${qs}`);
746
715
  const MAX_CHARS = 10000;
747
716
  if (content.length > MAX_CHARS) {
748
717
  return text(content.slice(0, MAX_CHARS) + `\n\n... (truncated, ${content.length - MAX_CHARS} more chars)`);
@@ -765,7 +734,7 @@ export class BitbucketClient {
765
734
  const qs = new URLSearchParams({ count: 'true' });
766
735
  if (state)
767
736
  qs.set('state', state);
768
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/blocker-comments?${qs}`);
737
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments?${qs}`);
769
738
  let open = data?.open ?? 0;
770
739
  let resolved = data?.resolved ?? 0;
771
740
  if ((open === 0 && resolved === 0) && data?.values && data.values.length > 0) {
@@ -789,7 +758,7 @@ export class BitbucketClient {
789
758
  const qs = new URLSearchParams({ limit: String(limit), start: String(start) });
790
759
  if (state)
791
760
  qs.set('state', state);
792
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/blocker-comments?${qs}`);
761
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments?${qs}`);
793
762
  if (!data || data.values.length === 0) {
794
763
  return text(`No ${state ?? 'OPEN/RESOLVED'} BLOCKER comments on PR #${args.prId}.`);
795
764
  }
@@ -804,7 +773,7 @@ export class BitbucketClient {
804
773
  });
805
774
  if (state)
806
775
  qs.set('state', state);
807
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments?${qs}`);
776
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments?${qs}`);
808
777
  const filtered = (data?.values ?? []).filter((comment) => {
809
778
  const matchesState = state ? commentMatchesState(comment, state) : true;
810
779
  return matchesState && commentMatchesSeverity(comment, severity);
@@ -816,7 +785,7 @@ export class BitbucketClient {
816
785
  const paging = data ? pageHint(data) : '';
817
786
  return text(`${filtered.length} comment thread(s) on PR #${args.prId} for ${args.path}${paging}:\n\n${blocks.join('\n\n')}`);
818
787
  }
819
- const activityData = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/activities?limit=${limit}&start=${start}`);
788
+ const activityData = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/activities?limit=${limit}&start=${start}`);
820
789
  const comments = uniqueCommentsFromActivities(activityData?.values ?? []).filter((comment) => {
821
790
  const matchesState = state ? commentMatchesState(comment, state) : true;
822
791
  return matchesState && commentMatchesSeverity(comment, severity);
@@ -847,6 +816,8 @@ export class BitbucketClient {
847
816
  commentText = args.text ? `${args.text}\n\n${suggestionBlock}` : suggestionBlock;
848
817
  }
849
818
  const body = { text: validateCommentText(commentText) };
819
+ if (args.severity)
820
+ body.severity = args.severity;
850
821
  if (replyToCommentId !== undefined)
851
822
  body.parent = { id: replyToCommentId };
852
823
  let inlineAnchor;
@@ -854,7 +825,7 @@ export class BitbucketClient {
854
825
  if (args.filePath === undefined || args.line === undefined) {
855
826
  throw new Error('filePath and line must be provided together for inline comments.');
856
827
  }
857
- const pr = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
828
+ const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
858
829
  inlineAnchor = {
859
830
  diffType: 'EFFECTIVE',
860
831
  fileType: args.fileType ?? 'TO',
@@ -879,7 +850,7 @@ export class BitbucketClient {
879
850
  }
880
851
  let created;
881
852
  try {
882
- created = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments`, body);
853
+ created = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments`, body);
883
854
  }
884
855
  catch (error) {
885
856
  const message = error instanceof Error ? error.message : String(error);
@@ -888,7 +859,7 @@ export class BitbucketClient {
888
859
  }
889
860
  const { fromHash: _fromHash, toHash: _toHash, ...anchorWithoutHashes } = inlineAnchor;
890
861
  body.anchor = anchorWithoutHashes;
891
- created = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments`, body);
862
+ created = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments`, body);
892
863
  }
893
864
  if (!created)
894
865
  return text(`Comment added to PR #${args.prId}.`);
@@ -903,17 +874,10 @@ export class BitbucketClient {
903
874
  if (!args.text && !args.state && !args.severity && args.threadResolved === undefined) {
904
875
  throw new Error('At least one field is required: text, state, severity, or threadResolved');
905
876
  }
906
- const current = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments/${args.commentId}`);
877
+ const current = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`);
907
878
  if (!current)
908
879
  throw new Error(`Comment #${args.commentId} not found.`);
909
880
  const currentSeverity = current.severity ?? 'NORMAL';
910
- const severityIsChanging = args.severity !== undefined && args.severity !== currentSeverity;
911
- const isResolutionOnlyUpdate = (args.state !== undefined || args.threadResolved !== undefined)
912
- && args.text === undefined
913
- && !severityIsChanging;
914
- if (!isResolutionOnlyUpdate) {
915
- await this.assertOwnComment(current);
916
- }
917
881
  const targetSeverity = args.severity ?? currentSeverity;
918
882
  if (args.state && targetSeverity !== 'BLOCKER') {
919
883
  throw new Error('state is only supported for BLOCKER comments (tasks). Use threadResolved for normal comment threads.');
@@ -922,8 +886,8 @@ export class BitbucketClient {
922
886
  throw new Error('threadResolved is only supported for normal comments. Use state for BLOCKER comment tasks.');
923
887
  }
924
888
  const commentPath = (targetSeverity === 'BLOCKER' || current.severity === 'BLOCKER')
925
- ? `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/blocker-comments/${args.commentId}`
926
- : `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments/${args.commentId}`;
889
+ ? `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments/${args.commentId}`
890
+ : `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`;
927
891
  const buildBody = (version) => {
928
892
  const body = { version };
929
893
  if (args.text !== undefined)
@@ -960,20 +924,19 @@ export class BitbucketClient {
960
924
  }
961
925
  async deletePrComment(args) {
962
926
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
963
- const current = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments/${args.commentId}`);
927
+ const current = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`);
964
928
  if (!current)
965
929
  throw new Error(`Comment #${args.commentId} not found.`);
966
- await this.assertOwnComment(current);
967
930
  const commentPath = current.severity === 'BLOCKER'
968
- ? `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/blocker-comments/${args.commentId}`
969
- : `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments/${args.commentId}`;
931
+ ? `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments/${args.commentId}`
932
+ : `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`;
970
933
  const path = `${commentPath}?version=${current.version}`;
971
934
  await this.request('DELETE', path);
972
935
  return text(`Comment #${args.commentId} deleted from PR #${args.prId}.`);
973
936
  }
974
937
  async getPrTasks(args) {
975
938
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
976
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/tasks`);
939
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/tasks`);
977
940
  if (!data || data.values.length === 0)
978
941
  return text(`No tasks on PR #${args.prId}.`);
979
942
  const lines = data.values.map((t) => {
@@ -1011,15 +974,22 @@ export class BitbucketClient {
1011
974
  const task = await this.request('GET', `/tasks/${args.taskId}`);
1012
975
  if (!task)
1013
976
  throw new Error(`Task #${args.taskId} not found.`);
1014
- await this.request('DELETE', `/tasks/${args.taskId}?version=${task.id}`);
977
+ // Verify the task belongs to the given PR (when anchor is a direct PR anchor)
978
+ if (args.prId !== undefined && task.anchor?.type === 'PULL_REQUEST' && task.anchor.id !== args.prId) {
979
+ throw new Error(`Task #${args.taskId} does not belong to PR #${args.prId}.`);
980
+ }
981
+ await this.request('DELETE', `/tasks/${args.taskId}?version=${task.version}`);
1015
982
  return text(`Task #${args.taskId} deleted.`);
1016
983
  }
1017
984
  // resolve or reopen
1018
985
  const task = await this.request('GET', `/tasks/${args.taskId}`);
1019
986
  if (!task)
1020
987
  throw new Error(`Task #${args.taskId} not found.`);
988
+ if (args.prId !== undefined && task.anchor?.type === 'PULL_REQUEST' && task.anchor.id !== args.prId) {
989
+ throw new Error(`Task #${args.taskId} does not belong to PR #${args.prId}.`);
990
+ }
1021
991
  const newState = args.action === 'resolve' ? 'RESOLVED' : 'OPEN';
1022
- const updated = await this.request('PUT', `/tasks/${args.taskId}`, {
992
+ const updated = await this.request('PUT', `/tasks/${args.taskId}?version=${task.version}`, {
1023
993
  id: task.id,
1024
994
  state: newState,
1025
995
  text: task.text,
package/dist/config.js CHANGED
@@ -38,7 +38,8 @@ export function loadConfig() {
38
38
  if (jiraUrl && jiraToken) {
39
39
  config.jira = { url: jiraUrl, token: jiraToken };
40
40
  }
41
- else {
41
+ else if (jiraUrl || jiraToken) {
42
+ // Partially configured — log which piece is missing so the user can fix it
42
43
  const missing = [];
43
44
  if (!jiraUrl)
44
45
  missing.push('jira.url (or JIRA_URL)');
@@ -49,7 +50,7 @@ export function loadConfig() {
49
50
  if (bitbucketUrl && bitbucketToken) {
50
51
  config.bitbucket = { url: bitbucketUrl, token: bitbucketToken };
51
52
  }
52
- else {
53
+ else if (bitbucketUrl || bitbucketToken) {
53
54
  const missing = [];
54
55
  if (!bitbucketUrl)
55
56
  missing.push('bitbucket.url (or BITBUCKET_URL)');
package/dist/context.js CHANGED
@@ -20,9 +20,25 @@ export async function getDevContext(args, jira, bitbucket) {
20
20
  const remote = safeExec('git remote get-url origin', repoPath) || '(no remote)';
21
21
  const recentCommits = safeExec('git log --oneline -5', repoPath) || '(none)';
22
22
  const status = safeExec('git status --short', repoPath) || '(clean)';
23
+ // Upstream ahead/behind
24
+ const upstream = safeExec('git rev-parse --abbrev-ref @{u}', repoPath);
25
+ let upstreamLine = '';
26
+ if (upstream) {
27
+ const ab = safeExec(`git rev-list --left-right --count ${upstream}...HEAD`, repoPath);
28
+ if (ab.includes('\t')) {
29
+ const [behind, ahead] = ab.split('\t').map(Number);
30
+ const parts = [];
31
+ if (ahead)
32
+ parts.push(`${ahead} ahead`);
33
+ if (behind)
34
+ parts.push(`${behind} behind`);
35
+ upstreamLine = `${upstream}${parts.length ? ` (${parts.join(', ')})` : ' (up to date)'}`;
36
+ }
37
+ }
23
38
  sections.push([
24
39
  `Repository: ${repoPath}`,
25
40
  `Branch: ${branch}`,
41
+ ...(upstreamLine ? [`Upstream: ${upstreamLine}`] : []),
26
42
  `Remote: ${remote}`,
27
43
  '',
28
44
  'Recent commits:',
@@ -31,9 +47,9 @@ export async function getDevContext(args, jira, bitbucket) {
31
47
  'Working tree:',
32
48
  status,
33
49
  ].join('\n'));
34
- // Jira — fetch overview for any tickets referenced in the branch name
50
+ // Jira — fetch overview for any tickets referenced in the branch name (parallel)
35
51
  const jiraKeys = jira ? [...new Set(branch.match(JIRA_KEY_RE) ?? [])] : [];
36
- for (const key of jiraKeys) {
52
+ const jiraResults = await Promise.all(jiraKeys.map(async (key) => {
37
53
  try {
38
54
  const result = await jira.issueOverview({
39
55
  issueKey: key,
@@ -42,12 +58,13 @@ export async function getDevContext(args, jira, bitbucket) {
42
58
  includeTransitions: true,
43
59
  includeSprint: true,
44
60
  });
45
- sections.push(`── Jira ${key} ──\n${result.content[0].text}`);
61
+ return `── Jira ${key} ──\n${result.content[0].text}`;
46
62
  }
47
63
  catch {
48
- sections.push(`── Jira ${key} ── (could not fetch)`);
64
+ return `── Jira ${key} ── (could not fetch)`;
49
65
  }
50
- }
66
+ }));
67
+ sections.push(...jiraResults);
51
68
  // Bitbucket — find the open PR for this branch (only if remote points to this instance)
52
69
  const parsed = bitbucket?.isRemoteForThisInstance(remote) ? parseBitbucketRemote(remote) : null;
53
70
  if (parsed) {
package/dist/git.js CHANGED
@@ -1,32 +1,55 @@
1
- import { execSync } from 'child_process';
1
+ import { execFileSync } from 'child_process';
2
2
  const JIRA_KEY_RE = /\b[A-Z][A-Z0-9]+-\d+\b/g;
3
+ // Allowlist for git refs (commits, branches used as refs in diff commands)
4
+ const SAFE_REF_RE = /^[a-zA-Z0-9/_.\-@{}~^:]+(\.\.\.[a-zA-Z0-9/_.\-@{}~^:]+)?$/;
5
+ // Allowlist for branch names (stricter — no range syntax)
6
+ const SAFE_BRANCH_RE = /^[a-zA-Z0-9/_.\-]+$/;
3
7
  function text(t) {
4
8
  return { content: [{ type: 'text', text: t }] };
5
9
  }
6
- function git(cmd, cwd) {
7
- return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8' }).trim();
10
+ function git(args, cwd) {
11
+ return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
8
12
  }
9
- function safeGit(cmd, cwd, fallback = '') {
13
+ function safeGit(args, cwd, fallback = '') {
10
14
  try {
11
- return git(cmd, cwd);
15
+ return git(args, cwd);
12
16
  }
13
17
  catch {
14
18
  return fallback;
15
19
  }
16
20
  }
21
+ function validateRepoPath(repoPath) {
22
+ try {
23
+ execFileSync('git', ['rev-parse', '--git-dir'], { cwd: repoPath, encoding: 'utf-8', stdio: 'pipe' });
24
+ }
25
+ catch {
26
+ throw new Error(`Not a git repository: ${repoPath}`);
27
+ }
28
+ }
29
+ function validateBranch(branch, label) {
30
+ if (!SAFE_BRANCH_RE.test(branch)) {
31
+ throw new Error(`Invalid ${label} "${branch}". Use only letters, numbers, /, _, ., -`);
32
+ }
33
+ }
34
+ function validateRef(ref, label) {
35
+ if (!SAFE_REF_RE.test(ref)) {
36
+ throw new Error(`Invalid ${label} "${ref}". Use only safe git ref characters.`);
37
+ }
38
+ }
17
39
  export function getContext(args) {
18
40
  const repoPath = args.repoPath ?? process.cwd();
19
- const limit = args.commitLimit ?? 10;
41
+ const limit = Math.max(1, Math.min(args.commitLimit ?? 10, 100));
20
42
  try {
21
- const branch = safeGit('rev-parse --abbrev-ref HEAD', repoPath, '(unknown)');
22
- const remote = safeGit('remote get-url origin', repoPath, '(no remote)');
23
- const commits = safeGit(`log --oneline -${limit}`, repoPath, '(no commits)');
24
- const status = safeGit('status --short', repoPath, '');
43
+ validateRepoPath(repoPath);
44
+ const branch = safeGit(['rev-parse', '--abbrev-ref', 'HEAD'], repoPath, '(unknown)');
45
+ const remote = safeGit(['remote', 'get-url', 'origin'], repoPath, '(no remote)');
46
+ const commits = safeGit(['log', '--oneline', `-${limit}`], repoPath, '(no commits)');
47
+ const status = safeGit(['status', '--short'], repoPath, '');
25
48
  // Upstream tracking
26
- const upstream = safeGit('rev-parse --abbrev-ref @{u}', repoPath, '');
49
+ const upstream = safeGit(['rev-parse', '--abbrev-ref', '@{u}'], repoPath, '');
27
50
  let upstreamLine = '';
28
51
  if (upstream) {
29
- const ab = safeGit(`rev-list --left-right --count ${upstream}...HEAD`, repoPath, '');
52
+ const ab = safeGit(['rev-list', '--left-right', '--count', `${upstream}...HEAD`], repoPath, '');
30
53
  if (ab.includes('\t')) {
31
54
  const [behind, ahead] = ab.split('\t').map(Number);
32
55
  const parts = [];
@@ -38,7 +61,7 @@ export function getContext(args) {
38
61
  }
39
62
  }
40
63
  // Diff stat summary
41
- const diffStatLines = safeGit('diff HEAD --stat', repoPath, '').split('\n').filter(Boolean);
64
+ const diffStatLines = safeGit(['diff', 'HEAD', '--stat'], repoPath, '').split('\n').filter(Boolean);
42
65
  const diffStat = diffStatLines[diffStatLines.length - 1]?.trim() ?? '';
43
66
  const jiraKeys = [...new Set(branch.match(JIRA_KEY_RE) ?? [])];
44
67
  const lines = [
@@ -62,7 +85,7 @@ export function getContext(args) {
62
85
  lines.push('(clean)');
63
86
  }
64
87
  if (args.includeDiff && status) {
65
- const diff = safeGit('diff HEAD', repoPath, '');
88
+ const diff = safeGit(['diff', 'HEAD'], repoPath, '');
66
89
  if (diff) {
67
90
  const MAX = 6000;
68
91
  lines.push('', '── Uncommitted diff ──', diff.length > MAX ? diff.slice(0, MAX) + `\n\n... (truncated, ${diff.length - MAX} more chars)` : diff);
@@ -74,18 +97,22 @@ export function getContext(args) {
74
97
  return text(`Error reading git context: ${err.message}`);
75
98
  }
76
99
  }
100
+ // Not exposed as an MCP tool — internal/experimental use only
77
101
  export function getCommits(args) {
78
102
  const repoPath = args.repoPath ?? process.cwd();
79
- const limit = args.limit ?? 20;
103
+ const limit = Math.max(1, Math.min(args.limit ?? 20, 200));
80
104
  const branch = args.branch ?? '';
81
105
  try {
106
+ validateRepoPath(repoPath);
107
+ if (branch)
108
+ validateBranch(branch, 'branch');
82
109
  const format = '%H|%an|%ad|%s';
83
- const cmd = `log --pretty=format:"${format}" --date=short -${limit}${branch ? ` ${branch}` : ''}`;
84
- const raw = safeGit(cmd, repoPath, '');
110
+ const gitArgs = ['log', `--pretty=format:${format}`, '--date=short', `-${limit}`, ...(branch ? [branch] : [])];
111
+ const raw = safeGit(gitArgs, repoPath, '');
85
112
  if (!raw)
86
113
  return text('No commits found.');
87
114
  const lines = raw.split('\n').map((line) => {
88
- const [hash, author, date, ...msgParts] = line.replace(/^"|"$/g, '').split('|');
115
+ const [hash, author, date, ...msgParts] = line.split('|');
89
116
  return `${hash?.slice(0, 8)} ${date} ${author}: ${msgParts.join('|')}`;
90
117
  });
91
118
  return text(`Last ${lines.length} commit(s)${branch ? ` on ${branch}` : ''}:\n${lines.join('\n')}`);
@@ -96,24 +123,24 @@ export function getCommits(args) {
96
123
  }
97
124
  export function getDiff(args) {
98
125
  const repoPath = args.repoPath ?? process.cwd();
99
- const MAX_CHARS = 8000;
100
126
  try {
101
- let cmd;
127
+ validateRepoPath(repoPath);
128
+ let gitArgs;
102
129
  if (args.fromRef && args.toRef) {
103
- cmd = `diff ${args.fromRef} ${args.toRef}`;
130
+ validateRef(args.fromRef, 'fromRef');
131
+ validateRef(args.toRef, 'toRef');
132
+ gitArgs = ['diff', args.fromRef, args.toRef];
104
133
  }
105
134
  else if (args.fromRef) {
106
- cmd = `diff ${args.fromRef}`;
135
+ validateRef(args.fromRef, 'fromRef');
136
+ gitArgs = ['diff', args.fromRef];
107
137
  }
108
138
  else {
109
- cmd = 'diff HEAD';
139
+ gitArgs = ['diff', 'HEAD'];
110
140
  }
111
- const diff = safeGit(cmd, repoPath, '');
141
+ const diff = safeGit(gitArgs, repoPath, '');
112
142
  if (!diff)
113
143
  return text('No differences found.');
114
- if (diff.length > MAX_CHARS) {
115
- return text(diff.slice(0, MAX_CHARS) + `\n\n... (truncated, ${diff.length - MAX_CHARS} more chars)`);
116
- }
117
144
  return text(diff);
118
145
  }
119
146
  catch (err) {
@@ -121,13 +148,19 @@ export function getDiff(args) {
121
148
  }
122
149
  }
123
150
  export function checkRemoteBranch(branchName, repoPath) {
124
- const lsRemote = safeGit(`ls-remote --heads origin refs/heads/${branchName}`, repoPath);
151
+ validateBranch(branchName, 'branchName');
152
+ const lsRemote = safeGit(['ls-remote', '--heads', 'origin', `refs/heads/${branchName}`], repoPath);
125
153
  if (!lsRemote)
126
154
  return { exists: false };
127
155
  const sha = lsRemote.split(/\s+/)[0]?.trim();
128
- // Fetch so we can read the log
129
- safeGit(`fetch origin ${branchName}`, repoPath);
130
- const log = safeGit(`log origin/${branchName} -1 --format=%an%x09%ae%x09%ad%x09%s`, repoPath);
156
+ // Fetch so we can read the log — failure is non-fatal (network/credentials issue)
157
+ try {
158
+ git(['fetch', 'origin', branchName], repoPath);
159
+ }
160
+ catch {
161
+ return { exists: true, sha: sha?.slice(0, 8) };
162
+ }
163
+ const log = safeGit(['log', `origin/${branchName}`, '-1', '--format=%an%x09%ae%x09%ad%x09%s'], repoPath);
131
164
  if (!log)
132
165
  return { exists: true, sha: sha?.slice(0, 8) };
133
166
  const [author, email, date, ...msgParts] = log.split('\t');
@@ -140,22 +173,23 @@ export function checkRemoteBranch(branchName, repoPath) {
140
173
  };
141
174
  }
142
175
  function getDefaultBranch(repoPath) {
143
- const head = safeGit('rev-parse --abbrev-ref origin/HEAD', repoPath);
176
+ const head = safeGit(['rev-parse', '--abbrev-ref', 'origin/HEAD'], repoPath);
144
177
  if (head && head.startsWith('origin/'))
145
178
  return head.slice('origin/'.length);
146
179
  // origin/HEAD not set — probe common defaults
147
- if (safeGit('rev-parse --verify origin/main', repoPath))
180
+ if (safeGit(['rev-parse', '--verify', 'origin/main'], repoPath))
148
181
  return 'main';
149
182
  return 'master';
150
183
  }
151
184
  export function checkoutRemoteBranch(branchName, repoPath) {
152
185
  try {
153
- const existing = safeGit(`branch --list ${branchName}`, repoPath);
186
+ validateBranch(branchName, 'branchName');
187
+ const existing = safeGit(['branch', '--list', branchName], repoPath);
154
188
  if (existing.trim()) {
155
- git(`checkout ${branchName}`, repoPath);
189
+ git(['checkout', branchName], repoPath);
156
190
  return text(`Switched to existing local branch "${branchName}".`);
157
191
  }
158
- git(`checkout --track origin/${branchName}`, repoPath);
192
+ git(['checkout', '--track', `origin/${branchName}`], repoPath);
159
193
  return text(`Checked out "${branchName}" tracking origin/${branchName}.`);
160
194
  }
161
195
  catch (err) {
@@ -165,20 +199,24 @@ export function checkoutRemoteBranch(branchName, repoPath) {
165
199
  export function createBranch(args) {
166
200
  const repoPath = args.repoPath ?? process.cwd();
167
201
  const { branchName, push = false } = args;
168
- const baseBranch = args.baseBranch ?? getDefaultBranch(repoPath);
169
202
  try {
170
- if (!/^[a-zA-Z0-9/_.\-]+$/.test(branchName)) {
203
+ validateRepoPath(repoPath);
204
+ if (!SAFE_BRANCH_RE.test(branchName)) {
171
205
  return text(`Invalid branch name "${branchName}". Use only letters, numbers, /, _, ., -`);
172
206
  }
173
- const existing = safeGit(`branch --list ${branchName}`, repoPath);
207
+ const baseBranch = args.baseBranch ?? getDefaultBranch(repoPath);
208
+ if (!SAFE_BRANCH_RE.test(baseBranch)) {
209
+ return text(`Invalid base branch name "${baseBranch}". Use only letters, numbers, /, _, ., -`);
210
+ }
211
+ const existing = safeGit(['branch', '--list', branchName], repoPath);
174
212
  if (existing.trim()) {
175
213
  return text(`Branch "${branchName}" already exists locally. Switch with: git checkout ${branchName}`);
176
214
  }
177
- safeGit(`fetch origin ${baseBranch}`, repoPath);
178
- git(`checkout -b ${branchName} origin/${baseBranch}`, repoPath);
215
+ safeGit(['fetch', 'origin', baseBranch], repoPath);
216
+ git(['checkout', '-b', branchName, `origin/${baseBranch}`], repoPath);
179
217
  const lines = [`Created and switched to branch "${branchName}" from origin/${baseBranch}.`];
180
218
  if (push) {
181
- git(`push -u origin ${branchName}`, repoPath);
219
+ git(['push', '-u', 'origin', branchName], repoPath);
182
220
  lines.push(`Pushed to origin/${branchName} and set upstream.`);
183
221
  }
184
222
  return text(lines.join('\n'));
package/dist/index.js CHANGED
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import 'dotenv/config';
3
- import { execSync } from 'child_process';
3
+ import { execFileSync } from 'child_process';
4
+ import { readFileSync } from 'fs';
5
+ import { fileURLToPath } from 'url';
6
+ import { dirname, join } from 'path';
4
7
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
8
+ const _pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf-8'));
5
9
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
10
  import { CallToolRequestSchema, ListToolsRequestSchema, McpError, ErrorCode, } from '@modelcontextprotocol/sdk/types.js';
7
11
  import { loadConfig } from './config.js';
@@ -11,7 +15,7 @@ import { getContext, getDiff, createBranch, checkRemoteBranch, checkoutRemoteBra
11
15
  import { getDevContext } from './context.js';
12
16
  function currentGitRemote() {
13
17
  try {
14
- return execSync('git remote get-url origin', { encoding: 'utf-8' }).trim();
18
+ return execFileSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf-8' }).trim();
15
19
  }
16
20
  catch {
17
21
  return '';
@@ -35,7 +39,7 @@ const bitbucket = (config.bitbucket && remoteMatchesBitbucketInstance(_remote, c
35
39
  if (config.bitbucket && !bitbucket) {
36
40
  console.error(`[atlassian-mcp] Bitbucket configured but remote "${_remote || '(none)'}" does not match ${config.bitbucket.url} — Bitbucket tools disabled for this repo.`);
37
41
  }
38
- const server = new Server({ name: 'atlassian-mcp', version: '1.0.0' }, { capabilities: { tools: {} } });
42
+ const server = new Server({ name: 'atlassian-mcp', version: _pkg.version }, { capabilities: { tools: {} } });
39
43
  server.onerror = (error) => console.error('[MCP Error]', error);
40
44
  function normalizeBitbucketArgs(args) {
41
45
  const src = (args && typeof args === 'object') ? args : {};
@@ -495,17 +499,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
495
499
  if (result.action === 'cancel' || result.action === 'decline') {
496
500
  return { content: [{ type: 'text', text: 'Cancelled.' }] };
497
501
  }
498
- if (result.action === 'accept' && result.content?.action === 'checkout') {
499
- const checkout = checkoutRemoteBranch(branchName, repoPath);
500
- return { content: [{ type: 'text', text: `${message}\n\n${checkout.content[0].text}` }] };
502
+ if (result.action === 'accept') {
503
+ const chosen = result.content?.action;
504
+ if (chosen === 'checkout') {
505
+ const checkout = checkoutRemoteBranch(branchName, repoPath);
506
+ return { content: [{ type: 'text', text: `${message}\n\n${checkout.content[0].text}` }] };
507
+ }
508
+ if (chosen === 'cancel') {
509
+ return { content: [{ type: 'text', text: 'Cancelled.' }] };
510
+ }
511
+ // new_name — instruct the model to re-run with a custom name
512
+ return {
513
+ content: [{
514
+ type: 'text',
515
+ text: `${message}\n\nRe-run start_work with a custom branchName to proceed.`,
516
+ }],
517
+ };
501
518
  }
502
- // new_name instruct the model to re-run with a custom name
503
- return {
504
- content: [{
505
- type: 'text',
506
- text: `${message}\n\nRe-run start_work with a custom branchName to proceed.`,
507
- }],
508
- };
519
+ // Fallback: unknown action
520
+ return { content: [{ type: 'text', text: 'Cancelled.' }] };
509
521
  }
510
522
  catch {
511
523
  // Client doesn't support elicitation — fall back to informational text
@@ -656,9 +668,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
656
668
  // Resolve PR — by prId or current branch
657
669
  let resolvedPrId = a.prId;
658
670
  if (resolvedPrId === undefined) {
659
- const { execSync } = await import('child_process');
660
671
  const branch = (() => { try {
661
- return execSync('git rev-parse --abbrev-ref HEAD', { cwd: repoPath, encoding: 'utf-8' }).trim();
672
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoPath, encoding: 'utf-8' }).trim();
662
673
  }
663
674
  catch {
664
675
  return '';
@@ -667,7 +678,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
667
678
  throw new Error('Could not determine current branch. Provide prId or run from a checked-out branch.');
668
679
  }
669
680
  const remote = (() => { try {
670
- return execSync('git remote get-url origin', { cwd: repoPath, encoding: 'utf-8' }).trim();
681
+ return execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: repoPath, encoding: 'utf-8' }).trim();
671
682
  }
672
683
  catch {
673
684
  return '';
@@ -730,9 +741,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
730
741
  };
731
742
  }
732
743
  });
733
- process.on('SIGINT', async () => {
744
+ async function shutdown() {
734
745
  await server.close();
735
746
  process.exit(0);
736
- });
747
+ }
748
+ process.on('SIGINT', shutdown);
749
+ process.on('SIGTERM', shutdown);
737
750
  const transport = new StdioServerTransport();
738
751
  await server.connect(transport);
package/dist/jira.js CHANGED
@@ -9,8 +9,11 @@ function pagination(total, startAt, count) {
9
9
  return total > end ? ` (showing ${startAt + 1}–${end} of ${total}, use startAt=${end} for next page)` : '';
10
10
  }
11
11
  function buildJQL(args) {
12
- if (args.jql)
12
+ if (args.jql) {
13
+ if (args.jql.length > 2000)
14
+ throw new Error('JQL query too long (max 2000 characters).');
13
15
  return args.jql;
16
+ }
14
17
  const clauses = [];
15
18
  if (args.query)
16
19
  clauses.push(`text ~ ${JSON.stringify(args.query)}`);
@@ -91,6 +94,7 @@ export class JiraClient {
91
94
  baseUrl;
92
95
  headers;
93
96
  currentUserCache;
97
+ projectsCache;
94
98
  constructor(baseUrl, token) {
95
99
  this.baseUrl = baseUrl.replace(/\/$/, '');
96
100
  this.headers = {
@@ -113,7 +117,7 @@ export class JiraClient {
113
117
  }
114
118
  async requestWithBase(apiBase, method, path, body) {
115
119
  const url = `${this.baseUrl}${apiBase}${path}`;
116
- const opts = { method, headers: this.headers };
120
+ const opts = { method, headers: this.headers, signal: AbortSignal.timeout(30_000) };
117
121
  if (body !== undefined)
118
122
  opts.body = JSON.stringify(body);
119
123
  const res = await fetch(url, opts);
@@ -195,7 +199,7 @@ export class JiraClient {
195
199
  if (args.description !== undefined)
196
200
  fields.description = args.description;
197
201
  if (args.assignee !== undefined)
198
- fields.assignee = { name: args.assignee };
202
+ fields.assignee = args.assignee ? { name: args.assignee } : null;
199
203
  if (args.priority !== undefined)
200
204
  fields.priority = { name: args.priority };
201
205
  if (args.labels !== undefined)
@@ -204,7 +208,7 @@ export class JiraClient {
204
208
  fields.fixVersions = args.fixVersion ? [{ name: args.fixVersion }] : [];
205
209
  if (Object.keys(fields).length === 0)
206
210
  return false;
207
- await this.request('PUT', `/issue/${args.issueKey}`, { fields });
211
+ await this.request('PUT', `/issue/${encodeURIComponent(args.issueKey)}`, { fields });
208
212
  return true;
209
213
  }
210
214
  async resolveTransitionId(issueKey, transitionId, transitionName) {
@@ -214,7 +218,7 @@ export class JiraClient {
214
218
  if (!requestedName) {
215
219
  throw new Error('Provide transitionId or transitionName');
216
220
  }
217
- const data = await this.request('GET', `/issue/${issueKey}/transitions`);
221
+ const data = await this.request('GET', `/issue/${encodeURIComponent(issueKey)}/transitions`);
218
222
  const transitions = data?.transitions ?? [];
219
223
  const lowered = requestedName.toLowerCase();
220
224
  const match = transitions.find((t) => t.name.toLowerCase() === lowered);
@@ -225,14 +229,17 @@ export class JiraClient {
225
229
  return match.id;
226
230
  }
227
231
  async transitionIssueInternal(issueKey, transitionId) {
228
- await this.request('POST', `/issue/${issueKey}/transitions`, {
232
+ await this.request('POST', `/issue/${encodeURIComponent(issueKey)}/transitions`, {
229
233
  transition: { id: transitionId },
230
234
  });
231
235
  }
232
236
  async resolveProjectKey(projectKey) {
233
237
  if (projectKey)
234
238
  return projectKey;
235
- const projects = (await this.request('GET', '/project?maxResults=100')) ?? [];
239
+ if (!this.projectsCache) {
240
+ this.projectsCache = (await this.request('GET', '/project?maxResults=100')) ?? [];
241
+ }
242
+ const projects = this.projectsCache;
236
243
  if (projects.length === 0) {
237
244
  throw new Error('No Jira projects found for your account.');
238
245
  }
@@ -287,7 +294,7 @@ export class JiraClient {
287
294
  }
288
295
  async getIssueTypes(args) {
289
296
  const projectKey = await this.resolveProjectKey(args.projectKey);
290
- const data = await this.request('GET', `/project/${projectKey}/statuses`);
297
+ const data = await this.request('GET', `/project/${encodeURIComponent(projectKey)}/statuses`);
291
298
  if (!data || data.length === 0)
292
299
  return text('No issue types found.');
293
300
  const lines = data.map((t) => {
@@ -330,7 +337,7 @@ export class JiraClient {
330
337
  return text(`${lines.length} user(s) found:\n${lines.join('\n')}`);
331
338
  }
332
339
  async getIssueFields(issueKey) {
333
- const data = await this.request('GET', `/issue/${issueKey}?fields=summary,status,issuetype`);
340
+ const data = await this.request('GET', `/issue/${encodeURIComponent(issueKey)}?fields=summary,status,issuetype`);
334
341
  if (!data)
335
342
  throw new Error(`Issue ${issueKey} not found`);
336
343
  return {
@@ -341,7 +348,7 @@ export class JiraClient {
341
348
  }
342
349
  async getIssue(args) {
343
350
  const fields = 'summary,description,status,assignee,priority,issuetype,labels,components';
344
- const data = await this.request('GET', `/issue/${args.issueKey}?fields=${fields}`);
351
+ const data = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}?fields=${fields}`);
345
352
  if (!data)
346
353
  return text('Issue not found.');
347
354
  const f = data.fields;
@@ -367,7 +374,7 @@ export class JiraClient {
367
374
  const commentsMaxResults = args.commentsMaxResults ?? 10;
368
375
  const commentsStartAt = args.commentsStartAt ?? 0;
369
376
  const fields = 'summary,description,status,assignee,priority,issuetype,labels,components,parent,fixVersions,issuelinks,subtasks';
370
- const issue = await this.request('GET', `/issue/${args.issueKey}?fields=${fields}`);
377
+ const issue = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}?fields=${fields}`);
371
378
  if (!issue)
372
379
  return text('Issue not found.');
373
380
  const f = issue.fields;
@@ -395,7 +402,7 @@ export class JiraClient {
395
402
  ];
396
403
  if (includeSprint) {
397
404
  try {
398
- const agileIssue = await this.requestAgile('GET', `/issue/${args.issueKey}?fields=sprint,closedSprints`);
405
+ const agileIssue = await this.requestAgile('GET', `/issue/${encodeURIComponent(args.issueKey)}?fields=sprint,closedSprints`);
399
406
  const activeSprint = agileIssue?.fields?.sprint;
400
407
  const closedSprints = agileIssue?.fields?.closedSprints ?? [];
401
408
  if (activeSprint) {
@@ -415,13 +422,13 @@ export class JiraClient {
415
422
  }
416
423
  }
417
424
  if (includeTransitions) {
418
- const transitions = await this.request('GET', `/issue/${args.issueKey}/transitions`);
425
+ const transitions = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}/transitions`);
419
426
  const names = (transitions?.transitions ?? []).map((t) => `${t.name} -> ${t.to.name}`);
420
427
  lines.push(`Transitions: ${names.length > 0 ? names.join(', ') : '(none)'}`);
421
428
  }
422
429
  lines.push('', 'Description:', f.description ?? '(no description)');
423
430
  if (includeComments) {
424
- const comments = await this.request('GET', `/issue/${args.issueKey}/comment?startAt=${commentsStartAt}&maxResults=${commentsMaxResults}`);
431
+ const comments = await this.request('GET', `/issue/${encodeURIComponent(args.issueKey)}/comment?startAt=${commentsStartAt}&maxResults=${commentsMaxResults}`);
425
432
  const items = comments?.comments ?? [];
426
433
  const total = comments?.total ?? 0;
427
434
  const page = comments ? pagination(total, commentsStartAt, items.length) : '';
@@ -592,7 +599,7 @@ export class JiraClient {
592
599
  actions.push(`transitioned via ${transitionId}`);
593
600
  }
594
601
  if (args.comment !== undefined) {
595
- await this.request('POST', `/issue/${issueKey}/comment`, { body: validateCommentBody(args.comment) });
602
+ await this.request('POST', `/issue/${encodeURIComponent(issueKey)}/comment`, { body: validateCommentBody(args.comment) });
596
603
  actions.push('added comment');
597
604
  }
598
605
  if (args.link) {
@@ -610,7 +617,7 @@ export class JiraClient {
610
617
  wBody.comment = args.worklog.comment;
611
618
  if (args.worklog.started)
612
619
  wBody.started = args.worklog.started;
613
- await this.request('POST', `/issue/${issueKey}/worklog`, wBody);
620
+ await this.request('POST', `/issue/${encodeURIComponent(issueKey)}/worklog`, wBody);
614
621
  actions.push(`logged ${args.worklog.timeSpent}`);
615
622
  }
616
623
  if (actions.length === 0) {
@@ -620,7 +627,7 @@ export class JiraClient {
620
627
  }
621
628
  async getComments(args) {
622
629
  const { issueKey, maxResults = 50, startAt = 0 } = args;
623
- const data = await this.request('GET', `/issue/${issueKey}/comment?startAt=${startAt}&maxResults=${maxResults}`);
630
+ const data = await this.request('GET', `/issue/${encodeURIComponent(issueKey)}/comment?startAt=${startAt}&maxResults=${maxResults}`);
624
631
  if (!data || data.comments.length === 0)
625
632
  return text('No comments found.');
626
633
  const blocks = data.comments.map((c) => {
@@ -631,15 +638,15 @@ export class JiraClient {
631
638
  return text(`${data.total} comment(s) on ${issueKey}${page}:\n\n${blocks.join('\n\n')}`);
632
639
  }
633
640
  async addComment(args) {
634
- await this.request('POST', `/issue/${args.issueKey}/comment`, { body: validateCommentBody(args.body) });
641
+ await this.request('POST', `/issue/${encodeURIComponent(args.issueKey)}/comment`, { body: validateCommentBody(args.body) });
635
642
  return text(`Comment added to ${args.issueKey}.`);
636
643
  }
637
644
  async editComment(args) {
638
- const commentId = String(args.commentId).trim();
639
- if (!commentId) {
645
+ const commentId = String(args.commentId ?? '').trim();
646
+ if (!commentId || commentId === 'undefined' || commentId === 'null') {
640
647
  throw new Error('commentId is required.');
641
648
  }
642
- const path = `/issue/${args.issueKey}/comment/${commentId}`;
649
+ const path = `/issue/${encodeURIComponent(args.issueKey)}/comment/${encodeURIComponent(commentId)}`;
643
650
  const current = await this.request('GET', path);
644
651
  if (!current)
645
652
  throw new Error(`Comment ${commentId} not found on ${args.issueKey}.`);
@@ -648,11 +655,11 @@ export class JiraClient {
648
655
  return text(`Comment ${commentId} updated on ${args.issueKey}.`);
649
656
  }
650
657
  async deleteComment(args) {
651
- const commentId = String(args.commentId).trim();
652
- if (!commentId) {
658
+ const commentId = String(args.commentId ?? '').trim();
659
+ if (!commentId || commentId === 'undefined' || commentId === 'null') {
653
660
  throw new Error('commentId is required.');
654
661
  }
655
- const path = `/issue/${args.issueKey}/comment/${commentId}`;
662
+ const path = `/issue/${encodeURIComponent(args.issueKey)}/comment/${encodeURIComponent(commentId)}`;
656
663
  const current = await this.request('GET', path);
657
664
  if (!current)
658
665
  throw new Error(`Comment ${commentId} not found on ${args.issueKey}.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stubbedev/atlassian-mcp",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "MCP server for self-hosted Jira and Bitbucket",
5
5
  "license": "MIT",
6
6
  "type": "module",