@stubbedev/atlassian-mcp 0.2.10 → 0.2.12

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
@@ -340,6 +340,55 @@ export class BitbucketClient {
340
340
  const lines = data.values.map((r, i) => `${start + i + 1}. ${r.project.key}/${r.slug} — ${r.name}`);
341
341
  return text(`${data.values.length} repo(s)${pageHint(data)}:\n${lines.join('\n')}`);
342
342
  }
343
+ async searchUsers(args) {
344
+ const params = new URLSearchParams();
345
+ if (args.query)
346
+ params.set('filter', args.query);
347
+ params.set('limit', String(args.limit ?? 25));
348
+ if (args.start)
349
+ params.set('start', String(args.start));
350
+ let path;
351
+ if (args.projectKey && args.repoSlug) {
352
+ path = `${this.rp(args.projectKey, args.repoSlug)}/permissions/users?${params}`;
353
+ }
354
+ else if (args.projectKey) {
355
+ path = `/projects/${encodeURIComponent(args.projectKey)}/permissions/users?${params}`;
356
+ }
357
+ else {
358
+ path = `/users?${params}`;
359
+ }
360
+ const data = await this.request('GET', path);
361
+ if (!data || data.values.length === 0)
362
+ return text('No users found.');
363
+ const lines = data.values.map((entry, i) => {
364
+ const user = entry.user ?? entry;
365
+ const parts = [`${i + 1}. ${user.displayName} (${user.name})`];
366
+ if (user.emailAddress)
367
+ parts.push(`— ${user.emailAddress}`);
368
+ if (user.active === false)
369
+ parts.push('[inactive]');
370
+ return parts.join(' ');
371
+ });
372
+ return text(`${data.values.length} user(s)${pageHint(data)}:\n${lines.join('\n')}`);
373
+ }
374
+ async searchUsersRaw(args) {
375
+ const params = new URLSearchParams();
376
+ if (args.query)
377
+ params.set('filter', args.query);
378
+ params.set('limit', String(args.limit ?? 50));
379
+ let path;
380
+ if (args.projectKey && args.repoSlug) {
381
+ path = `${this.rp(args.projectKey, args.repoSlug)}/permissions/users?${params}`;
382
+ }
383
+ else if (args.projectKey) {
384
+ path = `/projects/${encodeURIComponent(args.projectKey)}/permissions/users?${params}`;
385
+ }
386
+ else {
387
+ path = `/users?${params}`;
388
+ }
389
+ const data = await this.request('GET', path);
390
+ return (data?.values ?? []).map((entry) => entry.user ?? entry);
391
+ }
343
392
  async listPullRequests(args) {
344
393
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
345
394
  const { state = 'OPEN', fromBranch, text: searchText, limit = 25, start = 0 } = args;
@@ -730,6 +779,16 @@ export class BitbucketClient {
730
779
  }
731
780
  return text(content);
732
781
  }
