@stubbedev/atlassian-mcp 0.2.6 → 0.2.7

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;
@@ -190,6 +192,10 @@ export class BitbucketClient {
190
192
  Accept: 'application/json',
191
193
  };
192
194
  }
195
+ /** Returns a URL-safe `/projects/.../repos/...` prefix for REST paths. */
196
+ rp(projectKey, repoSlug) {
197
+ return `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}`;
198
+ }
193
199
  pullRequestUrl(projectKey, repoSlug, prId, pr) {
194
200
  const apiUrl = pr?.links?.self?.[0]?.href?.trim();
195
201
  if (apiUrl) {
@@ -232,7 +238,7 @@ export class BitbucketClient {
232
238
  }
233
239
  async request(method, path, body) {
234
240
  const url = `${this.baseUrl}/rest/api/1.0${path}`;
235
- const opts = { method, headers: this.headers };
241
+ const opts = { method, headers: this.headers, signal: AbortSignal.timeout(30_000) };
236
242
  if (body !== undefined)
237
243
  opts.body = JSON.stringify(body);
238
244
  const res = await fetch(url, opts);
@@ -248,6 +254,7 @@ export class BitbucketClient {
248
254
  const res = await fetch(url, {
249
255
  method: 'GET',
250
256
  headers: { Authorization: this.headers.Authorization },
257
+ signal: AbortSignal.timeout(30_000),
251
258
  });
252
259
  if (!res.ok) {
253
260
  const errText = await res.text();
@@ -258,7 +265,7 @@ export class BitbucketClient {
258
265
  }
259
266
  async requestBuildStatus(method, path, body) {
260
267
  const url = `${this.baseUrl}/rest/build-status/1.0${path}`;
261
- const opts = { method, headers: this.headers };
268
+ const opts = { method, headers: this.headers, signal: AbortSignal.timeout(30_000) };
262
269
  if (body !== undefined)
263
270
  opts.body = JSON.stringify(body);
264
271
  const res = await fetch(url, opts);
@@ -307,24 +314,19 @@ export class BitbucketClient {
307
314
  isRemoteForThisInstance(remoteUrl) {
308
315
  return this.remoteMatchesInstance(remoteUrl);
309
316
  }
310
- // Used internally by context tools — finds the open PR for a given source branch
317
+ // Used internally by context tools — finds the open PR for a given source branch.
318
+ // Uses the `at` filter to avoid paginating all open PRs.
311
319
  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
- }
320
+ const atRef = encodeURIComponent(toBranchRef(branch));
321
+ const encodedProject = encodeURIComponent(projectKey);
322
+ const encodedRepo = encodeURIComponent(repoSlug);
323
+ const data = await this.request('GET', `/projects/${encodedProject}/repos/${encodedRepo}/pull-requests?state=OPEN&direction=OUTGOING&at=${atRef}&limit=1`);
324
+ return data?.values[0] ?? null;
323
325
  }
324
326
  // Fallback: search branches matching filterText and check each for an open PR.
325
327
  // Used when exact branch name lookup yields no result (e.g. LLM provides a partial branch name).
326
328
  async findOpenPrByBranchFilter(projectKey, repoSlug, filterText) {
327
- const branches = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/branches?limit=25&filterText=${encodeURIComponent(filterText)}`);
329
+ const branches = await this.request('GET', `${this.rp(projectKey, repoSlug)}/branches?limit=25&filterText=${encodeURIComponent(filterText)}`);
328
330
  if (!branches?.values?.length)
329
331
  return null;
330
332
  for (const b of branches.values) {
@@ -338,7 +340,7 @@ export class BitbucketClient {
338
340
  const { limit = 50, start = 0 } = args;
339
341
  const qs = `?limit=${limit}&start=${start}`;
340
342
  const path = args.projectKey
341
- ? `/projects/${args.projectKey}/repos${qs}`
343
+ ? `/projects/${encodeURIComponent(args.projectKey)}/repos${qs}`
342
344
  : `/repos${qs}`;
343
345
  const data = await this.request('GET', path);
344
346
  if (!data || data.values.length === 0)
@@ -356,7 +358,7 @@ export class BitbucketClient {
356
358
  }
357
359
  if (searchText)
358
360
  qs.set('filterText', searchText);
359
- const path = `/projects/${projectKey}/repos/${repoSlug}/pull-requests?${qs}`;
361
+ const path = `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}/pull-requests?${qs}`;
360
362
  const data = await this.request('GET', path);
361
363
  if (!data || data.values.length === 0)
362
364
  return text(`No ${state} pull requests found.`);
@@ -365,21 +367,25 @@ export class BitbucketClient {
365
367
  }
366
368
  async myPrs(args) {
367
369
  const { limit = 25, start = 0, role } = args;
368
- const qs = new URLSearchParams({ limit: String(limit), start: String(start) });
370
+ const me = await this.getCurrentUser();
371
+ const userSlug = me.slug ?? me.name;
372
+ if (!userSlug)
373
+ throw new Error('Could not determine your Bitbucket user slug. Check token permissions.');
374
+ const qs = new URLSearchParams({ limit: String(limit), start: String(start), state: 'OPEN' });
369
375
  if (role)
370
- qs.set('role', role);
371
- const data = await this.request('GET', `/inbox/pull-requests?${qs}`);
376
+ qs.set('role', role.toUpperCase());
377
+ const data = await this.request('GET', `/users/${encodeURIComponent(userSlug)}/pull-requests?${qs}`);
372
378
  if (!data || data.values.length === 0)
373
- return text('No pull requests in your inbox.');
379
+ return text('No pull requests found.');
374
380
  const lines = data.values.map((pr) => {
375
381
  const repo = `${pr.toRef.repository.project.key}/${pr.toRef.repository.slug}`;
376
382
  return `#${pr.id} [${pr.state}] ${pr.title} | ${repo} | ${pr.fromRef.displayId} → ${pr.toRef.displayId}`;
377
383
  });
378
- return text(`${data.values.length} PR(s) in your inbox${pageHint(data)}:\n${lines.join('\n')}`);
384
+ return text(`${data.values.length} PR(s)${pageHint(data)}:\n${lines.join('\n')}`);
379
385
  }
380
386
  async getPullRequest(args) {
381
387
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
382
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
388
+ const data = await this.request('GET', `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}/pull-requests/${args.prId}`);
383
389
  if (!data)
384
390
  return text('Pull request not found.');
385
391
  const reviewers = data.reviewers
@@ -419,7 +425,7 @@ export class BitbucketClient {
419
425
  prId = found.id;
420
426
  }
421
427
  const [pr, me] = await Promise.all([
422
- this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}`),
428
+ this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}`),
423
429
  this.getCurrentUser().catch(() => null),
424
430
  ]);
425
431
  if (!pr)
@@ -470,7 +476,7 @@ export class BitbucketClient {
470
476
  if (includeCommits) {
471
477
  const commitsLimit = args.commitsLimit ?? 25;
472
478
  const commitsStart = args.commitsStart ?? 0;
473
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/commits?limit=${commitsLimit}&start=${commitsStart}`);
479
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/commits?limit=${commitsLimit}&start=${commitsStart}`);
474
480
  if (!data || data.values.length === 0) {
475
481
  sections.push('Commits:\n(no commits found)');
476
482
  }
@@ -489,7 +495,7 @@ export class BitbucketClient {
489
495
  }
490
496
  if (commentsSeverity === 'BLOCKER') {
491
497
  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}`);
498
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/blocker-comments?${qs}`);
493
499
  if (!data || data.values.length === 0) {
494
500
  sections.push(`Comments:\n(no ${commentsState} BLOCKER comments)`);
495
501
  }
@@ -499,7 +505,7 @@ export class BitbucketClient {
499
505
  }
500
506
  }
