@aaronsb/jira-cloud-mcp 0.5.4 → 0.5.6

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.
@@ -95,10 +95,20 @@ export class JiraClient {
95
95
  /** Maps a raw Jira API issue to our JiraIssueDetails shape */
96
96
  mapIssueFields(issue) {
97
97
  const fields = issue.fields ?? issue.fields;
98
+ // Extract people with accountIds for @mention support
99
+ const people = [];
100
+ if (fields?.assignee?.accountId && fields?.assignee?.displayName) {
101
+ people.push({ displayName: fields.assignee.displayName, accountId: fields.assignee.accountId, role: 'assignee' });
102
+ }
103
+ if (fields?.reporter?.accountId && fields?.reporter?.displayName) {
104
+ people.push({ displayName: fields.reporter.displayName, accountId: fields.reporter.accountId, role: 'reporter' });
105
+ }
98
106
  return {
99
107
  key: issue.key,
100
108
  summary: fields?.summary,
101
- description: issue.renderedFields?.description || '',
109
+ description: fields?.description
110
+ ? TextProcessor.adfToMarkdown(fields.description)
111
+ : '',
102
112
  issueType: fields?.issuetype?.name || '',
103
113
  priority: fields?.priority?.name || null,
104
114
  parent: fields?.parent?.key || null,
@@ -121,6 +131,7 @@ export class JiraClient {
121
131
  outward: link.outwardIssue?.key || null,
122
132
  inward: link.inwardIssue?.key || null,
123
133
  })),
134
+ people: people.length > 0 ? people : undefined,
124
135
  };
125
136
  }