782
+ async fetchFileText(projectKey, repoSlug, filePath) {
783
+ try {
784
+ const encoded = filePath.split('/').map(encodeURIComponent).join('/');
785
+ const content = await this.requestText(`${this.rp(projectKey, repoSlug)}/raw/${encoded}`);
786
+ return content;
787
+ }
788
+ catch {
789
+ return null;
790
+ }
791
+ }
733
792
  async getPrComments(args) {
734
793
  const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
735
794
  const limit = args.limit ?? 50;
@@ -972,7 +1031,7 @@ export class BitbucketClient {
972
1031
  return text(`${data.values.length} task(s) on PR #${args.prId} (${open} open)${pageHint(data)}:\n${lines.join('\n')}`);
973
1032
  }
974
1033
  async mutatePrTask(args) {
975
- const { projectKey, repoSlug } = this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
1034
+ this.resolveProjectAndRepo(args.projectKey, args.repoSlug);
976
1035
  if (args.action === 'create') {
977
1036
  if (!args.text)
978
1037
  throw new Error('text is required to create a task.');
package/dist/index.js CHANGED
@@ -132,18 +132,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
132
132
  ...(jira ? [
133
133
  {
134
134
  name: 'start_work',
135
- description: 'Start working on a Jira ticket: fetches the ticket, creates a local git branch with an auto-generated name (e.g. feature/FOO-123-add-payment-gateway), and optionally transitions the ticket. Use when told "make a branch for FOO-123", "start working on this ticket", "check out a branch for this issue", or "begin work on FOO-123".',
135
+ description: 'Start working on a Jira ticket end-to-end: resolves the ticket (by key or free-text search with a picker when multiple match), creates a local branch with an auto-generated name, fetches the project README from Bitbucket so you have commit/PR conventions in context, and prints a ready-to-use next-steps summary. Use when told "make a branch for FOO-123", "start working on this ticket", "I want to work on the login bug", or "begin work on the payment gateway story". If issueKey is omitted, provide query for free-text search.',
136
136
  inputSchema: {
137
137
  type: 'object',
138
138
  properties: {
139
- issueKey: { type: 'string', description: 'Jira issue key, e.g. FOO-123' },
139
+ issueKey: { type: 'string', description: 'Jira issue key, e.g. FOO-123 (provide this OR query)' },
140
+ query: { type: 'string', description: 'Free-text search when issueKey is unknown — shows a picker if multiple tickets match' },
140
141
  repoPath: { type: 'string', description: 'Local repo path (defaults to cwd)' },
141
142
  baseBranch: { type: 'string', description: 'Branch to base off (default: master)' },
142
143
  branchName: { type: 'string', description: 'Override the generated branch name' },
143
144
  transitionName: { type: 'string', description: 'Jira transition to apply, e.g. "In Progress" (optional)' },
144
145
  push: { type: 'boolean', description: 'Push branch to remote after creation (default false)', default: false },
145
146
  },
146
- required: ['issueKey'],
147
147
  },
148
148
  },
149
149
  {
@@ -263,17 +263,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
263
263
  ] : []),
264
264
  ...(bitbucket ? [{
265
265
  name: 'bitbucket_search',
266
- description: 'Discover Bitbucket resources. Use when asked "what PRs are open?", "show me the repos", "find the PR for this branch", or "list branches". Set resource:\n• "pull_requests" (default) — list PRs by state/branch/text; mine=true for your inbox\n• "repos" — list repositories in a project\n• "branches" — list or filter branches in a repo',
266
+ description: 'Discover Bitbucket resources. Use when asked "what PRs are open?", "show me the repos", "find the PR for this branch", or "list branches". Set resource:\n• "pull_requests" (default) — list PRs by state/branch/text; mine=true for your inbox\n• "repos" — list repositories in a project\n• "branches" — list or filter branches in a repo\n• "users" — find users by name/email (pass query); add projectKey+repoSlug to restrict to users with repo access. ALWAYS use this to look up valid usernames before adding reviewers to a PR.',
267
267
  inputSchema: {
268
268
  type: 'object',
269
269
  properties: {
270
- resource: { type: 'string', enum: ['pull_requests', 'repos', 'branches'], description: 'What to search (default: pull_requests)' },
270
+ resource: { type: 'string', enum: ['pull_requests', 'repos', 'branches', 'users'], description: 'What to search (default: pull_requests)' },
271
271
  mine: { type: 'boolean', description: 'Return your own PRs by role (resource=pull_requests only)' },
272
272
  role: { type: 'string', enum: ['author', 'reviewer', 'participant'], description: 'Your role filter when mine=true' },
273
273
  projectKey: { type: 'string', description: 'Bitbucket project code, e.g. "ENG"' },
274
274
  project: { type: 'string', description: 'Alias for projectKey' },
275
275
  repoSlug: { type: 'string', description: 'Repository slug' },
276
276
  repo: { type: 'string', description: 'Alias for repoSlug' },
277
+ query: { type: 'string', description: 'Name or email filter (resource=users only)' },
277
278
  state: { type: 'string', enum: ['OPEN', 'MERGED', 'DECLINED'], description: 'PR state filter (default OPEN)' },
278
279
  fromBranch: { type: 'string', description: 'Filter PRs from this source branch' },
279
280
  text: { type: 'string', description: 'Filter PRs by title/description text' },
@@ -285,7 +286,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
285
286
  },
286
287
  {
287
288
  name: 'bitbucket_get_pr',
288
- description: 'Full details for one PR: metadata, commits, open comments, blockers, and optional diff. Use when asked to "review this PR", "show me the review comments", "what\'s blocking the merge", or after get_dev_context surfaces a prId. For full file context during review, follow up with bitbucket_get_file. The response includes a "Viewing as" line — if it says "you are the author", do NOT add review comments or a summary unless explicitly asked; just answer questions about the PR. If it says "you are a reviewer", default to posting inline comments for suggested changes and a final summary comment.',
289
+ description: 'Full details for one PR: metadata, commits, open comments, blockers, and optional diff. Use when asked to "review this PR", "show me the review comments", "what\'s blocking the merge", or after get_dev_context surfaces a prId. IMPORTANT: The PR branch is often not the locally checked-out branch. Do NOT read files with local tools (Read, git_get_diff, etc.) for PR context — use bitbucket_get_file with the PR\'s source branch instead. The response includes a "Viewing as" line — if it says "you are the author", do NOT add review comments or a summary unless explicitly asked; just answer questions about the PR. If it says "you are a reviewer", default to posting inline comments for suggested changes and a final summary comment.',
289
290
  inputSchema: {
290
291
  type: 'object',
291
292
  properties: {
@@ -330,7 +331,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
330
331
  description: { type: 'string', description: 'PR description (optional)' },
331
332
  fromBranch: { type: 'string', description: 'Source branch (defaults to current branch)' },
332
333
  toBranch: { type: 'string', description: 'Target branch (default: master)' },
333
- reviewers: { type: 'array', items: { type: 'string' }, description: 'Reviewer usernames (optional)' },
334
+ reviewers: { type: 'array', items: { type: 'string' }, description: 'Reviewer usernames. Use bitbucket_search resource=users to look up valid usernames before setting this.' },
335
+ pickReviewers: { type: 'boolean', description: 'Show an interactive reviewer picker before creating the PR (lists users with repo access)' },
334
336
  },
335
337
  required: ['title'],
336
338
  },
@@ -340,7 +342,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
340
342
  title: { type: 'string', description: 'Updated PR title (optional)' },
341
343
  description: { type: 'string', description: 'Updated description, or empty string to clear (optional)' },
342
344
  toBranch: { type: 'string', description: 'Updated target branch (optional)' },
343
- reviewers: { type: 'array', items: { type: 'string' }, description: 'Updated reviewer usernames. Empty array clears reviewers.' },
345
+ reviewers: { type: 'array', items: { type: 'string' }, description: 'Updated reviewer usernames. Empty array clears reviewers. Use bitbucket_search resource=users to look up valid usernames before setting this.' },
344
346
  },
345
347
  },
346
348
  },
@@ -377,7 +379,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
377
379
  },
378
380
  {
379
381
  name: 'bitbucket_get_file',
380
- description: 'Raw file content from Bitbucket at a branch, tag, or commit. Use during PR reviews to get full-file context without relying on local checkout.',
382
+ description: 'Raw file content from Bitbucket at a branch, tag, or commit. CRITICAL: if the PR branch being reviewed is NOT the currently checked-out local branch, ALL additional file context for that review MUST come from this tool — never from local Read, git_get_diff, or any tool that reads local disk. Pass the PR source branch as ref.',
381
383
  inputSchema: {
382
384
  type: 'object',
383
385
  properties: {
@@ -461,8 +463,69 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
461
463
  if (!jira)
462
464
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
463
465
  const a = args;
464
- const fields = await jira.getIssueFields(a.issueKey);
465
- const branchName = a.branchName ?? slugifyBranchName(a.issueKey, fields.summary, fields.type);
466
+ if (!a.issueKey && !a.query) {
467
+ throw new Error('Provide issueKey (e.g. FOO-123) or query (free-text search).');
468
+ }
469
+ // Resolve issueKey from free-text query if not provided directly
470
+ let issueKey = a.issueKey;
471
+ if (!issueKey && a.query) {
472
+ const candidates = await jira.findIssues(a.query, 10);
473
+ if (candidates.length === 0) {
474
+ return { content: [{ type: 'text', text: `No Jira tickets found for: "${a.query}"` }] };
475
+ }
476
+ if (candidates.length === 1) {
477
+ issueKey = candidates[0].key;
478
+ }
479
+ else {
480
+ // Multiple matches — present a picker
481
+ const pickerMessage = [
482
+ `Found ${candidates.length} tickets matching "${a.query}". Which one do you want to work on?`,
483
+ ...candidates.map((c, i) => `${i + 1}. [${c.key}] ${c.summary} (${c.status})`),
484
+ ].join('\n');
485
+ try {
486
+ const pickerResult = await server.elicitInput({
487
+ message: pickerMessage,
488
+ requestedSchema: {
489
+ type: 'object',
490
+ properties: {
491
+ ticket: {
492
+ type: 'string',
493
+ title: 'Select a ticket',
494
+ oneOf: [
495
+ ...candidates.map((c) => ({ const: c.key, title: `[${c.key}] ${c.summary}` })),
496
+ { const: '__cancel__', title: 'Cancel' },
497
+ ],
498
+ },
499
+ },
500
+ required: ['ticket'],
501
+ },
502
+ });
503
+ if (pickerResult.action === 'cancel' ||
504
+ pickerResult.action === 'decline' ||
505
+ pickerResult.content?.ticket === '__cancel__') {
506
+ return { content: [{ type: 'text', text: 'Cancelled.' }] };
507
+ }
508
+ issueKey = pickerResult.content?.ticket;
509
+ if (!issueKey || issueKey === '__cancel__') {
510
+ return { content: [{ type: 'text', text: 'Cancelled.' }] };
511
+ }
512
+ }
513
+ catch {
514
+ // Client doesn't support elicitation — list options and ask caller to retry with issueKey
515
+ const list = candidates.map((c) => ` • ${c.key} — ${c.summary} (${c.status})`).join('\n');
516
+ return {
517
+ content: [{
518
+ type: 'text',
519
+ text: `Found ${candidates.length} tickets matching "${a.query}":\n${list}\n\nRe-run start_work with the desired issueKey.`,
520
+ }],
521
+ };
522
+ }
523
+ }
524
+ }
525
+ if (!issueKey)
526
+ throw new Error('Could not resolve issue key.');
527
+ const fields = await jira.getIssueFields(issueKey);
528
+ const branchName = a.branchName ?? slugifyBranchName(issueKey, fields.summary, fields.type);
466
529
  const repoPath = a.repoPath ?? process.cwd();
467
530
  // Check if branch already exists on remote before creating
468
531
  const remote = checkRemoteBranch(branchName, repoPath);
@@ -474,7 +537,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
474
537
  const contextLines = [authorLine, commitLine].filter(Boolean).join('\n');
475
538
  const message = [
476
539
  `Branch "${branchName}" already exists on remote.`,
477
- `Ticket: ${a.issueKey} — ${fields.summary}`,
540
+ `Ticket: ${issueKey} — ${fields.summary}`,
478
541
  contextLines,
479
542
  ].filter(Boolean).join('\n');
480
543
  try {
@@ -508,7 +571,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
508
571
  if (chosen === 'cancel') {
509
572
  return { content: [{ type: 'text', text: 'Cancelled.' }] };
510
573
  }
511
- // new_name — instruct the model to re-run with a custom name
512
574
  return {
513
575
  content: [{
514
576
  type: 'text',
@@ -516,11 +578,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
516
578
  }],
517
579
  };
518
580
  }
519
- // Fallback: unknown action
520
581
  return { content: [{ type: 'text', text: 'Cancelled.' }] };
521
582
  }
522
583
  catch {
523
- // Client doesn't support elicitation — fall back to informational text
524
584
  return {
525
585
  content: [{
526
586
  type: 'text',
@@ -542,21 +602,52 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
542
602
  push: a.push ?? false,
543
603
  });
544
604
  const lines = [
545
- `Ticket: ${a.issueKey} — ${fields.summary}`,
605
+ `Ticket: ${issueKey} — ${fields.summary}`,
546
606
  `Status: ${fields.status}`,
547
607
  branchResult.content[0].text,
548
608
  ];
549
609
  if (a.transitionName) {
550
610
  try {
551
- await jira.mutateIssue({ issueKey: a.issueKey, transitionName: a.transitionName });
611
+ await jira.mutateIssue({ issueKey, transitionName: a.transitionName });
552
612
  lines.push(`Jira: transitioned → ${a.transitionName}`);
553
613
  }
554
614
  catch (err) {
555
615
  lines.push(`Jira: could not transition — ${err.message}`);
556
616
  }
557
617
  }
558
- if (bitbucket)
559
- lines.push(``, `Next: push commits then use bitbucket_mutate to open a PR.`);
618
+ // Fetch README from Bitbucket for project conventions
619
+ if (bitbucket) {
620
+ try {
621
+ const remoteUrl = (() => {
622
+ try {
623
+ return execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: repoPath, encoding: 'utf-8' }).trim();
624
+ }
625
+ catch {
626
+ return '';
627
+ }
628
+ })();
629
+ const parsed = parseBitbucketRemote(remoteUrl);
630
+ if (parsed) {
631
+ const readme = await bitbucket.fetchFileText(parsed.projectKey, parsed.repoSlug, 'README.md');
632
+ if (readme) {
633
+ const maxLen = 4000;
634
+ const truncated = readme.length > maxLen ? readme.slice(0, maxLen) + '\n... (truncated)' : readme;
635
+ lines.push('');
636
+ lines.push('Project conventions (from README.md):');
637
+ lines.push('────────────────────────────────────');
638
+ lines.push(truncated);
639
+ lines.push('────────────────────────────────────');
640
+ lines.push('Follow the conventions above when writing commit messages and the PR description.');
641
+ }
642
+ }
643
+ }
644
+ catch { /* README fetch is best-effort */ }
645
+ lines.push('');
646
+ lines.push('Next steps:');
647
+ lines.push(' 1. Make your changes and commit following the project conventions.');
648
+ lines.push(' 2. Use bitbucket_mutate (create) to open a PR — the Jira summary and ticket key make a good title/description starting point.');
649
+ lines.push(' 3. Add reviewers: bitbucket_search resource=users to find colleagues, or pass pickReviewers=true in create to get an interactive picker.');
650
+ }
560
651
  return { content: [{ type: 'text', text: lines.join('\n') }] };
561
652
  }
562
653
  // Jira
@@ -611,6 +702,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
611
702
  return await bitbucket.listRepos(a);
612
703
  if (resource === 'branches')
613
704
  return await bitbucket.getBranches(a);
705
+ if (resource === 'users')
706
+ return await bitbucket.searchUsers({ projectKey: a.projectKey, repoSlug: a.repoSlug, query: a.query, limit: a.limit, start: a.start });
614
707
  // pull_requests (default)
615
708
  if (a.mine)
616
709
  return await bitbucket.myPrs({ limit: a.limit, start: a.start, role: a.role });
@@ -633,6 +726,42 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
633
726
  return await bitbucket.declinePr({ ...a, message: a.declineMessage });
634
727
  if (action === 'merge')
635
728
  return await bitbucket.mergePr({ ...a, message: a.mergeMessage, mergeStrategy: a.mergeStrategy });
729
+ // Handle interactive reviewer picker for PR creation
730
+ const createArgs = a.create;
731
+ if (createArgs?.pickReviewers && !a.prId) {
732
+ const projectKey = a.projectKey;
733
+ const repoSlug = a.repoSlug;
734
+ const users = await bitbucket.searchUsersRaw({ projectKey, repoSlug, limit: 30 });
735
+ if (users.length > 0) {
736
+ // Build boolean checkbox schema — one field per user
737
+ // key must be safe for JSON schema property names
738
+ const toSchemaKey = (uname) => uname.replace(/[^a-zA-Z0-9]/g, '_');
739
+ const userMap = new Map(); // schemaKey -> username
740
+ const properties = {};
741
+ for (const u of users) {
742
+ const key = toSchemaKey(u.name);
743
+ userMap.set(key, u.name);
744
+ properties[key] = { type: 'boolean', title: `${u.displayName} (${u.name})` };
745
+ }
746
+ try {
747
+ const pickerResult = await server.elicitInput({
748
+ message: 'Select reviewers to add to this PR:',
749
+ requestedSchema: { type: 'object', properties },
750
+ });
751
+ if (pickerResult.action === 'accept' && pickerResult.content) {
752
+ const selected = [];
753
+ for (const [key, username] of userMap) {
754
+ if (pickerResult.content[key] === true)
755
+ selected.push(username);
756
+ }
757
+ createArgs.reviewers = selected;
758
+ }
759
+ }
760
+ catch {
761
+ // elicitation not supported — proceed without reviewer picker
762
+ }
763
+ }
764
+ }
636
765
  return await bitbucket.mutatePullRequest(a);
637
766
  }
638
767
  case 'bitbucket_comment': {
package/dist/jira.js CHANGED
@@ -285,6 +285,22 @@ export class JiraClient {
285
285
  const page = pagination(data.total, startAt, data.issues.length);
286
286
  return text(`Found ${data.total} issues${page}:\n${lines.join('\n')}`);
287
287
  }
288
+ async findIssues(query, maxResults = 10) {
289
+ const jql = buildJQL({ query });
290
+ const params = new URLSearchParams({
291
+ jql,
292
+ maxResults: String(maxResults),
293
+ startAt: '0',
294
+ fields: 'summary,status,issuetype',
295
+ });
296
+ const data = await this.request('GET', `/search?${params}`);
297
+ return (data?.issues ?? []).map((i) => ({
298
+ key: i.key,
299
+ summary: i.fields.summary,
300
+ status: i.fields.status.name,
301
+ type: i.fields.issuetype.name,
302
+ }));
303
+ }
288
304
  async myIssues(args) {
289
305
  return this.searchIssues({
290
306
  jql: 'assignee = currentUser() ORDER BY updated DESC',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stubbedev/atlassian-mcp",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "MCP server for self-hosted Jira and Bitbucket",
5
5
  "license": "MIT",
6
6
  "type": "module",