501
507
  else {
502
- const activityData = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/activities?limit=${commentsLimit}&start=${commentsStart}`);
508
+ const activityData = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/activities?limit=${commentsLimit}&start=${commentsStart}`);
503
509
  const comments = uniqueCommentsFromActivities(activityData?.values ?? []).filter((comment) => {
504
510
  const matchesState = commentMatchesState(comment, commentsState);
505
511
  return matchesState && commentMatchesSeverity(comment, commentsSeverity);
@@ -515,14 +521,14 @@ export class BitbucketClient {
515
521
  }
516
522
  }
517
523
  if (includeDiff) {
518
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${prId}/diff`);
524
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/diff`);
519
525
  sections.push(`Diff:\n${data ? formatDiff(data, args.diffMaxChars ?? 8000) : '(no diff found)'}`);
520
526
  }
521
527
  return text(sections.join('\n\n'));
522
528
  }
523
529
  async getPrDiff(args) {
524
530
  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`);
531
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/diff`);
526
532
  if (!data)
527
533
  return text('No diff found.');
528
534
  return text(formatDiff(data));
@@ -531,7 +537,7 @@ export class BitbucketClient {
531
537
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
532
538
  const limit = args.limit ?? 25;
533
539
  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}`);
540
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/commits?limit=${limit}&start=${start}`);
535
541
  if (!data || data.values.length === 0)
