@stubbedev/atlassian-mcp 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bitbucket.js CHANGED
@@ -84,6 +84,7 @@ function commentMatchesState(comment, state) {
84
84
  const threadState = comment.threadResolved ? 'RESOLVED' : 'OPEN';
85
85
  if (threadState === state)
86
86
  return true;
87
+ return (comment.comments ?? []).some((child) => commentMatchesState(child, state));
87
88
  }
88
89
  const currentState = comment.state ?? 'OPEN';
89
90
  if (currentState === state)
@@ -125,7 +126,7 @@ function uniqueCommentsFromActivities(activities) {
125
126
  }
126
127
  return Array.from(byId.values())
127
128
  .filter((comment) => !comment.deleted)
128
- .sort((a, b) => (a.createdDate ?? 0) - (b.createdDate ?? 0));
129
+ .sort((a, b) => (b.createdDate ?? 0) - (a.createdDate ?? 0));
129
130
  }
130
131
  function pageHint(data) {
131
132
  return data.isLastPage ? '' : ` (use start=${data.nextPageStart} for next page)`;
@@ -237,6 +238,9 @@ export class BitbucketClient {
237
238
  this.currentUsernameCache = username;
238
239
  return username;
239
240
  }
241
+ async whoami() {
242
+ return this.getCurrentUsername();
243
+ }
240
244
  /** Returns a URL-safe `/projects/.../repos/...` prefix for REST paths. */
241
245
  rp(projectKey, repoSlug) {
242
246
  return `/projects/${encodeURIComponent(projectKey)}/repos/${encodeURIComponent(repoSlug)}`;
@@ -532,13 +536,15 @@ export class BitbucketClient {
532
536
  if (includeComments) {
533
537
  const commentsLimit = args.commentsLimit ?? 50;
534
538
  const commentsStart = args.commentsStart ?? 0;
535
- const commentsState = args.commentsState ?? 'OPEN';
539
+ const commentsState = args.commentsState ?? 'ALL';
536
540
  const commentsSeverity = args.commentsSeverity ?? 'ALL';
537
541
  if (commentsSeverity === 'BLOCKER' && commentsState === 'PENDING') {
538
- throw new Error('commentsState=PENDING is not valid when commentsSeverity=BLOCKER. Use OPEN or RESOLVED.');
542
+ throw new Error('commentsState=PENDING is not valid when commentsSeverity=BLOCKER. Use OPEN, RESOLVED, or ALL.');
539
543
  }
540
544
  if (commentsSeverity === 'BLOCKER') {
541
- const qs = new URLSearchParams({ limit: String(commentsLimit), start: String(commentsStart), state: commentsState });
545
+ const qs = new URLSearchParams({ limit: String(commentsLimit), start: String(commentsStart) });
546
+ if (commentsState !== 'ALL')
547
+ qs.set('state', commentsState);
542
548
  const data = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/blocker-comments?${qs}`);
543
549
  if (!data || data.values.length === 0) {
544
550
  sections.push(`Comments:\n(no ${commentsState} BLOCKER comments)`);
@@ -553,7 +559,7 @@ export class BitbucketClient {
553
559
  else {
554
560
  const activityData = await this.request('GET', `${this.rp(projectKey, repoSlug)}/pull-requests/${prId}/activities?limit=${commentsLimit}&start=${commentsStart}`);
555
561
  const comments = uniqueCommentsFromActivities(activityData?.values ?? []).filter((comment) => {
556
- const matchesState = commentMatchesState(comment, commentsState);
562
+ const matchesState = commentsState === 'ALL' ? true : commentMatchesState(comment, commentsState);
557
563
  return matchesState && commentMatchesSeverity(comment, commentsSeverity);
558
564
  });
559
565
  for (const comment of comments)
@@ -564,7 +570,7 @@ export class BitbucketClient {
564
570
  else {
565
571
  const blocks = comments.flatMap((comment) => formatCommentThread(comment));
566
572
  const paging = activityData ? pageHint(activityData) : '';
567
- sections.push(`Comments (${comments.length} thread(s))${paging}:\n\n${blocks.join('\n\n')}`);
573
+ sections.push(`Comments (${comments.length} thread(s), newest first)${paging}:\n\n${blocks.join('\n\n')}`);
568
574
  }
569
575
  }
570
576
  }
package/dist/context.js CHANGED
@@ -9,6 +9,27 @@ function safeExec(cmd, cwd) {
9
9
  return '';
10
10
  }
11
11
  }
12
+ /** Top recent committers from the last `lookback` commits, ranked by count. */
13
+ export function getTopCommitters(repoPath, lookback = 50, top = 5) {
14
+ const raw = safeExec(`git log -n ${lookback} --format=%aN%x09%aE`, repoPath);
15
+ if (!raw)
16
+ return [];
17
+ const counts = new Map();
18
+ for (const line of raw.split('\n')) {
19
+ const [name, email] = line.split('\t');
20
+ if (!name)
21
+ continue;
22
+ const key = (email || name).toLowerCase();
23
+ const existing = counts.get(key);
24
+ if (existing)
25
+ existing.commits++;
26
+ else
27
+ counts.set(key, { name, email: email ?? '', commits: 1 });
28
+ }
29
+ return [...counts.values()]
30
+ .sort((a, b) => b.commits - a.commits)
31
+ .slice(0, top);
32
+ }
12
33
  /**
13
34
  * Unified developer context: git state + linked Jira issues + open PR for current branch.
14
35
  * Either jira or bitbucket may be null when only one product is configured.
@@ -20,6 +41,8 @@ export async function getDevContext(args, jira, bitbucket) {
20
41
  const remote = safeExec('git remote get-url origin', repoPath) || '(no remote)';
21
42
  const recentCommits = safeExec('git log --oneline -5', repoPath) || '(none)';
22
43
  const status = safeExec('git status --short', repoPath) || '(clean)';
44
+ const committers = getTopCommitters(repoPath, 50, 5);
45
+ const parsed = bitbucket?.isRemoteForThisInstance(remote) ? parseBitbucketRemote(remote) : null;
23
46
  // Upstream ahead/behind
24
47
  const upstream = safeExec('git rev-parse --abbrev-ref @{u}', repoPath);
25
48
  let upstreamLine = '';
@@ -35,18 +58,40 @@ export async function getDevContext(args, jira, bitbucket) {
35
58
  upstreamLine = `${upstream}${parts.length ? ` (${parts.join(', ')})` : ' (up to date)'}`;
36
59
  }
37
60
  }
38
- sections.push([
61
+ // Identity — best-effort, parallel
62
+ const [jiraMe, bbMe] = await Promise.all([
63
+ jira ? jira.whoami().catch(() => null) : Promise.resolve(null),
64
+ bitbucket ? bitbucket.whoami().catch(() => null) : Promise.resolve(null),
65
+ ]);
66
+ const youParts = [];
67
+ if (jiraMe)
68
+ youParts.push(`Jira ${jiraMe.name ?? jiraMe.key ?? '(unknown)'}${jiraMe.displayName ? ` "${jiraMe.displayName}"` : ''}`);
69
+ if (bbMe)
70
+ youParts.push(`Bitbucket ${bbMe}`);
71
+ const headerLines = [
39
72
  `Repository: ${repoPath}`,
40
73
  `Branch: ${branch}`,
41
74
  ...(upstreamLine ? [`Upstream: ${upstreamLine}`] : []),
42
75
  `Remote: ${remote}`,
43
- '',
44
- 'Recent commits:',
45
- recentCommits,
46
- '',
47
- 'Working tree:',
48
- status,
49
- ].join('\n'));
76
+ ...(parsed ? [`Bitbucket: ${parsed.projectKey}/${parsed.repoSlug}`] : []),
77
+ ...(youParts.length ? [`You: ${youParts.join(' · ')}`] : []),
78
+ ];
79
+ if (committers.length) {
80
+ headerLines.push('');
81
+ headerLines.push('Top recent committers (last 50):');
82
+ for (const c of committers) {
83
+ const ident = c.email ? `${c.name} <${c.email}>` : c.name;
84
+ headerLines.push(` ${c.commits.toString().padStart(3)} — ${ident}`);
85
+ }
86
+ headerLines.push(' (look up usernames via jira_search resource=users / bitbucket_search resource=users — do NOT shell out to git/gh/bb)');
87
+ }
88
+ headerLines.push('');
89
+ headerLines.push('Recent commits:');
90
+ headerLines.push(recentCommits);
91
+ headerLines.push('');
92
+ headerLines.push('Working tree:');
93
+ headerLines.push(status);
94
+ sections.push(headerLines.join('\n'));
50
95
  // Jira — fetch overview for any tickets referenced in the branch name (parallel)
51
96
  const jiraKeys = jira ? [...new Set(branch.match(JIRA_KEY_RE) ?? [])] : [];
52
97
  const jiraResults = await Promise.all(jiraKeys.map(async (key) => {
@@ -66,7 +111,6 @@ export async function getDevContext(args, jira, bitbucket) {
66
111
  }));
67
112
  sections.push(...jiraResults);
68
113
  // Bitbucket — find the open PR for this branch (only if remote points to this instance)
69
- const parsed = bitbucket?.isRemoteForThisInstance(remote) ? parseBitbucketRemote(remote) : null;
70
114
  if (parsed) {
71
115
  try {
72
116
  const pr = await bitbucket.findOpenPrForBranch(parsed.projectKey, parsed.repoSlug, branch);
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { loadConfig } from './config.js';
12
12
  import { JiraClient } from './jira.js';
13
13
  import { BitbucketClient, parseBitbucketRemote } from './bitbucket.js';
14
14
  import { getContext, getDiff, createBranch, checkRemoteBranch, checkoutRemoteBranch } from './git.js';
15
- import { getDevContext } from './context.js';
15
+ import { getDevContext, getTopCommitters } from './context.js';
16
16
  function currentGitRemote() {
17
17
  try {
18
18
  return execFileSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf-8' }).trim();
@@ -21,6 +21,14 @@ function currentGitRemote() {
21
21
  return '';
22
22
  }
23
23
  }
24
+ function currentGitBranch() {
25
+ try {
26
+ return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf-8' }).trim();
27
+ }
28
+ catch {
29
+ return '';
30
+ }
31
+ }
24
32
  function remoteMatchesBitbucketInstance(remote, bitbucketUrl) {
25
33
  if (!remote)
26
34
  return false;
@@ -39,7 +47,57 @@ const bitbucket = (config.bitbucket && remoteMatchesBitbucketInstance(_remote, c
39
47
  if (config.bitbucket && !bitbucket) {
40
48
  console.error(`[atlassian-mcp] Bitbucket configured but remote "${_remote || '(none)'}" does not match ${config.bitbucket.url} — Bitbucket tools disabled for this repo.`);
41
49
  }
42
- const server = new Server({ name: 'atlassian-mcp', version: _pkg.version }, { capabilities: { tools: {} } });
50
+ async function buildInstructions() {
51
+ const branch = currentGitBranch();
52
+ const isGitRepo = branch !== '';
53
+ const jiraKeys = isGitRepo ? [...new Set(branch.match(/\b[A-Z][A-Z0-9]+-\d+\b/g) ?? [])] : [];
54
+ const parsed = bitbucket && _remote ? parseBitbucketRemote(_remote) : null;
55
+ const committers = isGitRepo ? getTopCommitters(process.cwd(), 50, 5) : [];
56
+ const [jiraMe, bbMe] = await Promise.all([
57
+ jira ? jira.whoami().catch(() => null) : Promise.resolve(null),
58
+ bitbucket ? bitbucket.whoami().catch(() => null) : Promise.resolve(null),
59
+ ]);
60
+ const lines = [];
61
+ lines.push('# atlassian-mcp');
62
+ lines.push('');
63
+ lines.push('Self-hosted Jira + Bitbucket Server tooling. Prefer these tools over shelling out to `git log`, `gh`, or any `bitbucket`/`bb` CLI for anything that touches tickets, PRs, reviewers, comments, or user lookups.');
64
+ lines.push('');
65
+ lines.push('## Configured services');
66
+ lines.push(`- Jira: ${jira ? config.jira.url : '(not configured)'}${jiraMe ? ` — you are ${jiraMe.name ?? jiraMe.key ?? '?'}${jiraMe.displayName ? ` "${jiraMe.displayName}"` : ''}` : ''}`);
67
+ lines.push(`- Bitbucket: ${bitbucket ? config.bitbucket.url : (config.bitbucket ? `${config.bitbucket.url} — DISABLED for this cwd (remote does not match)` : '(not configured)')}${bbMe ? ` — you are ${bbMe}` : ''}`);
68
+ lines.push('');
69
+ if (isGitRepo) {
70
+ lines.push('## Current repo');
71
+ lines.push(`- Branch: ${branch} (may have changed since startup — re-run \`get_dev_context\` to refresh)`);
72
+ lines.push(`- Remote: ${_remote || '(none)'}`);
73
+ if (parsed)
74
+ lines.push(`- Bitbucket repo: ${parsed.projectKey}/${parsed.repoSlug}`);
75
+ if (jiraKeys.length)
76
+ lines.push(`- Jira keys in branch: ${jiraKeys.join(', ')}`);
77
+ }
78
+ else {
79
+ lines.push('## Current repo');
80
+ lines.push('- Not a git repository.');
81
+ }
82
+ if (committers.length) {
83
+ lines.push('');
84
+ lines.push('## Recent committers in this repo (last 50 commits)');
85
+ for (const c of committers) {
86
+ const ident = c.email ? `${c.name} <${c.email}>` : c.name;
87
+ lines.push(`- ${c.commits}× ${ident}`);
88
+ }
89
+ }
90
+ lines.push('');
91
+ lines.push('## Use these tools — do NOT shell out');
92
+ lines.push('- "What am I working on / what\'s the status / show me the context" → call `get_dev_context` first. It returns branch state, linked Jira tickets, the open PR, and reviewer status in one shot.');
93
+ lines.push('- Looking up a person\'s username (for reviewers, assignees, mentions) → ALWAYS use `bitbucket_search resource=users` or `jira_search resource=users`. NEVER use `git log`/`git shortlog`/`gh api`/`bb`/any bitbucket CLI to discover who someone is — those return commit-author strings, not Bitbucket/Jira usernames, and the wrong identifier breaks reviewer assignment.');
94
+ lines.push('- Reading a Jira ticket → `jira_get` (single) or `jira_search` (many). Mutating → `jira_mutate`.');
95
+ lines.push('- Reading a PR → `bitbucket_get_pr`. Creating/updating/merging → `bitbucket_mutate`. Commenting → `bitbucket_comment`.');
96
+ lines.push('- Starting work on a ticket (branch + status transition + README) → `start_work`. Closing it (merge + transition) → `complete_work`.');
97
+ return lines.join('\n');
98
+ }
99
+ const _instructions = await buildInstructions();
100
+ const server = new Server({ name: 'atlassian-mcp', version: _pkg.version }, { capabilities: { tools: {} }, instructions: _instructions });
43
101
  server.onerror = (error) => console.error('[MCP Error]', error);
44
102
  function normalizeBitbucketArgs(args) {
45
103
  const src = (args && typeof args === 'object') ? args : {};
@@ -314,7 +372,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
314
372
  includeComments: { type: 'boolean', description: 'Include review comments and blockers (default true)', default: true },
315
373
  includeDiff: { type: 'boolean', description: 'Include diff text (default false)', default: false },
316
374
  includeBuildStatus: { type: 'boolean', description: 'Include CI/build status for the head commit (default true)', default: true },
317
- commentsState: { type: 'string', enum: ['OPEN', 'RESOLVED', 'PENDING'], description: 'Comment state filter (default OPEN)', default: 'OPEN' },
375
+ commentsState: { type: 'string', enum: ['ALL', 'OPEN', 'RESOLVED', 'PENDING'], description: 'Comment state filter (default ALL — returns every comment with its state badge so nothing is silently hidden). Pass OPEN/RESOLVED only when explicitly narrowing.', default: 'ALL' },
318
376
  commentsSeverity: { type: 'string', enum: ['ALL', 'NORMAL', 'BLOCKER'], description: 'Comment severity filter (default ALL)', default: 'ALL' },
319
377
  commentsLimit: { type: 'number', description: 'Max comments (default 50)', default: 50 },
320
378
  commentsStart: { type: 'number', description: 'Comment pagination offset (default 0)', default: 0 },
package/dist/jira.js CHANGED
@@ -149,6 +149,9 @@ export class JiraClient {
149
149
  this.currentUserCache = me;
150
150
  return me;
151
151
  }
152
+ async whoami() {
153
+ return this.getCurrentUser();
154
+ }
152
155
  async getIssueLinkingEnabled() {
153
156
  if (this.issueLinkingEnabled !== undefined)
154
157
  return this.issueLinkingEnabled;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stubbedev/atlassian-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "MCP server for self-hosted Jira and Bitbucket",
5
5
  "license": "MIT",
6
6
  "type": "module",