@aaronsb/jira-cloud-mcp 0.5.3 → 0.5.5

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.
@@ -84,6 +84,7 @@ export class JiraClient {
84
84
  'created',
85
85
  'updated',
86
86
  'resolutiondate',
87
+ 'statuscategorychangedate',
87
88
  'duedate',
88
89
  this.customFields.startDate,
89
90
  this.customFields.storyPoints,
@@ -94,10 +95,20 @@ export class JiraClient {
94
95
  /** Maps a raw Jira API issue to our JiraIssueDetails shape */
95
96
  mapIssueFields(issue) {
96
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
+ }
97
106
  return {
98
107
  key: issue.key,
99
108
  summary: fields?.summary,
100
- description: issue.renderedFields?.description || '',
109
+ description: fields?.description
110
+ ? TextProcessor.adfToMarkdown(fields.description)
111
+ : '',
101
112
  issueType: fields?.issuetype?.name || '',
102
113
  priority: fields?.priority?.name || null,
103
114
  parent: fields?.parent?.key || null,
@@ -110,6 +121,7 @@ export class JiraClient {
110
121
  created: fields?.created || '',
111
122
  updated: fields?.updated || '',
112
123
  resolutionDate: fields?.resolutiondate || null,
124
+ statusCategoryChanged: fields?.statuscategorychangedate ?? fields?.statuscategorychangeddate ?? null,
113
125
  dueDate: fields?.duedate || null,
114
126
  startDate: fields?.[this.customFields.startDate] || null,
115
127
  storyPoints: fields?.[this.customFields.storyPoints] ?? null,
@@ -119,6 +131,7 @@ export class JiraClient {
119
131
  outward: link.outwardIssue?.key || null,
120
132
  inward: link.inwardIssue?.key || null,
121
133
  })),
134
+ people: people.length > 0 ? people : undefined,
122
135
  };
123
136
  }