536
542
  return text('No commits found.');
537
543
  const lines = data.values.map((c) => `${c.displayId} ${formatDate(c.authorTimestamp)} ${c.author.name}: ${c.message.split('\n')[0]}`);
@@ -549,15 +555,18 @@ export class BitbucketClient {
549
555
  const url = this.pullRequestUrl(projectKey, repoSlug, existing.id, existing);
550
556
  return text(`Open PR already exists for branch "${sourceBranchName}": #${existing.id} "${existing.title}"\n${url}`);
551
557
  }
552
- const { title, description, toBranch = 'master', reviewers = [] } = args;
558
+ const { title, description, reviewers = [] } = args;
559
+ const toRef = args.toBranch
560
+ ? toBranchRef(args.toBranch)
561
+ : await this.getDefaultBranchRef(projectKey, repoSlug);
553
562
  const body = {
554
563
  title,
555
564
  description: description ?? '',
556
565
  fromRef: { id: toBranchRef(sourceBranch), repository: { slug: repoSlug, project: { key: projectKey } } },
557
- toRef: { id: toBranchRef(toBranch), repository: { slug: repoSlug, project: { key: projectKey } } },
566
+ toRef: { id: toRef, repository: { slug: repoSlug, project: { key: projectKey } } },
558
567
  reviewers: reviewers.map((name) => ({ user: { name } })),
559
568
  };
560
- const data = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/pull-requests`, body);
569
+ const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests`, body);
561
570
  if (!data)
562
571
  return text('Pull request created.');
563
572
  const url = this.pullRequestUrl(projectKey, repoSlug, data.id, data);
@@ -571,7 +580,7 @@ export class BitbucketClient {
571
580
  && args.reviewers === undefined) {
572
581
  throw new Error('At least one field is required: title, description, toBranch, or reviewers');
573
582
  }
574
- const existing = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
583
+ const existing = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
575
584
  if (!existing)
576
585
  throw new Error(`PR #${args.prId} not found.`);
