@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 +12 -6
- package/dist/context.js +53 -9
- package/dist/index.js +61 -3
- package/dist/jira.js +3 -0
- package/package.json +1 -1
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) => (
|
|
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 ?? '
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
'
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
'
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
|
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;
|