124
137
  async getIssue(issueKey, includeComments = false, includeAttachments = false, customFieldMeta) {
@@ -137,7 +150,7 @@ export class JiraClient {
137
150
  const params = {
138
151
  issueIdOrKey: issueKey,
139
152
  fields,
140
- expand: includeComments ? 'renderedFields,comments' : 'renderedFields'
153
+ expand: includeComments ? 'comments' : undefined
141
154
  };
142
155
  const issue = await this.client.issues.getIssue(params);
143
156
  const issueDetails = this.mapIssueFields(issue);
@@ -169,9 +182,24 @@ export class JiraClient {
169
182
  .map(comment => ({
170
183
  id: comment.id,
171
184
  author: comment.author.displayName,
172
- body: comment.body?.content ? TextProcessor.extractTextFromAdf(comment.body) : String(comment.body),
185
+ body: comment.body?.content ? TextProcessor.adfToMarkdown(comment.body) : String(comment.body),
173
186
  created: comment.created,
174
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
+ }
175
203
  }
176
204
  if (includeAttachments && issue.fields.attachment) {
177
205
  issueDetails.attachments = issue.fields.attachment
@@ -373,7 +401,6 @@ export class JiraClient {
373
401
  const searchResults = await this.client.issueSearch.searchForIssuesUsingJql({
374
402
  jql: filter.jql,
375
403
  fields: this.issueFields,
376
- expand: 'renderedFields'
377
404
  });
378
405
  return (searchResults.issues || []).map(issue => this.mapIssueFields(issue));
379
406
  }
@@ -431,7 +458,7 @@ export class JiraClient {
431
458
  const leanFields = [
432
459
  'summary', 'issuetype', 'priority', 'assignee', 'reporter',
433
460
  'status', 'resolution', 'labels', 'created', 'updated',
434
- 'resolutiondate', 'duedate', 'timeestimate',
461
+ 'resolutiondate', 'statuscategorychangedate', 'duedate', 'timeestimate',
435
462
  this.customFields.startDate, this.customFields.storyPoints,
436
463
  ];
437
464
  const params = {
@@ -475,7 +502,6 @@ export class JiraClient {
475
502
  jql: cleanJql,
476
503
  maxResults: Math.min(maxResults, 100),
477
504
  fields: this.issueFields,
478
- expand: 'renderedFields',
479
505
  });
480
506
  const issues = (searchResults.issues || []).map(issue => this.mapIssueFields(issue));
481
507
  // Note: Enhanced search API uses token-based pagination, not offset-based
@@ -576,7 +602,7 @@ export class JiraClient {
576
602
  async getPopulatedFields(issueKey) {
577
603
  const issue = await this.client.issues.getIssue({
578
604
  issueIdOrKey: issueKey,
579
- expand: 'renderedFields,names',
605
+ expand: 'names',
580
606
  });
581
607
  const fieldNames = issue.names || {};
582
608
  const fields = issue.fields;
@@ -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, no_estimate, no_start_date, no_labels, 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, stale, stale_status, backlog_rot.",
487
487
  },
488
488
  maxResults: {
489
489
  type: "integer",
@@ -210,6 +210,48 @@ export function renderCycle(issues, now) {
210
210
  .slice(0, 5);
211
211
  const oldestStr = oldest.map(o => `${o.key} (${o.age}d)`).join(', ');
212
212
  lines.push(`**Oldest open:** ${oldestStr}`);
213
+ // Staleness — how long since last update
214
+ const staleness = open.map(i => ({
215
+ key: i.key,
216
+ days: daysBetween(parseDate(i.updated), now),
217
+ }));
218
+ const buckets = { fresh: 0, aging: 0, stale: 0, abandoned: 0 };
219
+ for (const s of staleness) {
220
+ if (s.days < 7)
221
+ buckets.fresh++;
222
+ else if (s.days < 30)
223
+ buckets.aging++;
224
+ else if (s.days < 90)
225
+ buckets.stale++;
226
+ else
227
+ buckets.abandoned++;
228
+ }
229
+ lines.push(`**Staleness:** <7d: ${buckets.fresh} | 7-30d: ${buckets.aging} | 30-90d: ${buckets.stale} | 90d+: ${buckets.abandoned}`);
230
+ // Most stale open issues
231
+ const mostStale = staleness
232
+ .sort((a, b) => b.days - a.days)
233
+ .slice(0, 5);
234
+ if (mostStale.length > 0 && mostStale[0].days >= 30) {
235
+ const staleStr = mostStale.map(s => `${s.key} (${s.days}d)`).join(', ');
236
+ lines.push(`**Most stale:** ${staleStr}`);
237
+ }
238
+ // Status age — how long in current status
239
+ const withStatusAge = open.filter(i => i.statusCategoryChanged);
240
+ if (withStatusAge.length > 0) {
241
+ const statusAges = withStatusAge.map(i => daysBetween(parseDate(i.statusCategoryChanged), now));
242
+ const med = median(statusAges);
243
+ const avg = mean(statusAges);
244
+ lines.push(`**Status age:** median ${med.toFixed(1)} days, mean ${avg.toFixed(1)} days in current status (${withStatusAge.length} issues)`);
245
+ const stuck = withStatusAge
246
+ .map(i => ({ key: i.key, status: i.status, days: daysBetween(parseDate(i.statusCategoryChanged), now) }))
247
+ .filter(s => s.days >= 30)
248
+ .sort((a, b) => b.days - a.days)
249
+ .slice(0, 5);
250
+ if (stuck.length > 0) {
251
+ const stuckStr = stuck.map(s => `${s.key} ${s.status} (${s.days}d)`).join(', ');
252
+ lines.push(`**Stuck:** ${stuckStr}`);
253
+ }
254
+ }
213
255
  }
214
256
  return lines.join('\n');
215
257
  }
@@ -271,6 +313,9 @@ function buildImplicitMeasures(customFieldIds) {
271
313
  no_due_date: 'dueDate is EMPTY AND resolution = Unresolved',
272
314
  blocked: 'status = Blocked',
273
315
  no_labels: 'labels is EMPTY AND resolution = Unresolved',
316
+ stale: 'resolution = Unresolved AND updated <= -60d',
317
+ stale_status: 'resolution = Unresolved AND statusCategoryChangedDate <= -30d',
318
+ backlog_rot: 'resolution = Unresolved AND dueDate is EMPTY AND assignee is EMPTY AND updated <= -60d',
274
319
  };
275
320
  if (customFieldIds) {
276
321
  measures.no_estimate = `${customFieldIds.storyPoints} is EMPTY AND resolution = Unresolved`;
@@ -471,7 +516,7 @@ export function renderCubeSetup(jql, sampleSize, dimensions) {
471
516
  lines.push('- total, open, overdue, high+, created_7d, resolved_7d');
472
517
  lines.push('');
473
518
  lines.push('Implicit measures (lazily resolved if referenced in `compute`):');
474
- lines.push('- bugs, unassigned, no_due_date, no_estimate, no_start_date, no_labels, blocked');
519
+ lines.push('- bugs, unassigned, no_due_date, no_estimate, no_start_date, no_labels, blocked, stale, stale_status, backlog_rot');
475
520
  // Suggested cubes with cost estimates
476
521
  lines.push('');
477
522
  lines.push(`## Suggested Cubes (budget: ${MAX_COUNT_QUERIES} queries)`);
@@ -639,7 +684,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
639
684
  }
640
685
  }
641
686
  // Next steps
642
- const nextSteps = analysisNextSteps(jql, allIssues.slice(0, 3).map(i => i.key));
687
+ const nextSteps = analysisNextSteps(jql, allIssues.slice(0, 3).map(i => i.key), truncated);
643
688
  lines.push(nextSteps);
644
689
  return {
645
690
  content: [{
@@ -430,6 +430,13 @@ Use \`manage_jira_filter\` with \`execute_jql\` for this one:
430
430
  \`\`\`
431
431
  Blocked lists tend to be small but each one is a potential cascade. Oldest first surfaces the longest-stuck items for escalation.
432
432
 
433
+ ### Data Quality / Backlog Rot
434
+ **Question:** How much of this backlog is noise?
435
+ \`\`\`json
436
+ { "jql": "project in (...) AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project", "compute": ["stale_pct = stale / open * 100", "rot_pct = backlog_rot / open * 100"] }
437
+ \`\`\`
438
+ \`stale\` = untouched 60+ days. \`backlog_rot\` = undated + unassigned + untouched 60+ days. High rot_pct means overdue counts are misleading — the backlog is full of phantom work.
439
+
433
440
  ## Data Cube
434
441
 
435
442
  For multi-dimensional analysis, use the two-phase cube pattern:
@@ -449,7 +456,7 @@ Returns available dimensions, their values, cost estimates, and query budget.
449
456
  - Arithmetic: \`+\`, \`-\`, \`*\`, \`/\` (division by zero = 0)
450
457
  - Comparisons: \`>\`, \`<\`, \`>=\`, \`<=\`, \`==\`, \`!=\` (produce Yes/No — cannot be summed or averaged)
451
458
  - Standard columns: total, open, overdue, high, created_7d, resolved_7d
452
- - Implicit measures (lazily resolved via count API): bugs, unassigned, no_due_date, no_estimate, no_start_date, no_labels, blocked
459
+ - Implicit measures (lazily resolved via count API): 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+)
453
460
  - Max 5 expressions per query, 150-query budget per execution
454
461
  - Expressions evaluate linearly — later expressions can reference earlier ones
455
462
 
package/build/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import { createRequire } from 'module';
3
3
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
4
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
- import { CallToolRequestSchema, ErrorCode, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, McpError, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
+ import { CallToolRequestSchema, ErrorCode, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema, McpError, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
6
6
  import { fieldDiscovery } from './client/field-discovery.js';
7
7
  import { JiraClient } from './client/jira-client.js';
8
8
  import { handleAnalysisRequest } from './handlers/analysis-handler.js';
@@ -13,6 +13,8 @@ import { handleProjectRequest } from './handlers/project-handlers.js';
13
13
  import { createQueueHandler } from './handlers/queue-handler.js';
14
14
  import { setupResourceHandlers } from './handlers/resource-handlers.js';
15
15
  import { handleSprintRequest } from './handlers/sprint-handlers.js';
16
+ import { promptDefinitions } from './prompts/prompt-definitions.js';
17
+ import { getPrompt } from './prompts/prompt-messages.js';
16
18
  import { toolSchemas } from './schemas/tool-schemas.js';
17
19
  // Jira credentials from environment variables
18
20
  const JIRA_EMAIL = process.env.JIRA_EMAIL;
@@ -43,6 +45,7 @@ class JiraServer {
43
45
  capabilities: {
44
46
  tools: {},
45
47
  resources: {},
48
+ prompts: {},
46
49
  },
47
50
  });
48
51
  this.jiraClient = new JiraClient({
@@ -80,6 +83,18 @@ class JiraServer {
80
83
  this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
81
84
  return resourceHandlers.readResource(request.params.uri);
82
85
  });
86
+ // Set up prompt handlers
87
+ this.server.setRequestHandler(ListPromptsRequestSchema, async () => ({
88
+ prompts: promptDefinitions.map(p => ({
89
+ name: p.name,
90
+ description: p.description,
91
+ arguments: p.arguments,
92
+ })),
93
+ }));
94
+ this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
95
+ const { name, arguments: args } = request.params;
96
+ return getPrompt(name, args);
97
+ });
83
98
  // Set up tool handlers
84
99
  this.server.setRequestHandler(CallToolRequestSchema, async (request, _extra) => {
85
100
  console.error('Received request:', JSON.stringify(request, null, 2));
@@ -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
  }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * MCP Prompt definitions for Jira analysis workflows.
3
+ * Prompts are user-controlled templates surfaced by clients as slash commands or menu items.
4
+ */
5
+ export const promptDefinitions = [
6
+ {
7
+ name: 'backlog_health',
8
+ description: 'Run a data quality health check on a project backlog — surfaces rot, staleness, and planning gaps',
9
+ arguments: [
10
+ { name: 'project', description: 'Jira project key (e.g. PROJ, ENG)', required: true },
11
+ ],
12
+ },
13
+ {
14
+ name: 'contributor_workload',
15
+ description: 'Per-contributor workload breakdown with staleness and risk — scopes detail queries to fit within sample cap',
16
+ arguments: [
17
+ { name: 'project', description: 'Jira project key (e.g. PROJ, ENG)', required: true },
18
+ ],
19
+ },
20
+ {
21
+ name: 'sprint_review',
22
+ description: 'Sprint review preparation — velocity, scope changes, at-risk items, and completion forecast',
23
+ arguments: [
24
+ { name: 'board_id', description: 'Jira board ID (find via manage_jira_board list)', required: true },
25
+ ],
26
+ },
27
+ {
28
+ name: 'narrow_analysis',
29
+ description: 'Refine a capped analysis query — guides you to narrow JQL for precise detail metrics',
30
+ arguments: [
31
+ { name: 'jql', description: 'The JQL query to refine (from a previous capped analysis)', required: true },
32
+ ],
33
+ },
34
+ ];
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Builds PromptMessage arrays for each prompt, substituting user-provided arguments.
3
+ */
4
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
5
+ import { promptDefinitions } from './prompt-definitions.js';
6
+ function msg(text) {
7
+ return { role: 'user', content: { type: 'text', text } };
8
+ }
9
+ const builders = {
10
+ backlog_health({ project }) {
11
+ return {
12
+ description: `Backlog health check for ${project}`,
13
+ messages: [msg(`Analyze backlog health for project ${project}. Use the analyze_jira_issues and manage_jira_filter tools.
14
+
15
+ Step 1 — Summary with data quality signals:
16
+ {"jql":"project = ${project} AND resolution = Unresolved","metrics":["summary"],"groupBy":"priority","compute":["rot_pct = backlog_rot / open * 100","stale_pct = stale / open * 100","gap_pct = no_estimate / open * 100"]}
17
+
18
+ Step 2 — Cycle metrics for staleness distribution and status age:
19
+ {"jql":"project = ${project} AND resolution = Unresolved","metrics":["cycle"],"maxResults":100}
20
+
21
+ Step 3 — Summarize findings:
22
+ - What percentage of the backlog is rotting (no owner, no dates, untouched)?
23
+ - What's stuck in the same status for 30+ days?
24
+ - What's missing estimates or start dates?
25
+ - Flag the worst offenders by issue key.
26
+ - Recommend specific triage actions.`)],
27
+ };
28
+ },
29
+ contributor_workload({ project }) {
30
+ return {
31
+ description: `Contributor workload for ${project}`,
32
+ messages: [msg(`Analyze contributor workload for project ${project}. Use the analyze_jira_issues tool.
33
+
34
+ Step 1 — Assignee distribution with quality signals:
35
+ {"jql":"project = ${project} AND resolution = Unresolved","metrics":["summary"],"groupBy":"assignee","compute":["stale_pct = stale / open * 100","blocked_pct = blocked / open * 100"]}
36
+
37
+ Step 2 — For the top 3 assignees by open issue count, run scoped detail metrics:
38
+ {"jql":"project = ${project} AND resolution = Unresolved AND assignee = '{name}'","metrics":["cycle","schedule"]}
39
+
40
+ This pattern keeps each detail query within the sample cap for precise results.
41
+
42
+ Step 3 — Summarize:
43
+ - Who has the most open work?
44
+ - Who has the most stale or at-risk issues?
45
+ - Are there load imbalances?
46
+ - What needs reassignment or triage?`)],
47
+ };
48
+ },
49
+ sprint_review({ board_id }) {
50
+ return {
51
+ description: `Sprint review prep for board ${board_id}`,
52
+ messages: [msg(`Prepare a sprint review for board ${board_id}. Use manage_jira_sprint and analyze_jira_issues tools.
53
+
54
+ Step 1 — Find the active sprint:
55
+ {"operation":"list","boardId":${board_id},"state":"active"}
56
+
57
+ Step 2 — Analyze sprint issues (use the sprint ID from step 1):
58
+ {"jql":"sprint = {sprintId}","metrics":["summary","points","schedule"],"compute":["done_pct = resolved_7d / total * 100"]}
59
+
60
+ Step 3 — Summarize:
61
+ - Current velocity vs planned
62
+ - Scope changes (items added/removed mid-sprint)
63
+ - At-risk items (overdue, blocked, stale)
64
+ - Completion forecast — will the sprint goal be met?`)],
65
+ };
66
+ },
67
+ narrow_analysis({ jql }) {
68
+ return {
69
+ description: 'Refine a capped analysis query',
70
+ messages: [msg(`The previous analysis was sampled — detail metrics didn't cover all matching issues.
71
+
72
+ Original query: ${jql}
73
+
74
+ To get precise results, help me narrow the query. Here are useful approaches:
75
+
76
+ By assignee (each person's list usually fits within the cap):
77
+ {"jql":"${jql} AND assignee = currentUser()","metrics":["cycle","schedule"]}
78
+
79
+ By priority (focus on what matters):
80
+ {"jql":"${jql} AND priority in (High, Highest)","metrics":["cycle","schedule"]}
81
+
82
+ By issue type:
83
+ {"jql":"${jql} AND issuetype = Bug","metrics":["cycle"]}
84
+
85
+ By recency:
86
+ {"jql":"${jql} AND created >= -30d","metrics":["cycle"]}
87
+
88
+ Or use summary metrics for the full population (count API, no cap):
89
+ {"jql":"${jql}","metrics":["summary"],"groupBy":"assignee","compute":["stale_pct = stale / open * 100"]}
90
+
91
+ Ask me which dimension I'd like to drill into, or suggest the most useful one based on the original query.`)],
92
+ };
93
+ },
94
+ };
95
+ export function getPrompt(name, args) {
96
+ const def = promptDefinitions.find(p => p.name === name);
97
+ if (!def) {
98
+ throw new McpError(ErrorCode.InvalidParams, `Unknown prompt: ${name}`);
99
+ }
100
+ const resolvedArgs = args ?? {};
101
+ for (const arg of def.arguments) {
102
+ if (arg.required && !resolvedArgs[arg.name]) {
103
+ throw new McpError(ErrorCode.InvalidParams, `Missing required argument: ${arg.name}`);
104
+ }
105
+ }
106
+ const builder = builders[name];
107
+ if (!builder) {
108
+ throw new Error(`No message builder for prompt: ${name}`);
109
+ }
110
+ return builder(resolvedArgs);
111
+ }
@@ -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, no_estimate, no_start_date, no_labels, blocked. Max 5 expressions. Example: ["bug_pct = bugs / total * 100", "planning_gap = no_estimate / open * 100"].',
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
356
  maxResults: {
@@ -125,11 +125,14 @@ export function boardNextSteps(operation, boardId) {
125
125
  }
126
126
  return steps.length > 0 ? formatSteps(steps) : '';
127
127
  }
128
- export function analysisNextSteps(jql, issueKeys) {
128
+ export function analysisNextSteps(jql, issueKeys, truncated = false) {
129
129
  const steps = [];
130
130
  if (issueKeys.length > 0) {
131
131
  steps.push({ description: 'Get details on a specific issue', tool: 'manage_jira_issue', example: { operation: 'get', issueKey: issueKeys[0] } });
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
+ 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'] } });
136
+ }
134
137
  return formatSteps(steps) + '\n- Read `jira://analysis/recipes` for data cube patterns and compute DSL examples';
135
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.3",
3
+ "version": "0.5.5",
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",