577
586
  const buildBody = (pr) => {
@@ -595,16 +604,16 @@ export class BitbucketClient {
595
604
  };
596
605
  let updated;
597
606
  try {
598
- updated = await this.request('PUT', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`, buildBody(existing));
607
+ updated = await this.request('PUT', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`, buildBody(existing));
599
608
  }
600
609
  catch (error) {
601
610
  const message = error instanceof Error ? error.message : String(error);
602
611
  if (!message.includes('Bitbucket 409'))
603
612
  throw error;
604
- const latest = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
613
+ const latest = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
605
614
  if (!latest)
606
615
  throw error;
607
- updated = await this.request('PUT', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`, buildBody(latest));
616
+ updated = await this.request('PUT', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`, buildBody(latest));
608
617
  }
609
618
  if (!updated)
610
619
  return text(`Updated PR #${args.prId}.`);
@@ -670,7 +679,7 @@ export class BitbucketClient {
670
679
  }
671
680
  async approvePr(args) {
672
681
  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`);
682
+ const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/approve`);
674
683
  const url = this.pullRequestUrl(projectKey, repoSlug, args.prId);
675
684
  if (!data)
676
685
  return text(`Approved PR #${args.prId}.\n${url}`);
@@ -678,25 +687,25 @@ export class BitbucketClient {
678
687
  }
679
688
  async unapprovePr(args) {
680
689
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
681
- await this.request('DELETE', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/approve`);
690
+ await this.request('DELETE', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/approve`);
682
691
  return text(`Approval removed from PR #${args.prId}.\n${this.pullRequestUrl(projectKey, repoSlug, args.prId)}`);
683
692
  }
684
693
  async declinePr(args) {
685
694
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
686
- const pr = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
695
+ const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
687
696
  if (!pr)
688
697
  throw new Error(`PR #${args.prId} not found.`);
689
698
  const body = { version: pr.version };
690
699
  if (args.message)
691
700
  body.message = args.message;
692
- const data = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/decline`, body);
701
+ const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/decline`, body);
693
702
  if (!data)
694
703
  return text(`Declined PR #${args.prId}.\n${this.pullRequestUrl(projectKey, repoSlug, args.prId)}`);
695
704
  return text(`Declined PR #${data.id}: "${data.title}".\n${this.pullRequestUrl(projectKey, repoSlug, data.id, data)}`);
696
705
  }
697
706
  async mergePr(args) {
698
707
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
699
- const pr = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
708
+ const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
700
709
  if (!pr)
701
710
  throw new Error(`PR #${args.prId} not found.`);
702
711
  const body = { version: pr.version };
@@ -704,13 +713,13 @@ export class BitbucketClient {
704
713
  body.strategyId = args.mergeStrategy;
705
714
  if (args.message)
706
715
  body.message = args.message;
707
- const data = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/merge`, body);
716
+ const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/merge`, body);
708
717
  if (!data)
709
718
  return text(`Merged PR #${args.prId}.\n${this.pullRequestUrl(projectKey, repoSlug, args.prId)}`);
710
719
  return text(`Merged PR #${data.id}: "${data.title}" (${data.fromRef.displayId} → ${data.toRef.displayId}).\n${this.pullRequestUrl(projectKey, repoSlug, data.id, data)}`);
711
720
  }
712
721
  async getDefaultBranchRef(projectKey, repoSlug) {
713
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/default-branch`);
722
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/default-branch`);
714
723
  if (data?.displayId)
715
724
  return `refs/heads/${data.displayId}`;
716
725
  // Fallback: detect from local git
@@ -722,7 +731,7 @@ export class BitbucketClient {
722
731
  async createBranch(args) {
723
732
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
724
733
  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 });
734
+ const data = await this.request('POST', `${this.rp(projectKey, repoSlug)}/branches`, { name: args.branchName, startPoint });
726
735
  if (!data)
727
736
  return text(`Branch "${args.branchName}" created.`);
728
737
  return text(`Created branch "${data.displayId}" at ${data.latestCommit.slice(0, 8)} in ${projectKey}/${repoSlug}.`);
@@ -732,7 +741,7 @@ export class BitbucketClient {
732
741
  const qs = new URLSearchParams({ limit: String(args.limit ?? 25), start: String(args.start ?? 0) });
733
742
  if (args.filter)
734
743
  qs.set('filterText', args.filter);
735
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/branches?${qs}`);
744
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/branches?${qs}`);
736
745
  if (!data || data.values.length === 0)
737
746
  return text('No branches found.');
738
747
  const lines = data.values.map((b) => `${b.displayId}${b.isDefault ? ' (default)' : ''} — ${b.latestCommit.slice(0, 8)}`);