126
137
  async getIssue(issueKey, includeComments = false, includeAttachments = false, customFieldMeta) {
@@ -139,7 +150,7 @@ export class JiraClient {
139
150
  const params = {
140
151
  issueIdOrKey: issueKey,
141
152
  fields,
142
- expand: includeComments ? 'renderedFields,comments' : 'renderedFields'
153
+ expand: includeComments ? 'comments' : undefined
143
154
  };
144
155
  const issue = await this.client.issues.getIssue(params);
145
156
  const issueDetails = this.mapIssueFields(issue);
@@ -171,9 +182,24 @@ export class JiraClient {
171
182
  .map(comment => ({
172
183
  id: comment.id,
173
184
  author: comment.author.displayName,
174
- body: comment.body?.content ? TextProcessor.extractTextFromAdf(comment.body) : String(comment.body),
185
+ body: comment.body?.content ? TextProcessor.adfToMarkdown(comment.body) : String(comment.body),
175
186
  created: comment.created,
176
187
  }));
188
+ // Add comment authors to people list (deduplicated, capped at 10 total)
189
+ const existingIds = new Set((issueDetails.people || []).map(p => p.accountId));
190
+ const commentAuthors = [];
191
+ for (const comment of issue.fields.comment.comments) {
192
+ const aid = comment.author?.accountId;
193
+ const name = comment.author?.displayName;
194
+ if (aid && name && !existingIds.has(aid)) {
195
+ existingIds.add(aid);
196
+ commentAuthors.push({ displayName: name, accountId: aid, role: 'commenter' });
197
+ }
198
+ }
199
+ if (commentAuthors.length > 0) {
200
+ const people = [...(issueDetails.people || []), ...commentAuthors];
201
+ issueDetails.people = people.slice(0, 10);
202
+ }
177
203
  }
178
204
  if (includeAttachments && issue.fields.attachment) {
179
205
  issueDetails.attachments = issue.fields.attachment
@@ -375,7 +401,6 @@ export class JiraClient {
375
401
  const searchResults = await this.client.issueSearch.searchForIssuesUsingJql({
376
402
  jql: filter.jql,
377
403
  fields: this.issueFields,
378
- expand: 'renderedFields'
379
404
  });
380
405
  return (searchResults.issues || []).map(issue => this.mapIssueFields(issue));
381
406
  }
@@ -477,7 +502,6 @@ export class JiraClient {
477
502
  jql: cleanJql,
478
503
  maxResults: Math.min(maxResults, 100),
479
504
  fields: this.issueFields,
480
- expand: 'renderedFields',
481
505
  });
482
506
  const issues = (searchResults.issues || []).map(issue => this.mapIssueFields(issue));
483
507
  // Note: Enhanced search API uses token-based pagination, not offset-based
@@ -578,7 +602,7 @@ export class JiraClient {
578
602
  async getPopulatedFields(issueKey) {
579
603
  const issue = await this.client.issues.getIssue({
580
604
  issueIdOrKey: issueKey,
581
- expand: 'renderedFields,names',
605
+ expand: 'names',
582
606
  });
583
607
  const fieldNames = issue.names || {};
584
608
  const fields = issue.fields;
@@ -4,11 +4,12 @@ import { analysisNextSteps } from '../utils/next-steps.js';
4
4
  import { normalizeArgs } from '../utils/normalize-args.js';
5
5
  const ALL_METRICS = ['points', 'time', 'schedule', 'cycle', 'distribution'];
6
6
  const VALID_GROUP_BY = ['project', 'assignee', 'priority', 'issuetype'];
7
- const MAX_ISSUES = 500;
7
+ const MAX_ISSUES_HARD = 500; // absolute ceiling for detail metrics — beyond this, context explodes
8
+ const MAX_ISSUES_DEFAULT = 100;
8
9
  const CUBE_SAMPLE_PCT = 0.2; // 20% of total issues
9
10
  const CUBE_SAMPLE_MIN = 50; // floor — enough for rare dimension values
10
11
  const CUBE_SAMPLE_MAX = 500; // ceiling — proven fast with lean search
11
- const MAX_CUBE_GROUPS = 20;
12
+ const DEFAULT_GROUP_LIMIT = 20;
12
13
  const MAX_COUNT_QUERIES = 150; // ADR-206 budget: max count API calls per execution
13
14
  const STANDARD_MEASURES = 6; // total, open, overdue, high+, created_7d, resolved_7d
14
15
  // ── Helpers ────────────────────────────────────────────────────────────
@@ -58,11 +59,14 @@ function countBy(items, keyFn) {
58
59
  function sumBy(items, valueFn) {
59
60
  return items.reduce((sum, item) => sum + (valueFn(item) || 0), 0);
60
61
  }
61
- function mapToString(map, separator = ' | ') {
62
- return [...map.entries()]
63
- .sort((a, b) => b[1] - a[1])
64
- .map(([k, v]) => `${k}: ${v}`)
65
- .join(separator);
62
+ function mapToString(map, separator = ' | ', limit) {
63
+ const sorted = [...map.entries()].sort((a, b) => b[1] - a[1]);
64
+ const capped = limit ? sorted.slice(0, limit) : sorted;
65
+ const result = capped.map(([k, v]) => `${k}: ${v}`).join(separator);
66
+ if (limit && sorted.length > limit) {
67
+ return `${result} | (+${sorted.length - limit} more — use groupLimit to see all)`;
68
+ }
69
+ return result;
66
70
  }
67
71
  function median(values) {
68
72
  if (values.length === 0)
@@ -255,16 +259,16 @@ export function renderCycle(issues, now) {
255
259
  }
256
260
  return lines.join('\n');
257
261
  }
258
- export function renderDistribution(issues) {
262
+ export function renderDistribution(issues, groupLimit = DEFAULT_GROUP_LIMIT) {
259
263
  const lines = ['## Distribution', ''];
260
264
  const byStatus = countBy(issues, i => i.status);
261
- lines.push(`**By status:** ${mapToString(byStatus)}`);
265
+ lines.push(`**By status:** ${mapToString(byStatus, ' | ', groupLimit)}`);
262
266
  const byAssignee = countBy(issues, i => i.assignee || 'Unassigned');
263
- lines.push(`**By assignee:** ${mapToString(byAssignee)}`);
267
+ lines.push(`**By assignee:** ${mapToString(byAssignee, ' | ', groupLimit)}`);
264
268
  const byPriority = countBy(issues, i => i.priority || 'None');
265
- lines.push(`**By priority:** ${mapToString(byPriority)}`);
269
+ lines.push(`**By priority:** ${mapToString(byPriority, ' | ', groupLimit)}`);
266
270
  const byType = countBy(issues, i => i.issueType || 'Unknown');
267
- lines.push(`**By type:** ${mapToString(byType)}`);
271
+ lines.push(`**By type:** ${mapToString(byType, ' | ', groupLimit)}`);
268
272
  return lines.join('\n');
269
273
  }
270
274
  /** Extract project keys from JQL like "project in (AA, GC, GD)" or "project = AA" */
@@ -408,7 +412,7 @@ function maxGroupsForBudget(implicitCount) {
408
412
  const queriesPerGroup = STANDARD_MEASURES + implicitCount;
409
413
  return Math.floor(MAX_COUNT_QUERIES / queriesPerGroup);
410
414
  }
411
- async function handleSummary(jiraClient, jql, groupBy, compute) {
415
+ async function handleSummary(jiraClient, jql, groupBy, compute, groupLimit = DEFAULT_GROUP_LIMIT) {
412
416
  const lines = [];
413
417
  lines.push(`# Summary: ${jql}`);
414
418
  lines.push(`As of ${formatDateShort(new Date())} — counts are exact (no sampling cap)`);
@@ -416,21 +420,26 @@ async function handleSummary(jiraClient, jql, groupBy, compute) {
416
420
  const implicitDefs = buildImplicitMeasures(jiraClient.customFieldIds);
417
421
  const neededImplicits = compute ? detectImplicitMeasures(compute, implicitDefs) : [];
418
422
  const groupBudget = maxGroupsForBudget(neededImplicits.length);
423
+ // Effective group cap: user's preference vs API query budget (whichever is smaller)
424
+ const effectiveGroupCap = Math.min(groupLimit, groupBudget);
419
425
  if (groupBy === 'project') {
420
426
  let keys = extractProjectKeys(jql);
421
427
  if (keys.length === 0) {
422
428
  throw new McpError(ErrorCode.InvalidParams, 'groupBy "project" requires project keys in JQL (e.g., project in (AA, GC))');
423
429
  }
424
- const capped = keys.length > groupBudget;
430
+ const capped = keys.length > effectiveGroupCap;
425
431
  if (capped)
426
- keys = keys.slice(0, groupBudget);
432
+ keys = keys.slice(0, effectiveGroupCap);
427
433
  const remaining = removeProjectClause(jql);
428
434
  const rows = await batchParallel(keys.map(k => () => buildCountRow(jiraClient, k, remaining ? `project = ${k} AND (${remaining})` : `project = ${k}`, neededImplicits, implicitDefs)), ROW_BATCH_SIZE);
429
435
  rows.sort((a, b) => b.unresolved - a.unresolved);
430
436
  lines.push('');
431
437
  lines.push(renderSummaryTable(rows, compute));
432
438
  if (capped) {
433
- lines.push(`*Capped at ${groupBudget} groups to stay within ${MAX_COUNT_QUERIES}-query budget*`);
439
+ const reason = effectiveGroupCap < groupBudget
440
+ ? `groupLimit=${effectiveGroupCap} — increase groupLimit to see more`
441
+ : `${MAX_COUNT_QUERIES}-query budget`;
442
+ lines.push(`*Capped at ${effectiveGroupCap} groups (${reason})*`);
434
443
  }
435
444
  }
436
445
  else if (groupBy) {
@@ -439,23 +448,29 @@ async function handleSummary(jiraClient, jql, groupBy, compute) {
439
448
  if (issues.length === 0) {
440
449
  throw new McpError(ErrorCode.InvalidParams, `No issues matched JQL — cannot discover ${groupBy} values`);
441
450
  }
442
- const dims = extractDimensions(issues);
451
+ const dims = extractDimensions(issues, groupLimit);
443
452
  const dim = dims.find(d => d.name === groupBy);
444
453
  if (!dim || dim.values.length === 0) {
445
454
  throw new McpError(ErrorCode.InvalidParams, `No ${groupBy} values found in sampled issues`);
446
455
  }
447
- // Cap groups to query budget
448
- const cappedValues = dim.values.slice(0, groupBudget);
456
+ // Cap groups to effective limit
457
+ const cappedValues = dim.values.slice(0, effectiveGroupCap);
449
458
  const jqlClause = groupByJqlClause(groupBy, cappedValues);
450
459
  const rows = await batchParallel(cappedValues.map((value, idx) => () => buildCountRow(jiraClient, value, `(${jql}) AND ${jqlClause[idx]}`, neededImplicits, implicitDefs)), ROW_BATCH_SIZE);
451
460
  rows.sort((a, b) => b.unresolved - a.unresolved);
452
461
  lines.push('');
453
462
  lines.push(renderSummaryTable(rows, compute));
454
463
  if (dim.count > cappedValues.length) {
455
- const reason = cappedValues.length < dim.values.length
456
- ? `capped at ${groupBudget} groups (${MAX_COUNT_QUERIES}-query budget)`
457
- : `from ${issues.length}-issue sample`;
458
- lines.push(`*Showing top ${cappedValues.length} of ${dim.count} ${groupBy} values (${reason})*`);
464
+ const reasons = [];
465
+ if (cappedValues.length < dim.values.length) {
466
+ reasons.push(effectiveGroupCap < groupBudget
467
+ ? `groupLimit=${effectiveGroupCap} increase groupLimit to see more`
468
+ : `${MAX_COUNT_QUERIES}-query budget`);
469
+ }
470
+ else {
471
+ reasons.push(`from ${issues.length}-issue sample`);
472
+ }
473
+ lines.push(`*Showing top ${cappedValues.length} of ${dim.count} ${groupBy} values (${reasons.join(', ')})*`);
459
474
  }
460
475
  }
461
476
  else {
@@ -472,7 +487,7 @@ function detectImplicitMeasures(compute, implicitMeasureDefs) {
472
487
  return Object.keys(implicitMeasureDefs).filter(name => refs.has(name));
473
488
  }
474
489
  /** Extract distinct dimension values from sampled issues */
475
- export function extractDimensions(issues) {
490
+ export function extractDimensions(issues, groupLimit = DEFAULT_GROUP_LIMIT) {
476
491
  const dims = [
477
492
  { name: 'project', extractor: i => i.key.split('-')[0] },
478
493
  { name: 'status', extractor: i => i.status },
@@ -486,10 +501,10 @@ export function extractDimensions(issues) {
486
501
  const val = extractor(issue);
487
502
  counts.set(val, (counts.get(val) || 0) + 1);
488
503
  }
489
- // Sort by count descending, cap at MAX_CUBE_GROUPS
504
+ // Sort by count descending, cap at groupLimit
490
505
  const sorted = [...counts.entries()]
491
506
  .sort((a, b) => b[1] - a[1])
492
- .slice(0, MAX_CUBE_GROUPS);
507
+ .slice(0, groupLimit);
493
508
  return {
494
509
  name,
495
510
  values: sorted.map(([v]) => v),
@@ -521,7 +536,7 @@ export function renderCubeSetup(jql, sampleSize, dimensions) {
521
536
  lines.push('');
522
537
  lines.push(`## Suggested Cubes (budget: ${MAX_COUNT_QUERIES} queries)`);
523
538
  for (const dim of dimensions) {
524
- const groups = Math.min(dim.count, MAX_CUBE_GROUPS);
539
+ const groups = Math.min(dim.count, DEFAULT_GROUP_LIMIT);
525
540
  const queries = groups * STANDARD_MEASURES;
526
541
  const estSeconds = Math.max(1, Math.round(queries / 12)); // ~12 parallel queries/sec
527
542
  const withinBudget = queries <= MAX_COUNT_QUERIES;
@@ -596,6 +611,9 @@ export async function handleAnalysisRequest(jiraClient, request) {
596
611
  if (args.compute && Array.isArray(args.compute) && args.compute.length > 0) {
597
612
  compute = parseComputeList(args.compute);
598
613
  }
614
+ // Parse groupLimit — soft cap on group/dimension values (no hard cap for summary metrics)
615
+ const rawGroupLimit = Number(args.groupLimit);
616
+ const groupLimit = rawGroupLimit > 0 ? rawGroupLimit : DEFAULT_GROUP_LIMIT;
599
617
  // Cube setup — discover dimensions from sample, no issue fetching
600
618
  if (hasCubeSetup) {
601
619
  const cubeText = await handleCubeSetup(jiraClient, jql);
@@ -609,7 +627,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
609
627
  }
610
628
  // If only summary requested, skip issue fetching entirely
611
629
  if (hasSummary && fetchMetrics.length === 0) {
612
- const summaryText = await handleSummary(jiraClient, jql, groupBy, compute);
630
+ const summaryText = await handleSummary(jiraClient, jql, groupBy, compute, groupLimit);
613
631
  const nextSteps = analysisNextSteps(jql, []);
614
632
  return {
615
633
  content: [{
@@ -618,8 +636,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
618
636
  }],
619
637
  };
620
638
  }
621
- const DEFAULT_MAX = 100;
622
- const maxResults = Math.min(Number(args.maxResults) || DEFAULT_MAX, MAX_ISSUES);
639
+ const maxResults = Math.min(Number(args.maxResults) || MAX_ISSUES_DEFAULT, MAX_ISSUES_HARD);
623
640
  // Fetch issues using cursor-based pagination (50 per page, Jira enhanced search API)
624
641
  const allIssues = [];
625
642
  const seen = new Set();
@@ -658,7 +675,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
658
675
  const lines = [];
659
676
  // Summary first if requested alongside other metrics
660
677
  if (hasSummary) {
661
- const summaryText = await handleSummary(jiraClient, jql, groupBy, compute);
678
+ const summaryText = await handleSummary(jiraClient, jql, groupBy, compute, groupLimit);
662
679
  lines.push(summaryText);
663
680
  }
664
681
  // Header for fetch-based metrics
@@ -668,7 +685,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
668
685
  lines.push(`# Detail: ${jql}`);
669
686
  lines.push(`Analyzed ${allIssues.length} issues (as of ${formatDateShort(now)})`);
670
687
  if (truncated) {
671
- lines.push(`*Results capped at ${maxResults} issues — query may match more.*`);
688
+ lines.push(`*Results capped at ${maxResults} issues — distributions below are approximate. For exact counts, use metrics: ["summary"] with groupBy instead.*`);
672
689
  }
673
690
  // Render requested metrics
674
691
  const renderers = {
@@ -676,7 +693,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
676
693
  time: () => renderTime(allIssues),
677
694
  schedule: () => renderSchedule(allIssues, now),
678
695
  cycle: () => renderCycle(allIssues, now),
679
- distribution: () => renderDistribution(allIssues),
696
+ distribution: () => renderDistribution(allIssues, groupLimit),
680
697
  };
681
698
  for (const metric of fetchMetrics) {
682
699
  lines.push('');
@@ -122,11 +122,11 @@ export function renderIssue(issue, transitions) {
122
122
  }
123
123
  if (issue.resolution)
124
124
  lines.push(`Resolution: ${issue.resolution}`);
125
- // Description — full for single issue view
125
+ // Description — already markdown from ADF conversion
126
126
  if (issue.description) {
127
127
  lines.push('');
128
128
  lines.push('Description:');
129
- lines.push(stripHtml(issue.description));
129
+ lines.push(issue.description);
130
130
  }
131
131
  // Issue links
132
132
  if (issue.issueLinks && issue.issueLinks.length > 0) {
@@ -150,7 +150,8 @@ export function renderIssue(issue, transitions) {
150
150
  }
151
151
  for (let i = 0; i < recentComments.length; i++) {
152
152
  const comment = recentComments[i];
153
- lines.push(`[${startIdx + i}/${issue.comments.length}] ${comment.author} (${formatDate(comment.created)}): ${stripHtml(comment.body)}`);
153
+ const preview = comment.body.split('\n').filter((l) => l.trim()).slice(0, 2).join(' | ');
154
+ lines.push(`[${startIdx + i}/${issue.comments.length}] ${comment.author} (${formatDate(comment.created)}): ${truncate(preview, 200)}`);
154
155
  }
155
156
  }
156
157
  // Custom fields (from catalog discovery)
@@ -172,6 +173,18 @@ export function renderIssue(issue, transitions) {
172
173
  lines.push(`${t.name} -> ${t.to.name} (id: ${t.id})`);
173
174
  }
174
175
  }
176
+ // People — accountIds for @mentions and assignee operations
177
+ if (issue.people && issue.people.length > 0) {
178
+ lines.push('');
179
+ lines.push('People:');
180
+ for (const person of issue.people) {
181
+ lines.push(`${person.displayName} | ${person.role} | accountId: ${person.accountId}`);
182
+ }
183
+ lines.push('Use accountId to assign issues or @mention in comments');
184
+ }
185
+ // Formatting hint — remind the agent that descriptions/comments accept markdown
186
+ lines.push('');
187
+ lines.push('Formatting: write markdown for descriptions and comments (headings, **bold**, *italic*, ~~strikethrough~~, `code`, lists)');
175
188
  return lines.join('\n');
176
189
  }
177
190
  /**
@@ -210,7 +223,7 @@ export function renderIssueSearchResults(issues, pagination, jql) {
210
223
  meta.push(`due ${formatDate(issue.dueDate)}`);
211
224
  lines.push(meta.join(' | '));
212
225
  if (issue.description) {
213
- const desc = stripHtml(issue.description);
226
+ const desc = issue.description.split('\n').filter((l) => l.trim()).slice(0, 2).join(' | ');
214
227
  if (desc.length > 0) {
215
228
  lines.push(` ${truncate(desc, 120)}`);
216
229
  }
@@ -326,7 +326,7 @@ export const toolSchemas = {
326
326
  },
327
327
  analyze_jira_issues: {
328
328
  name: 'analyze_jira_issues',
329
- description: 'Compute project metrics over issues selected by JQL. Use "summary" for exact counts across projects (no sampling cap). Use other metrics for detailed analysis of individual issue data. Always prefer this tool over manage_jira_filter or manage_jira_project for quantitative questions (counts, totals, overdue, workload). Read jira://analysis/recipes for composition patterns.',
329
+ description: 'Compute project metrics over issues selected by JQL. For counting and breakdown questions ("how many by status/assignee/priority"), use metrics: ["summary"] with groupBy — this gives exact counts with no issue cap. Use detail metrics (points, time, schedule, cycle, distribution) only when you need per-issue analysis; these are capped at maxResults issues. Always prefer this tool over manage_jira_filter or manage_jira_project for quantitative questions. Read jira://analysis/recipes for composition patterns.',
330
330
  inputSchema: {
331
331
  type: 'object',
332
332
  properties: {
@@ -340,12 +340,12 @@ export const toolSchemas = {
340
340
  type: 'string',
341
341
  enum: ['summary', 'points', 'time', 'schedule', 'cycle', 'distribution', 'cube_setup'],
342
342
  },
343
- description: 'Which metric groups to compute. summary = exact issue counts via count API (no cap, fastest). cube_setup = sample issues and discover available dimensions/measures for cube queries. points = earned value/SPI. time = effort estimates. schedule = overdue/risk. cycle = lead time/throughput. distribution = counts by status/assignee/priority/type. Default: all except summary and cube_setup.',
343
+ description: 'Which metric groups to compute. summary = exact counts via count API (no issue cap, fastest) — use with groupBy for "how many by assignee/status/priority" questions. distribution = approximate counts from fetched issues (capped by maxResults — use summary + groupBy instead when you need exact counts). cube_setup = discover dimensions before cube queries. points = earned value/SPI. time = effort estimates. schedule = overdue/risk. cycle = lead time/throughput. Default: all detail metrics. For counting/breakdown questions, always prefer summary + groupBy over distribution.',
344
344
  },
345
345
  groupBy: {
346
346
  type: 'string',
347
347
  enum: ['project', 'assignee', 'priority', 'issuetype'],
348
- description: 'Split summary counts by this dimension. Use with metrics: ["summary"]. "project" produces a per-project comparison table.',
348
+ description: 'Split counts by this dimension — produces a breakdown table. Use with metrics: ["summary"] for exact counts. This is the correct approach for "how many issues per assignee/priority/type" questions. "project" produces a per-project comparison.',
349
349
  },
350
350
  compute: {
351
351
  type: 'array',
@@ -353,6 +353,11 @@ export const toolSchemas = {
353
353
  description: 'Computed columns for cube execute. Each entry: "name = expr". Arithmetic (+,-,*,/), comparisons (>,<,>=,<=,==,!=). Column refs: total, open, overdue, high, created_7d, resolved_7d. Implicit measures resolved lazily: bugs, unassigned, no_due_date, no_estimate, no_start_date, no_labels, blocked, stale (untouched 60d+), stale_status (stuck in status 30d+), backlog_rot (undated+unassigned+untouched 60d+). Max 5 expressions. Example: ["bug_pct = bugs / total * 100", "rot_pct = backlog_rot / open * 100"].',
354
354
  maxItems: 5,
355
355
  },
356
+ groupLimit: {
357
+ type: 'integer',
358
+ description: 'Max groups/dimension values to show (default 20). Applies to summary groupBy rows and distribution breakdowns. No hard cap for summary metrics — increase freely for full visibility. For detail metrics the issue fetch is separately capped by maxResults.',
359
+ default: 20,
360
+ },
356
361
  maxResults: {
357
362
  type: 'integer',
358
363
  description: 'Max issues to fetch for detail metrics (default 100, max 500). Does not apply to summary (which uses count API).',
@@ -132,7 +132,7 @@ export function analysisNextSteps(jql, issueKeys, truncated = false) {
132
132
  }
133
133
  steps.push({ description: 'Discover dimensions for cube analysis', tool: 'analyze_jira_issues', example: { jql, metrics: ['cube_setup'] } }, { description: 'Add computed columns', tool: 'analyze_jira_issues', example: { jql, metrics: ['summary'], groupBy: 'project', compute: ['bug_pct = bugs / total * 100'] } }, { description: 'Narrow the analysis with refined JQL', tool: 'analyze_jira_issues', example: { jql: `${jql} AND priority = High` } }, { description: 'View the full issue list', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql } });
134
134
  if (truncated) {
135
- steps.push({ description: 'Detail metrics are sampled narrow JQL by assignee, priority, or type for precise results. Use summary metrics (count API) for whole-project totals', tool: 'analyze_jira_issues', example: { jql: `${jql} AND assignee = currentUser()`, metrics: ['cycle'] } });
135
+ steps.push({ description: 'Distribution counts above are approximate (issue cap hit). For exact breakdowns use summary + groupBy', tool: 'analyze_jira_issues', example: { jql, metrics: ['summary'], groupBy: 'assignee' } }, { description: 'Or narrow JQL for precise detail metrics', tool: 'analyze_jira_issues', example: { jql: `${jql} AND assignee = currentUser()`, metrics: ['cycle'] } });
136
136
  }
137
137
  return formatSteps(steps) + '\n- Read `jira://analysis/recipes` for data cube patterns and compute DSL examples';
138
138
  }
@@ -1,6 +1,46 @@
1
1
  import MarkdownIt from 'markdown-it';
2
+ // Jira accountIds: hex strings or "712020:uuid-format"
3
+ const MENTION_RE = /@([a-zA-Z0-9][a-zA-Z0-9:_-]{9,})/g;
2
4
  export class TextProcessor {
3
- static md = new MarkdownIt();
5
+ static md = new MarkdownIt().enable('strikethrough');
6
+ /**
7
+ * Split text into alternating text and mention ADF nodes.
8
+ * Recognizes @accountId patterns and converts them to ADF mention nodes.
9
+ */
10
+ static splitMentions(text, marks) {
11
+ const nodes = [];
12
+ let lastIndex = 0;
13
+ MENTION_RE.lastIndex = 0;
14
+ let match;
15
+ while ((match = MENTION_RE.exec(text)) !== null) {
16
+ // Text before the mention
17
+ if (match.index > lastIndex) {
18
+ const before = text.slice(lastIndex, match.index);
19
+ const node = { type: 'text', text: before };
20
+ if (marks?.length)
21
+ node.marks = marks;
22
+ nodes.push(node);
23
+ }
24
+ // Mention node
25
+ nodes.push({
26
+ type: 'mention',
27
+ attrs: { id: match[1], text: `@${match[1]}` },
28
+ });
29
+ lastIndex = MENTION_RE.lastIndex;
30
+ }
31
+ // Remaining text after last mention
32
+ if (lastIndex < text.length) {
33
+ const remaining = text.slice(lastIndex);
34
+ const node = { type: 'text', text: remaining };
35
+ if (marks?.length)
36
+ node.marks = marks;
37
+ nodes.push(node);
38
+ }
39
+ // No mentions found — return empty so caller uses original logic
40
+ if (nodes.length === 0)
41
+ return [];
42
+ return nodes;
43
+ }
4
44
  static markdownToAdf(markdown) {
5
45
  // Replace literal \n sequences with actual newlines so markdown-it
6
46
  // correctly splits paragraphs. MCP JSON transport may deliver these
@@ -73,6 +113,24 @@ export class TextProcessor {
73
113
  for (let i = 0; i < token.children.length; i++) {
74
114
  const child = token.children[i];
75
115
  if (child.type === 'text') {
116
+ // Check for @accountId mentions in this text segment
117
+ const mentionNodes = TextProcessor.splitMentions(child.content, marks.length > 0 ? marks : undefined);
118
+ if (mentionNodes.length > 0) {
119
+ // Flush any pending plain text first
120
+ if (currentText) {
121
+ lastBlock.content.push({
122
+ type: 'text',
123
+ text: currentText,
124
+ ...(marks.length > 0 && { marks })
125
+ });
126
+ currentText = '';
127
+ }
128
+ lastBlock.content.push(...mentionNodes);
129
+ // Reset marks after flushing mention-bearing text
130
+ if (marks.length > 0)
131
+ marks = [];
132
+ continue;
133
+ }
76
134
  if (currentText && marks.length > 0) {
77
135
  lastBlock.content.push({
78
136
  type: 'text',
@@ -104,6 +162,16 @@ export class TextProcessor {
104
162
  }
105
163
  marks.push({ type: 'em' });
106
164
  }
165
+ else if (child.type === 's_open') {
166
+ if (currentText) {
167
+ lastBlock.content.push({
168
+ type: 'text',
169
+ text: currentText
170
+ });
171
+ currentText = '';
172
+ }
173
+ marks.push({ type: 'strike' });
174
+ }
107
175
  else if (child.type === 'link_open') {
108
176
  if (currentText) {
109
177
  lastBlock.content.push({
@@ -183,6 +251,134 @@ export class TextProcessor {
183
251
  }
184
252
  return '';
185
253
  }
254
+ /**
255
+ * Convert ADF to markdown, preserving formatting for round-trip fidelity.
256
+ * This is the inverse of markdownToAdf — the agent reads markdown and
257
+ * writes markdown, so the formatting survives the Jira round-trip.
258
+ */
259
+ static adfToMarkdown(node) {
260
+ if (!node)
261
+ return '';
262
+ switch (node.type) {
263
+ case 'doc':
264
+ return (node.content || [])
265
+ .map((child) => TextProcessor.adfToMarkdown(child))
266
+ .join('\n\n')
267
+ .replace(/\n{3,}/g, '\n\n')
268
+ .trim();
269
+ case 'paragraph':
270
+ return (node.content || [])
271
+ .map((child) => TextProcessor.adfToMarkdown(child))
272
+ .join('');
273
+ case 'heading': {
274
+ const level = node.attrs?.level || 1;
275
+ const prefix = '#'.repeat(level);
276
+ const text = (node.content || [])
277
+ .map((child) => TextProcessor.adfToMarkdown(child))
278
+ .join('');
279
+ return `${prefix} ${text}`;
280
+ }
281
+ case 'text': {
282
+ let text = node.text || '';
283
+ if (node.marks) {
284
+ // Apply inline marks first (bold, italic, etc.), then link outermost
285
+ // so we get **[text](url)** not [**text**](url)
286
+ const inlineMarks = node.marks.filter((m) => m.type !== 'link');
287
+ const linkMark = node.marks.find((m) => m.type === 'link');
288
+ for (const mark of inlineMarks) {
289
+ switch (mark.type) {
290
+ case 'strong':
291
+ text = `**${text}**`;
292
+ break;
293
+ case 'em':
294
+ text = `*${text}*`;
295
+ break;
296
+ case 'strike':
297
+ text = `~~${text}~~`;
298
+ break;
299
+ case 'code':
300
+ text = `\`${text}\``;
301
+ break;
302
+ }
303
+ }
304
+ if (linkMark) {
305
+ text = `[${text}](${linkMark.attrs?.href || ''})`;
306
+ }
307
+ }
308
+ return text;
309
+ }
310
+ case 'mention':
311
+ // Emit @accountId so the agent can reuse it in comments
312
+ return `@${node.attrs?.id || node.attrs?.text?.replace('@', '') || ''}`;
313
+ case 'hardBreak':
314
+ return '\n';
315
+ case 'bulletList':
316
+ return (node.content || [])
317
+ .map((child) => TextProcessor.adfToMarkdown(child))
318
+ .join('\n');
319
+ case 'orderedList':
320
+ return (node.content || [])
321
+ .map((child, i) => {
322
+ const itemText = TextProcessor.adfListItemContent(child);
323
+ return `${i + 1}. ${itemText}`;
324
+ })
325
+ .join('\n');
326
+ case 'listItem': {
327
+ const itemText = TextProcessor.adfListItemContent(node);
328
+ return `- ${itemText}`;
329
+ }
330
+ case 'codeBlock': {
331
+ const lang = node.attrs?.language || '';
332
+ const code = (node.content || [])
333
+ .map((child) => child.text || '')
334
+ .join('');
335
+ return `\`\`\`${lang}\n${code}\n\`\`\``;
336
+ }
337
+ case 'blockquote': {
338
+ const content = (node.content || [])
339
+ .map((child) => TextProcessor.adfToMarkdown(child))
340
+ .join('\n');
341
+ return content.split('\n').map((line) => `> ${line}`).join('\n');
342
+ }
343
+ case 'rule':
344
+ return '---';
345
+ case 'table':
346
+ case 'tableRow':
347
+ case 'tableHeader':
348
+ case 'tableCell':
349
+ // Flatten table content to text — ADF tables don't round-trip well through markdown
350
+ return (node.content || [])
351
+ .map((child) => TextProcessor.adfToMarkdown(child))
352
+ .join(node.type === 'tableRow' ? ' | ' : '\n');
353
+ case 'mediaSingle':
354
+ case 'media':
355
+ // Media nodes can't round-trip; skip silently
356
+ return '';
357
+ case 'inlineCard':
358
+ return node.attrs?.url || '';
359
+ default:
360
+ // Unknown node — recurse into children if present
361
+ if (node.content) {
362
+ return (node.content || [])
363
+ .map((child) => TextProcessor.adfToMarkdown(child))
364
+ .join('');
365
+ }
366
+ return node.text || '';
367
+ }
368
+ }
369
+ /** Extract text content from a listItem node (skipping the wrapping paragraph) */
370
+ static adfListItemContent(node) {
371
+ return (node.content || [])
372
+ .map((child) => {
373
+ if (child.type === 'paragraph') {
374
+ return (child.content || [])
375
+ .map((c) => TextProcessor.adfToMarkdown(c))
376
+ .join('');
377
+ }
378
+ return TextProcessor.adfToMarkdown(child);
379
+ })
380
+ .join('\n');
381
+ }
186
382
  static isFieldPopulated(value) {
187
383
  if (value === null || value === undefined)
188
384
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "mcpName": "io.github.aaronsb/jira-cloud",
5
5
  "description": "Model Context Protocol (MCP) server for Jira Cloud - enables AI assistants to interact with Jira",
6
6
  "type": "module",