@aaronsb/jira-cloud-mcp 0.5.2 → 0.5.3

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/README.md CHANGED
@@ -40,26 +40,31 @@ Generate an API token at [Atlassian Account Settings](https://id.atlassian.com/m
40
40
 
41
41
  | Tool | Description |
42
42
  |------|-------------|
43
- | `manage_jira_issue` | Get, create, update, delete, move, transition, comment on, or link Jira issues |
43
+ | `manage_jira_issue` | Get, create, update, delete, move, transition, comment on, link, or traverse hierarchy of issues |
44
44
  | `manage_jira_filter` | Search for issues using JQL queries, or manage saved filters |
45
- | `manage_jira_project` | List projects or get project details including status counts |
45
+ | `manage_jira_project` | List projects or get project configuration and metadata |
46
46
  | `manage_jira_board` | List boards or get board details and configuration |
47
47
  | `manage_jira_sprint` | Manage sprints: create, start, close, and assign issues to sprints |
48
- | `queue_jira_operations` | Execute multiple operations in one call with result references and error strategies |
48
+ | `queue_jira_operations` | Batch multiple operations with result references (`$0.key`) and error strategies |
49
+ | `analyze_jira_issues` | Compute metrics, exact counts, and data cube analysis over issues selected by JQL |
49
50
 
50
- Each tool accepts an `operation` parameter (except `queue_jira_operations` which takes an `operations` array). Detailed documentation is available as MCP resources at `jira://tools/{tool_name}/documentation`.
51
+ Each tool accepts an `operation` parameter (except `queue_jira_operations` which takes an `operations` array, and `analyze_jira_issues` which takes `jql` + `metrics`). Per-tool documentation is available as MCP resources at `jira://tools/{tool_name}/documentation`.
52
+
53
+ See [docs/tools.md](docs/tools.md) for detailed tool descriptions, workspace patterns, and design principles.
51
54
 
52
55
  ## MCP Resources
53
56
 
54
57
  | Resource | Description |
55
58
  |----------|-------------|
56
59
  | `jira://instance/summary` | Instance-level statistics |
60
+ | `jira://projects/distribution` | Project distribution overview |
57
61
  | `jira://projects/{key}/overview` | Project overview with status counts |
58
62
  | `jira://boards/{id}/overview` | Board overview with sprint info |
59
63
  | `jira://issue-link-types` | Available issue link types |
60
64
  | `jira://custom-fields` | Custom field catalog (auto-discovered at startup) |
61
65
  | `jira://custom-fields/{project}/{issueType}` | Context-specific custom fields |
62
- | `jira://tools/{name}/documentation` | Tool documentation |
66
+ | `jira://analysis/recipes` | Analysis query patterns and compute DSL reference |
67
+ | `jira://tools/{name}/documentation` | Per-tool documentation |
63
68
 
64
69
  ## License
65
70
 
@@ -8,6 +8,10 @@ export class JiraClient {
8
8
  get v3Client() {
9
9
  return this.client;
10
10
  }
11
+ /** Expose custom field IDs for JQL construction outside the client */
12
+ get customFieldIds() {
13
+ return this.customFields;
14
+ }
11
15
  constructor(config) {
12
16
  const clientConfig = {
13
17
  host: config.host,
@@ -483,7 +483,7 @@ function generateAnalysisToolDocumentation(schema) {
483
483
  },
484
484
  compute: {
485
485
  type: "array of strings",
486
- description: "Computed columns for summary tables. Each: 'name = expr'. Arithmetic, comparisons, column refs. Implicit measures: bugs, unassigned, no_due_date, blocked.",
486
+ description: "Computed columns for summary tables. Each: 'name = expr'. Arithmetic, comparisons, column refs. Implicit measures: bugs, unassigned, no_due_date, no_estimate, no_start_date, no_labels, blocked.",
487
487
  },
488
488
  maxResults: {
489
489
  type: "integer",
@@ -248,17 +248,36 @@ export function removeProjectClause(jql) {
248
248
  .replace(/\s*AND\s*$/i, '')
249
249
  .trim();
250
250
  }
251
+ /** Run async tasks in batches to avoid API rate limiting */
252
+ async function batchParallel(tasks, batchSize) {
253
+ const results = [];
254
+ for (let i = 0; i < tasks.length; i += batchSize) {
255
+ const batch = tasks.slice(i, i + batchSize);
256
+ results.push(...await Promise.all(batch.map(fn => fn())));
257
+ }
258
+ return results;
259
+ }
260
+ const ROW_BATCH_SIZE = 3; // ~18-33 concurrent count queries per batch
251
261
  /** Build a scoped JQL by adding a condition to the base query */
252
262
  function scopeJql(baseJql, condition) {
253
263
  return `(${baseJql}) AND ${condition}`;
254
264
  }
255
- /** Implicit measures — lazily resolved if referenced in compute expressions */
256
- const IMPLICIT_MEASURES = {
257
- bugs: 'issuetype = Bug AND resolution = Unresolved',
258
- unassigned: 'assignee is EMPTY AND resolution = Unresolved',
259
- no_due_date: 'dueDate is EMPTY AND resolution = Unresolved',
260
- blocked: 'status = Blocked',
261
- };
265
+ /** Implicit measures — lazily resolved if referenced in compute expressions.
266
+ * Built dynamically because some use custom field IDs. */
267
+ function buildImplicitMeasures(customFieldIds) {
268
+ const measures = {
269
+ bugs: 'issuetype = Bug AND resolution = Unresolved',
270
+ unassigned: 'assignee is EMPTY AND resolution = Unresolved',
271
+ no_due_date: 'dueDate is EMPTY AND resolution = Unresolved',
272
+ blocked: 'status = Blocked',
273
+ no_labels: 'labels is EMPTY AND resolution = Unresolved',
274
+ };
275
+ if (customFieldIds) {
276
+ measures.no_estimate = `${customFieldIds.storyPoints} is EMPTY AND resolution = Unresolved`;
277
+ measures.no_start_date = `${customFieldIds.startDate} is EMPTY AND resolution = Unresolved`;
278
+ }
279
+ return measures;
280
+ }
262
281
  /** Map dimension values to JQL clauses for groupBy scoping */
263
282
  export function groupByJqlClause(dimension, values) {
264
283
  switch (dimension) {
@@ -272,7 +291,7 @@ export function groupByJqlClause(dimension, values) {
272
291
  return values.map(v => `issuetype = "${v}"`);
273
292
  }
274
293
  }
275
- async function buildCountRow(jiraClient, label, baseJql, implicitMeasureNames) {
294
+ async function buildCountRow(jiraClient, label, baseJql, implicitMeasureNames, implicitMeasureDefs) {
276
295
  const [total, unresolved, overdue, highPriority, createdRecently, resolvedRecently] = await Promise.all([
277
296
  jiraClient.countIssues(baseJql),
278
297
  jiraClient.countIssues(scopeJql(baseJql, 'resolution = Unresolved')),
@@ -282,8 +301,8 @@ async function buildCountRow(jiraClient, label, baseJql, implicitMeasureNames) {
282
301
  jiraClient.countIssues(scopeJql(baseJql, 'resolved >= -7d')),
283
302
  ]);
284
303
  let implicitMeasures;
285
- if (implicitMeasureNames && implicitMeasureNames.length > 0) {
286
- const counts = await Promise.all(implicitMeasureNames.map(name => jiraClient.countIssues(scopeJql(baseJql, IMPLICIT_MEASURES[name]))));
304
+ if (implicitMeasureNames && implicitMeasureNames.length > 0 && implicitMeasureDefs) {
305
+ const counts = await Promise.all(implicitMeasureNames.map(name => jiraClient.countIssues(scopeJql(baseJql, implicitMeasureDefs[name]))));
287
306
  implicitMeasures = {};
288
307
  for (let i = 0; i < implicitMeasureNames.length; i++) {
289
308
  implicitMeasures[implicitMeasureNames[i]] = counts[i];
@@ -348,8 +367,9 @@ async function handleSummary(jiraClient, jql, groupBy, compute) {
348
367
  const lines = [];
349
368
  lines.push(`# Summary: ${jql}`);
350
369
  lines.push(`As of ${formatDateShort(new Date())} — counts are exact (no sampling cap)`);
351
- // Detect implicit measures needed by compute expressions
352
- const neededImplicits = compute ? detectImplicitMeasures(compute) : [];
370
+ // Build implicit measures with custom field IDs for JQL construction
371
+ const implicitDefs = buildImplicitMeasures(jiraClient.customFieldIds);
372
+ const neededImplicits = compute ? detectImplicitMeasures(compute, implicitDefs) : [];
353
373
  const groupBudget = maxGroupsForBudget(neededImplicits.length);
354
374
  if (groupBy === 'project') {
355
375
  let keys = extractProjectKeys(jql);
@@ -360,7 +380,7 @@ async function handleSummary(jiraClient, jql, groupBy, compute) {
360
380
  if (capped)
361
381
  keys = keys.slice(0, groupBudget);
362
382
  const remaining = removeProjectClause(jql);
363
- const rows = await Promise.all(keys.map(k => buildCountRow(jiraClient, k, remaining ? `project = ${k} AND (${remaining})` : `project = ${k}`, neededImplicits)));
383
+ const rows = await batchParallel(keys.map(k => () => buildCountRow(jiraClient, k, remaining ? `project = ${k} AND (${remaining})` : `project = ${k}`, neededImplicits, implicitDefs)), ROW_BATCH_SIZE);
364
384
  rows.sort((a, b) => b.unresolved - a.unresolved);
365
385
  lines.push('');
366
386
  lines.push(renderSummaryTable(rows, compute));
@@ -382,7 +402,7 @@ async function handleSummary(jiraClient, jql, groupBy, compute) {
382
402
  // Cap groups to query budget
383
403
  const cappedValues = dim.values.slice(0, groupBudget);
384
404
  const jqlClause = groupByJqlClause(groupBy, cappedValues);
385
- const rows = await Promise.all(cappedValues.map((value, idx) => buildCountRow(jiraClient, value, `(${jql}) AND ${jqlClause[idx]}`, neededImplicits)));
405
+ const rows = await batchParallel(cappedValues.map((value, idx) => () => buildCountRow(jiraClient, value, `(${jql}) AND ${jqlClause[idx]}`, neededImplicits, implicitDefs)), ROW_BATCH_SIZE);
386
406
  rows.sort((a, b) => b.unresolved - a.unresolved);
387
407
  lines.push('');
388
408
  lines.push(renderSummaryTable(rows, compute));
@@ -395,16 +415,16 @@ async function handleSummary(jiraClient, jql, groupBy, compute) {
395
415
  }
396
416
  else {
397
417
  // No groupBy — single row for the whole JQL
398
- const row = await buildCountRow(jiraClient, 'All', jql, neededImplicits);
418
+ const row = await buildCountRow(jiraClient, 'All', jql, neededImplicits, implicitDefs);
399
419
  lines.push('');
400
420
  lines.push(renderSummaryTable([row], compute));
401
421
  }
402
422
  return lines.join('\n');
403
423
  }
404
424
  /** Detect which implicit measures are referenced by compute expressions */
405
- function detectImplicitMeasures(compute) {
425
+ function detectImplicitMeasures(compute, implicitMeasureDefs) {
406
426
  const refs = extractColumnRefs(compute);
407
- return Object.keys(IMPLICIT_MEASURES).filter(name => refs.has(name));
427
+ return Object.keys(implicitMeasureDefs).filter(name => refs.has(name));
408
428
  }
409
429
  /** Extract distinct dimension values from sampled issues */
410
430
  export function extractDimensions(issues) {
@@ -451,7 +471,7 @@ export function renderCubeSetup(jql, sampleSize, dimensions) {
451
471
  lines.push('- total, open, overdue, high+, created_7d, resolved_7d');
452
472
  lines.push('');
453
473
  lines.push('Implicit measures (lazily resolved if referenced in `compute`):');
454
- lines.push('- bugs, unassigned, no_due_date, blocked');
474
+ lines.push('- bugs, unassigned, no_due_date, no_estimate, no_start_date, no_labels, blocked');
455
475
  // Suggested cubes with cost estimates
456
476
  lines.push('');
457
477
  lines.push(`## Suggested Cubes (budget: ${MAX_COUNT_QUERIES} queries)`);
@@ -371,103 +371,95 @@ async function getContextCustomFields(jiraClient, projectKey, issueType) {
371
371
  function getAnalysisRecipes() {
372
372
  const markdown = `# Analysis Query Recipes
373
373
 
374
- ## Two Layers
374
+ ## Foundation Rules
375
375
 
376
- Stack these for magnitude + detail:
376
+ - **Always start with cube_setup** on unfamiliar JQL before committing to a query. It tells you what dimensions exist, how many distinct values each has, and estimates query cost. Think of it as DESCRIBE TABLE before a SQL query.
377
+ - **\`metrics: ["summary"] + groupBy: "project"\` is your default opening move.** It's exact (no sampling cap), fast, and gives you a cross-project comparison in one call. Use everything else to drill down after.
378
+ - **The \`distribution\` metric is rich but capped at 500 issues.** Only use it scoped to a single project, never cross-project. Mixing it into a broad query will silently undersample.
379
+ - **\`manage_jira_project\` issue counts cap at 100** — never use it for actual counts, only for metadata. \`analyze_jira_issues\` with \`metrics: ["summary"]\` is always more accurate.
377
380
 
378
- 1. **Count layer** — \`analyze_jira_issues\` with \`metrics: ["summary"]\` + \`groupBy: "project"\` → exact counts, no cap
379
- 2. **Detail layer** — \`analyze_jira_issues\` with other metrics, or \`manage_jira_filter\` execute_jql → individual issues
381
+ ## Core Recipes
380
382
 
381
- ## Recipes
382
-
383
- ### Project Health Snapshot
383
+ ### Org-wide Health Snapshot
384
384
  **Question:** Which projects are busiest / most at-risk?
385
385
  \`\`\`json
386
- { "jql": "project in (AA, LGS, GD, GC) AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project" }
386
+ { "jql": "project in (...) AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project", "compute": ["overdue_pct = overdue / open * 100", "high_pct = high / open * 100", "clearing = resolved_7d > created_7d", "planning_gap = no_due_date / open * 100"] }
387
387
  \`\`\`
388
+ One query, full picture. The \`clearing\` boolean immediately flags which projects are accumulating debt.
388
389
 
389
390
  ### Net Flow / Velocity
390
391
  **Question:** Are we resolving faster than creating?
391
- Run two summary queries and subtract:
392
- - \`created >= -7d\` with \`groupBy: "project"\` → created this week
393
- - \`resolved >= -7d\` with \`groupBy: "project"\` → resolved this week
394
- Net = created - resolved. Positive = growing backlog. Negative = clearing.
395
-
396
- ### Bug Ratio
397
- **Question:** How much open work is bugs vs planned?
398
392
  \`\`\`json
399
- { "jql": "issuetype = Bug AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project" }
393
+ { "jql": "project in (...) AND (created >= -7d OR resolved >= -7d)", "metrics": ["summary"], "groupBy": "project", "compute": ["net_flow = resolved_7d - created_7d", "clearing = resolved_7d > created_7d"] }
400
394
  \`\`\`
395
+ Positive net_flow = clearing, negative = accumulating. The OR condition in JQL ensures you capture both sides of the ledger.
401
396
 
402
- ### Overdue Breakdown
403
- **Question:** Where are we most behind?
397
+ ### Team Workload Scorecard
398
+ **Question:** Who is overloaded or under-tracked?
404
399
  \`\`\`json
405
- { "jql": "resolution = Unresolved AND dueDate < now()", "metrics": ["summary"], "groupBy": "project" }
400
+ { "jql": "project in (...) AND resolution = Unresolved AND assignee is not EMPTY", "metrics": ["summary"], "groupBy": "assignee", "compute": ["overdue_pct = overdue / open * 100", "high_pct = high / open * 100", "no_dates = no_due_date / open * 100"] }
406
401
  \`\`\`
402
+ The \`no_dates\` column is the secret ingredient — it distinguishes "this person is behind" from "this person has no dates set so overdue looks artificially low." Scope to 2-3 projects max.
407
403
 
408
- ### Deadline Pressure Window
409
- **Question:** What's due in the next 2 weeks?
404
+ ### Planning Gap Detector
405
+ **Question:** Where is risk invisible?
410
406
  \`\`\`json
411
- { "jql": "resolution = Unresolved AND dueDate >= now() AND dueDate <= 14d", "metrics": ["summary"], "groupBy": "project" }
407
+ { "jql": "resolution = Unresolved AND dueDate is EMPTY", "metrics": ["summary"], "groupBy": "project" }
412
408
  \`\`\`
409
+ Projects with high-priority open issues and no due dates aren't "on time" — they're untracked. Don't trust overdue numbers in projects with high planning gap.
413
410
 
414
- ### Stale Backlog
415
- **Question:** How much backlog needs grooming?
411
+ ### Stale Backlog Grooming Target
412
+ **Question:** What needs a decision?
416
413
  \`\`\`json
417
414
  { "jql": "status = Backlog AND created <= -90d", "metrics": ["summary"], "groupBy": "project" }
418
415
  \`\`\`
416
+ Anything sitting in Backlog 90+ days with no movement is a decision waiting to happen. Pair with \`manage_jira_filter\` execute_jql to get the actual list for a grooming session.
419
417
 
420
- ### Ownership Gaps
421
- **Question:** What has no owner?
418
+ ### Unowned + Urgent (Danger Combo)
419
+ **Question:** What's high priority with no owner?
422
420
  \`\`\`json
423
- { "jql": "resolution = Unresolved AND assignee is EMPTY", "metrics": ["summary"], "groupBy": "project" }
421
+ { "jql": "resolution = Unresolved AND assignee is EMPTY AND priority in (High, Highest)", "metrics": ["summary"], "groupBy": "project" }
424
422
  \`\`\`
423
+ This should always return zero. When it doesn't, it's the most actionable finding in the entire system.
425
424
 
426
- ### Planning Coverage
427
- **Question:** How much work has no due date?
425
+ ### Blocked Issue Sweep
426
+ **Question:** What's stuck?
427
+ Use \`manage_jira_filter\` with \`execute_jql\` for this one:
428
428
  \`\`\`json
429
- { "jql": "resolution = Unresolved AND dueDate is EMPTY", "metrics": ["summary"], "groupBy": "project" }
429
+ { "operation": "execute_jql", "jql": "status = Blocked ORDER BY created ASC" }
430
430
  \`\`\`
431
+ Blocked lists tend to be small but each one is a potential cascade. Oldest first surfaces the longest-stuck items for escalation.
431
432
 
432
- ## Data Cube (Advanced)
433
+ ## Data Cube
433
434
 
434
435
  For multi-dimensional analysis, use the two-phase cube pattern:
435
436
 
436
437
  ### Phase 1: Discover dimensions
437
438
  \`\`\`json
438
- { "jql": "project in (AA, LGS, GD, GC) AND resolution = Unresolved", "metrics": ["cube_setup"] }
439
+ { "jql": "project in (...) AND resolution = Unresolved", "metrics": ["cube_setup"] }
439
440
  \`\`\`
440
- Returns available dimensions, their values, and cost estimates for each groupBy option.
441
+ Returns available dimensions, their values, cost estimates, and query budget.
441
442
 
442
443
  ### Phase 2: Execute with computed columns
443
444
  \`\`\`json
444
- { "jql": "project in (AA, LGS, GD, GC) AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project", "compute": ["bug_pct = bugs / total * 100", "net_flow = created_7d - resolved_7d", "clearing = resolved_7d > created_7d"] }
445
+ { "jql": "project in (...) AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project", "compute": ["bug_pct = bugs / total * 100", "net_flow = created_7d - resolved_7d", "clearing = resolved_7d > created_7d"] }
445
446
  \`\`\`
446
447
 
447
- ### Compute DSL
448
- - Arithmetic: \`+\`, \`-\`, \`*\`, \`/\`
449
- - Comparisons: \`>\`, \`<\`, \`>=\`, \`<=\`, \`==\`, \`!=\` (produce Yes/No)
448
+ ### Compute DSL Reference
449
+ - Arithmetic: \`+\`, \`-\`, \`*\`, \`/\` (division by zero = 0)
450
+ - Comparisons: \`>\`, \`<\`, \`>=\`, \`<=\`, \`==\`, \`!=\` (produce Yes/No — cannot be summed or averaged)
450
451
  - Standard columns: total, open, overdue, high, created_7d, resolved_7d
451
- - Implicit measures (lazily resolved): bugs, unassigned, no_due_date, blocked
452
- - Max 5 expressions per query
453
-
454
- ### Example computed columns
455
- - \`bug_pct = bugs / total * 100\` — bug ratio as percentage
456
- - \`net_flow = created_7d - resolved_7d\` — positive = growing backlog
457
- - \`clearing = resolved_7d > created_7d\` — Yes/No: is backlog shrinking?
458
- - \`risk = overdue > 10\` — Yes/No flag for at-risk projects
459
- - \`velocity = resolved_7d / 7\` — daily throughput
452
+ - Implicit measures (lazily resolved via count API): bugs, unassigned, no_due_date, no_estimate, no_start_date, no_labels, blocked
453
+ - Max 5 expressions per query, 150-query budget per execution
454
+ - Expressions evaluate linearly — later expressions can reference earlier ones
460
455
 
461
- ## Key Patterns
456
+ ## Gotchas
462
457
 
463
- - \`groupBy: "project"\` turns any query into a cross-project comparison table with exact counts
464
- - \`groupBy: "assignee"\` / \`"priority"\` / \`"issuetype"\` slice by any dimension
465
- - \`compute\` adds derived columns without extra tool calls
466
- - Two summary queries = velocity (created vs resolved over same window)
467
- - \`dueDate is EMPTY\` surfaces planning gaps that overdue queries miss
468
- - \`assignee is EMPTY AND priority in (High, Highest)\` = high-priority work with no owner (most actionable)
469
- - Use \`cube_setup\` first to discover dimensions, then \`summary\` + \`groupBy\` + \`compute\` to execute
470
- - Use \`summary\` for cross-project scope, then \`distribution\`/\`schedule\` per-project for detail
458
+ - **\`groupBy: "assignee"\` on large JQL will error.** Scope to 2-3 projects at a time.
459
+ - **Don't trust overdue in projects with high planning_gap.** If 100% of issues have no due date, overdue = 0 is meaningless, not good news.
460
+ - **Boolean computed columns (Yes/No) can't be summed or averaged.** Don't try to build a ratio on top of one.
461
+ - **\`resolved >= -7d\` is the reliable resolution window.** The Resolved 7d column in summary derives from this same window.
462
+ - **Use \`cube_setup\` first** to discover dimensions, then \`summary\` + \`groupBy\` + \`compute\` to execute.
471
463
  `;
472
464
  return {
473
465
  contents: [{
@@ -83,41 +83,33 @@ export function renderIssue(issue, transitions) {
83
83
  const lines = [];
84
84
  lines.push(`# ${issue.key}: ${issue.summary}`);
85
85
  lines.push('');
86
- // Core fields
87
- lines.push(`**Type:** ${issue.issueType} | **Status:** ${formatStatus(issue.status)}${issue.priority ? ` | **Priority:** ${issue.priority}` : ''}`);
88
- if (issue.assignee) {
89
- lines.push(`**Assignee:** ${issue.assignee}`);
90
- }
91
- else {
92
- lines.push(`**Assignee:** Unassigned`);
93
- }
94
- lines.push(`**Reporter:** ${issue.reporter}`);
95
- if (issue.parent) {
96
- lines.push(`**Parent:** ${issue.parent}`);
97
- }
98
- if (issue.labels && issue.labels.length > 0) {
99
- lines.push(`**Labels:** ${issue.labels.join(', ')}`);
100
- }
101
- // Dates
86
+ // Core fields — pipe-delimited
87
+ const core = [issue.issueType, formatStatus(issue.status)];
88
+ if (issue.priority)
89
+ core.push(issue.priority);
90
+ core.push(issue.assignee || 'Unassigned');
91
+ lines.push(core.join(' | '));
92
+ lines.push(`Reporter: ${issue.reporter}`);
93
+ if (issue.parent)
94
+ lines.push(`Parent: ${issue.parent}`);
95
+ if (issue.labels && issue.labels.length > 0)
96
+ lines.push(`Labels: ${issue.labels.join(', ')}`);
97
+ // Dates — single line
102
98
  const dates = [];
103
99
  if (issue.created)
104
100
  dates.push(`Created ${formatDate(issue.created)}`);
105
101
  if (issue.updated)
106
102
  dates.push(`Updated ${formatDate(issue.updated)}`);
107
- if (dates.length > 0)
108
- lines.push(`**${dates.join(' | ')}**`);
109
- const scheduleDates = [];
110
103
  if (issue.startDate)
111
- scheduleDates.push(`Start: ${formatDate(issue.startDate)}`);
104
+ dates.push(`Start ${formatDate(issue.startDate)}`);
112
105
  if (issue.dueDate)
113
- scheduleDates.push(`Due: ${formatDate(issue.dueDate)}`);
106
+ dates.push(`Due ${formatDate(issue.dueDate)}`);
114
107
  if (issue.resolutionDate)
115
- scheduleDates.push(`Resolved: ${formatDate(issue.resolutionDate)}`);
116
- if (scheduleDates.length > 0)
117
- lines.push(`**${scheduleDates.join(' | ')}**`);
118
- if (issue.storyPoints) {
119
- lines.push(`**Points:** ${issue.storyPoints}`);
120
- }
108
+ dates.push(`Resolved ${formatDate(issue.resolutionDate)}`);
109
+ if (dates.length > 0)
110
+ lines.push(dates.join(' | '));
111
+ if (issue.storyPoints)
112
+ lines.push(`Points: ${issue.storyPoints}`);
121
113
  if (issue.timeEstimate) {
122
114
  const hours = Math.floor(issue.timeEstimate / 3600);
123
115
  const minutes = Math.floor((issue.timeEstimate % 3600) / 60);
@@ -126,61 +118,58 @@ export function renderIssue(issue, transitions) {
126
118
  parts.push(`${hours}h`);
127
119
  if (minutes > 0)
128
120
  parts.push(`${minutes}m`);
129
- lines.push(`**Estimate:** ${parts.length > 0 ? parts.join(' ') : '0m'}`);
121
+ lines.push(`Estimate: ${parts.length > 0 ? parts.join(' ') : '0m'}`);
130
122
  }
131
- if (issue.resolution) {
132
- lines.push(`**Resolution:** ${issue.resolution}`);
133
- }
134
- // Description (truncated for token efficiency)
123
+ if (issue.resolution)
124
+ lines.push(`Resolution: ${issue.resolution}`);
125
+ // Description — full for single issue view
135
126
  if (issue.description) {
136
127
  lines.push('');
137
- lines.push('## Description');
138
- const desc = stripHtml(issue.description);
139
- lines.push(truncate(desc, 300));
128
+ lines.push('Description:');
129
+ lines.push(stripHtml(issue.description));
140
130
  }
141
131
  // Issue links
142
132
  if (issue.issueLinks && issue.issueLinks.length > 0) {
143
133
  lines.push('');
144
- lines.push('## Links');
134
+ lines.push('Links:');
145
135
  for (const link of issue.issueLinks) {
146
- if (link.outward) {
147
- lines.push(`- ${link.type} -> ${link.outward}`);
148
- }
149
- if (link.inward) {
150
- lines.push(`- ${link.type} <- ${link.inward}`);
151
- }
136
+ if (link.outward)
137
+ lines.push(`${link.type} -> ${link.outward}`);
138
+ if (link.inward)
139
+ lines.push(`${link.type} <- ${link.inward}`);
152
140
  }
153
141
  }
154
142
  // Comments (if present)
155
143
  if (issue.comments && issue.comments.length > 0) {
156
144
  lines.push('');
157
- lines.push(`## Comments (${issue.comments.length})`);
158
- // Show last 3 comments
159
- const recentComments = issue.comments.slice(-3);
160
- for (const comment of recentComments) {
161
- lines.push(`- **${comment.author}** (${formatDate(comment.created)}): ${truncate(stripHtml(comment.body), 100)}`);
145
+ lines.push(`Comments (${issue.comments.length}):`);
146
+ const recentComments = issue.comments.slice(-5);
147
+ const startIdx = issue.comments.length - recentComments.length + 1;
148
+ if (issue.comments.length > 5) {
149
+ lines.push(` +${issue.comments.length - 5} older comments`);
162
150
  }
163
- if (issue.comments.length > 3) {
164
- lines.push(` ... and ${issue.comments.length - 3} more comments`);
151
+ for (let i = 0; i < recentComments.length; i++) {
152
+ const comment = recentComments[i];
153
+ lines.push(`[${startIdx + i}/${issue.comments.length}] ${comment.author} (${formatDate(comment.created)}): ${stripHtml(comment.body)}`);
165
154
  }
166
155
  }
167
156
  // Custom fields (from catalog discovery)
168
157
  if (issue.customFieldValues && issue.customFieldValues.length > 0) {
169
158
  lines.push('');
170
- lines.push('## Custom Fields');
159
+ lines.push('Custom Fields:');
171
160
  for (const cf of issue.customFieldValues) {
172
161
  const displayValue = Array.isArray(cf.value)
173
162
  ? cf.value.join(', ')
174
163
  : String(cf.value);
175
- lines.push(`- **${cf.name}** (${cf.type}): ${displayValue}`);
164
+ lines.push(`${cf.name} (${cf.type}): ${displayValue}`);
176
165
  }
177
166
  }
178
167
  // Available transitions
179
168
  if (transitions && transitions.length > 0) {
180
169
  lines.push('');
181
- lines.push('## Available Actions');
170
+ lines.push('Actions:');
182
171
  for (const t of transitions) {
183
- lines.push(`- **${t.name}** -> ${t.to.name} (id: ${t.id})`);
172
+ lines.push(`${t.name} -> ${t.to.name} (id: ${t.id})`);
184
173
  }
185
174
  }
186
175
  return lines.join('\n');
@@ -214,26 +203,18 @@ export function renderIssueSearchResults(issues, pagination, jql) {
214
203
  // Issues list
215
204
  for (let i = 0; i < issues.length; i++) {
216
205
  const issue = issues[i];
217
- const num = pagination.startAt + i + 1;
218
- lines.push(`## ${num}. ${issue.key}: ${issue.summary}`);
219
- const meta = [`${formatStatus(issue.status)}`, issue.assignee || 'Unassigned'];
206
+ const meta = [issue.key, issue.summary, formatStatus(issue.status), issue.assignee || 'Unassigned'];
220
207
  if (issue.priority)
221
208
  meta.push(issue.priority);
222
- lines.push(meta.join(' | '));
223
- const searchDates = [];
224
209
  if (issue.dueDate)
225
- searchDates.push(`Due: ${formatDate(issue.dueDate)}`);
226
- if (issue.startDate)
227
- searchDates.push(`Start: ${formatDate(issue.startDate)}`);
228
- if (searchDates.length > 0)
229
- lines.push(searchDates.join(' | '));
210
+ meta.push(`due ${formatDate(issue.dueDate)}`);
211
+ lines.push(meta.join(' | '));
230
212
  if (issue.description) {
231
213
  const desc = stripHtml(issue.description);
232
214
  if (desc.length > 0) {
233
- lines.push(`> ${truncate(desc, 120)}`);
215
+ lines.push(` ${truncate(desc, 120)}`);
234
216
  }
235
217
  }
236
- lines.push('');
237
218
  }
238
219
  // Pagination guidance
239
220
  lines.push('---');
@@ -297,19 +278,16 @@ export function renderProjectList(projects) {
297
278
  lines.push(`# Projects (${projects.length})`);
298
279
  lines.push('');
299
280
  for (const project of projects) {
300
- lines.push(`## ${project.key}: ${project.name}`);
301
- if (project.lead) {
302
- lines.push(`Lead: ${project.lead}`);
303
- }
304
- if (project.description) {
305
- lines.push(`> ${truncate(stripHtml(project.description), 100)}`);
306
- }
281
+ const parts = [project.key, project.name];
282
+ if (project.lead)
283
+ parts.push(project.lead);
307
284
  if (project.statusCounts) {
308
285
  const total = Object.values(project.statusCounts).reduce((a, b) => a + b, 0);
309
- lines.push(`Issues: ${total}`);
286
+ parts.push(`${total} issues`);
310
287
  }
311
- lines.push('');
288
+ lines.push(parts.join(' | '));
312
289
  }
290
+ lines.push('');
313
291
  lines.push('---');
314
292
  lines.push(`Tip: Use manage_jira_project with operation="get" and projectKey="KEY" for details`);
315
293
  return lines.join('\n');
@@ -348,9 +326,9 @@ export function renderBoardList(boards) {
348
326
  byType[type].push(board);
349
327
  }
350
328
  for (const [type, typeBoards] of Object.entries(byType)) {
351
- lines.push(`## ${type.charAt(0).toUpperCase() + type.slice(1)} Boards`);
329
+ lines.push(`${type.charAt(0).toUpperCase() + type.slice(1)} Boards:`);
352
330
  for (const board of typeBoards) {
353
- lines.push(`- **${board.name}** (id: ${board.id})${board.projectKey ? ` - ${board.projectKey}` : ''}`);
331
+ lines.push(`${board.name} | id: ${board.id}${board.projectKey ? ` | ${board.projectKey}` : ''}`);
354
332
  }
355
333
  lines.push('');
356
334
  }
@@ -378,7 +356,7 @@ export function renderSprint(sprint) {
378
356
  }
379
357
  if (sprint.issues && sprint.issues.length > 0) {
380
358
  lines.push('');
381
- lines.push(`## Issues (${sprint.issues.length})`);
359
+ lines.push(`Issues (${sprint.issues.length}):`);
382
360
  // Group by status
383
361
  const byStatus = {};
384
362
  for (const issue of sprint.issues) {
@@ -388,9 +366,9 @@ export function renderSprint(sprint) {
388
366
  byStatus[status].push(issue);
389
367
  }
390
368
  for (const [status, statusIssues] of Object.entries(byStatus)) {
391
- lines.push(`### ${status} (${statusIssues.length})`);
369
+ lines.push(`${status} (${statusIssues.length}):`);
392
370
  for (const issue of statusIssues) {
393
- lines.push(`- ${issue.key}: ${issue.summary}${issue.assignee ? ` [${issue.assignee}]` : ''}`);
371
+ lines.push(`${issue.key} | ${issue.summary}${issue.assignee ? ` | ${issue.assignee}` : ''}`);
394
372
  }
395
373
  }
396
374
  }
@@ -431,16 +409,16 @@ export function renderFilterList(filters) {
431
409
  const favorites = filters.filter(f => f.favourite);
432
410
  const others = filters.filter(f => !f.favourite);
433
411
  if (favorites.length > 0) {
434
- lines.push('## Favorites');
412
+ lines.push('Favorites:');
435
413
  for (const filter of favorites) {
436
- lines.push(`- **${filter.name}** (id: ${filter.id}) - ${filter.owner}`);
414
+ lines.push(`${filter.name} | id: ${filter.id} | ${filter.owner}`);
437
415
  }
438
416
  lines.push('');
439
417
  }
440
418
  if (others.length > 0) {
441
- lines.push('## Other Filters');
419
+ lines.push('Other Filters:');
442
420
  for (const filter of others) {
443
- lines.push(`- ${filter.name} (id: ${filter.id}) - ${filter.owner}`);
421
+ lines.push(`${filter.name} | id: ${filter.id} | ${filter.owner}`);
444
422
  }
445
423
  }
446
424
  return lines.join('\n');
@@ -350,7 +350,7 @@ export const toolSchemas = {
350
350
  compute: {
351
351
  type: 'array',
352
352
  items: { type: 'string' },
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, blocked. Max 5 expressions. Example: ["bug_pct = bugs / total * 100", "clearing = resolved_7d > created_7d"].',
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. Max 5 expressions. Example: ["bug_pct = bugs / total * 100", "planning_gap = no_estimate / open * 100"].',
354
354
  maxItems: 5,
355
355
  },
356
356
  maxResults: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
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",