@@ -742,7 +751,7 @@ export class BitbucketClient {
742
751
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
743
752
  const qs = args.ref ? `?at=${encodeURIComponent(args.ref)}` : '';
744
753
  const encodedPath = args.path.split('/').map(encodeURIComponent).join('/');
745
- const content = await this.requestText(`/projects/${projectKey}/repos/${repoSlug}/raw/${encodedPath}${qs}`);
754
+ const content = await this.requestText(`${this.rp(projectKey, repoSlug)}/raw/${encodedPath}${qs}`);
746
755
  const MAX_CHARS = 10000;
747
756
  if (content.length > MAX_CHARS) {
748
757
  return text(content.slice(0, MAX_CHARS) + `\n\n... (truncated, ${content.length - MAX_CHARS} more chars)`);
@@ -765,7 +774,7 @@ export class BitbucketClient {
765
774
  const qs = new URLSearchParams({ count: 'true' });
766
775
  if (state)
767
776
  qs.set('state', state);
768
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/blocker-comments?${qs}`);
777
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments?${qs}`);
769
778
  let open = data?.open ?? 0;
770
779
  let resolved = data?.resolved ?? 0;
771
780
  if ((open === 0 && resolved === 0) && data?.values && data.values.length > 0) {
@@ -789,7 +798,7 @@ export class BitbucketClient {
789
798
  const qs = new URLSearchParams({ limit: String(limit), start: String(start) });
790
799
  if (state)
791
800
  qs.set('state', state);
792
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/blocker-comments?${qs}`);
801
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments?${qs}`);
793
802
  if (!data || data.values.length === 0) {
794
803
  return text(`No ${state ?? 'OPEN/RESOLVED'} BLOCKER comments on PR #${args.prId}.`);
795
804
  }
@@ -804,7 +813,7 @@ export class BitbucketClient {
804
813
  });
805
814
  if (state)
806
815
  qs.set('state', state);
807
- const data = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments?${qs}`);
816
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments?${qs}`);
808
817
  const filtered = (data?.values ?? []).filter((comment) => {
809
818
  const matchesState = state ? commentMatchesState(comment, state) : true;
810
819
  return matchesState && commentMatchesSeverity(comment, severity);
@@ -816,7 +825,7 @@ export class BitbucketClient {
816
825
  const paging = data ? pageHint(data) : '';
817
826
  return text(`${filtered.length} comment thread(s) on PR #${args.prId} for ${args.path}${paging}:\n\n${blocks.join('\n\n')}`);
818
827
  }
819
- const activityData = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/activities?limit=${limit}&start=${start}`);
828
+ const activityData = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/activities?limit=${limit}&start=${start}`);
820
829
  const comments = uniqueCommentsFromActivities(activityData?.values ?? []).filter((comment) => {
821
830
  const matchesState = state ? commentMatchesState(comment, state) : true;
822
831
  return matchesState && commentMatchesSeverity(comment, severity);
@@ -847,6 +856,8 @@ export class BitbucketClient {
847
856
  commentText = args.text ? `${args.text}\n\n${suggestionBlock}` : suggestionBlock;
848
857
  }
849
858
  const body = { text: validateCommentText(commentText) };
859
+ if (args.severity)
860
+ body.severity = args.severity;
850
861
  if (replyToCommentId !== undefined)
851
862
  body.parent = { id: replyToCommentId };
852
863
  let inlineAnchor;
@@ -854,7 +865,7 @@ export class BitbucketClient {
854
865
  if (args.filePath === undefined || args.line === undefined) {
855
866
  throw new Error('filePath and line must be provided together for inline comments.');
856
867
  }
857
- const pr = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}`);
868
+ const pr = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}`);
858
869
  inlineAnchor = {
859
870
  diffType: 'EFFECTIVE',
860
871
  fileType: args.fileType ?? 'TO',
@@ -879,7 +890,7 @@ export class BitbucketClient {
879
890
  }
880
891
  let created;
881
892
  try {
882
- created = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments`, body);
893
+ created = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments`, body);
883
894
  }
884
895
  catch (error) {
885
896
  const message = error instanceof Error ? error.message : String(error);
@@ -888,7 +899,7 @@ export class BitbucketClient {
888
899
  }
889
900
  const { fromHash: _fromHash, toHash: _toHash, ...anchorWithoutHashes } = inlineAnchor;
890
901
  body.anchor = anchorWithoutHashes;
891
- created = await this.request('POST', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments`, body);
902
+ created = await this.request('POST', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments`, body);
892
903
  }
893
904
  if (!created)
894
905
  return text(`Comment added to PR #${args.prId}.`);
@@ -903,7 +914,7 @@ export class BitbucketClient {
903
914
  if (!args.text && !args.state && !args.severity && args.threadResolved === undefined) {
904
915
  throw new Error('At least one field is required: text, state, severity, or threadResolved');
905
916
  }
906
- const current = await this.request('GET', `/projects/${projectKey}/repos/${repoSlug}/pull-requests/${args.prId}/comments/${args.commentId}`);
917
+ const current = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`);
907
918
  if (!current)
908
919
  throw new Error(`Comment #${args.commentId} not found.`);
909
920
  const currentSeverity = current.severity ?? 'NORMAL';
@@ -922,8 +933,8 @@ export class BitbucketClient {
922
933
  throw new Error('threadResolved is only supported for normal comments. Use state for BLOCKER comment tasks.');
923
934
  }
924
935
  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}`;
936
+ ? `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments/${args.commentId}`
937
+ : `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`;
927
938
  const buildBody = (version) => {
928
939
  const body = { version };
929
940
  if (args.text !== undefined)
@@ -960,20 +971,20 @@ export class BitbucketClient {
960
971
  }
961
972
  async deletePrComment(args) {
962
973
  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}`);
974
+ const current = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`);
964
975
  if (!current)
965
976
  throw new Error(`Comment #${args.commentId} not found.`);
966
977
  await this.assertOwnComment(current);
967
978
  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}`;
979
+ ? `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/blocker-comments/${args.commentId}`
980
+ : `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/comments/${args.commentId}`;
970
981
  const path = `${commentPath}?version=${current.version}`;
971
982
  await this.request('DELETE', path);
972
983
  return text(`Comment #${args.commentId} deleted from PR #${args.prId}.`);
973
984
  }
