@aaronsb/jira-cloud-mcp 0.5.4 → 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.
@@ -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;
@@ -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
  }
@@ -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.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",