974
985
  async getPrTasks(args) {
975
986
  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`);
987
+ const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${args.prId}/tasks`);
977
988
  if (!data || data.values.length === 0)
978
989
  return text(`No tasks on PR #${args.prId}.`);
979
990
  const lines = data.values.map((t) => {
@@ -1011,15 +1022,22 @@ export class BitbucketClient {
1011
1022
  const task = await this.request('GET', `/tasks/${args.taskId}`);
1012
1023
  if (!task)
1013
1024
  throw new Error(`Task #${args.taskId} not found.`);
1014
- await this.request('DELETE', `/tasks/${args.taskId}?version=${task.id}`);
1025
+ // Verify the task belongs to the given PR (when anchor is a direct PR anchor)
1026
+ if (args.prId !== undefined && task.anchor?.type === 'PULL_REQUEST' && task.anchor.id !== args.prId) {
1027
+ throw new Error(`Task #${args.taskId} does not belong to PR #${args.prId}.`);
1028
+ }
1029
+ await this.request('DELETE', `/tasks/${args.taskId}?version=${task.version}`);
1015
1030
  return text(`Task #${args.taskId} deleted.`);
1016
1031
  }
1017
1032
  // resolve or reopen
1018
1033
  const task = await this.request('GET', `/tasks/${args.taskId}`);
1019
1034
  if (!task)
1020
1035
  throw new Error(`Task #${args.taskId} not found.`);
1036
+ if (args.prId !== undefined && task.anchor?.type === 'PULL_REQUEST' && task.anchor.id !== args.prId) {
1037
+ throw new Error(`Task #${args.taskId} does not belong to PR #${args.prId}.`);
1038
+ }
1021
1039
  const newState = args.action === 'resolve' ? 'RESOLVED' : 'OPEN';
1022
- const updated = await this.request('PUT', `/tasks/${args.taskId}`, {
1040
+ const updated = await this.request('PUT', `/tasks/${args.taskId}?version=${task.version}`, {
1023
1041
  id: task.id,
1024
1042
  state: newState,
1025
1043
  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.7",
4
4
  "description": "MCP server for self-hosted Jira and Bitbucket",
5
5
  "license": "MIT",
6
6
  "type": "module",