@aaronsb/jira-cloud-mcp 0.2.1 → 0.2.4

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
@@ -17,7 +17,7 @@ Add to your MCP settings:
17
17
  "env": {
18
18
  "JIRA_API_TOKEN": "your-api-token",
19
19
  "JIRA_EMAIL": "your-email",
20
- "JIRA_HOST": "your-team.atlassian.net"
20
+ "JIRA_HOST": "https://your-team.atlassian.net"
21
21
  }
22
22
  }
23
23
  }
@@ -165,18 +165,29 @@ export class JiraClient {
165
165
  })),
166
166
  }));
167
167
  }
168
- async updateIssue(issueKey, summary, description, parentKey) {
168
+ async updateIssue(params) {
169
169
  const fields = {};
170
- if (summary)
171
- fields.summary = summary;
172
- if (description) {
173
- fields.description = TextProcessor.markdownToAdf(description);
170
+ if (params.summary)
171
+ fields.summary = params.summary;
172
+ if (params.description) {
173
+ fields.description = TextProcessor.markdownToAdf(params.description);
174
+ }
175
+ if (params.parentKey !== undefined) {
176
+ fields.parent = params.parentKey ? { key: params.parentKey } : null;
174
177
  }
175
- if (parentKey !== undefined) {
176
- fields.parent = parentKey ? { key: parentKey } : null;
178
+ if (params.assignee !== undefined) {
179
+ // null unassigns, string assigns by account ID or name
180
+ fields.assignee = params.assignee ? { id: params.assignee } : null;
181
+ }
182
+ if (params.priority)
183
+ fields.priority = { id: params.priority };
184
+ if (params.labels)
185
+ fields.labels = params.labels;
186
+ if (params.customFields) {
187
+ Object.assign(fields, params.customFields);
177
188
  }
178
189
  await this.client.issues.editIssue({
179
- issueIdOrKey: issueKey,
190
+ issueIdOrKey: params.issueKey,
180
191
  fields,
181
192
  });
182
193
  }
@@ -191,9 +202,9 @@ export class JiraClient {
191
202
  // Remove escaped quotes from JQL
192
203
  const cleanJql = jql.replace(/\\"/g, '"');
193
204
  console.error(`Executing JQL search with query: ${cleanJql}`);
194
- const searchResults = await this.client.issueSearch.searchForIssuesUsingJql({
205
+ // Use the new enhanced search API (old /rest/api/3/search was deprecated Oct 2025)
206
+ const searchResults = await this.client.issueSearch.searchForIssuesUsingJqlEnhancedSearch({
195
207
  jql: cleanJql,
196
- startAt,
197
208
  maxResults: Math.min(maxResults, 100),
198
209
  fields: [
199
210
  'summary',
@@ -203,39 +214,43 @@ export class JiraClient {
203
214
  'status',
204
215
  'resolution',
205
216
  'duedate',
217
+ 'parent',
206
218
  this.customFields.startDate,
207
219
  this.customFields.storyPoints,
208
220
  'timeestimate',
209
221
  'issuelinks',
210
222
  ],
211
- expand: 'renderedFields'
223
+ expand: 'renderedFields',
212
224
  });
213
225
  const issues = (searchResults.issues || []).map(issue => ({
214
226
  key: issue.key,
215
- summary: issue.fields.summary,
227
+ summary: issue.fields?.summary,
216
228
  description: issue.renderedFields?.description || '',
217
- parent: issue.fields.parent?.key || null,
218
- assignee: issue.fields.assignee?.displayName || null,
219
- reporter: issue.fields.reporter?.displayName || '',
220
- status: issue.fields.status?.name || '',
221
- resolution: issue.fields.resolution?.name || null,
222
- dueDate: issue.fields.duedate || null,
223
- startDate: issue.fields[this.customFields.startDate] || null,
224
- storyPoints: issue.fields[this.customFields.storyPoints] || null,
225
- timeEstimate: issue.fields.timeestimate || null,
226
- issueLinks: (issue.fields.issuelinks || []).map(link => ({
229
+ parent: issue.fields?.parent?.key || null,
230
+ assignee: issue.fields?.assignee?.displayName || null,
231
+ reporter: issue.fields?.reporter?.displayName || '',
232
+ status: issue.fields?.status?.name || '',
233
+ resolution: issue.fields?.resolution?.name || null,
234
+ dueDate: issue.fields?.duedate || null,
235
+ startDate: issue.fields?.[this.customFields.startDate] || null,
236
+ storyPoints: issue.fields?.[this.customFields.storyPoints] || null,
237
+ timeEstimate: issue.fields?.timeestimate || null,
238
+ issueLinks: (issue.fields?.issuelinks || []).map(link => ({
227
239
  type: link.type?.name || '',
228
240
  outward: link.outwardIssue?.key || null,
229
241
  inward: link.inwardIssue?.key || null,
230
242
  })),
231
243
  }));
244
+ // Note: Enhanced search API uses token-based pagination, not offset-based
245
+ // The total count is not available in the new API
246
+ const hasMore = !!searchResults.nextPageToken;
232
247
  return {
233
248
  issues,
234
249
  pagination: {
235
250
  startAt,
236
251
  maxResults,
237
- total: searchResults.total || 0,
238
- hasMore: (startAt + issues.length) < (searchResults.total || 0)
252
+ total: hasMore ? issues.length + 1 : issues.length, // Approximate since total not available
253
+ hasMore,
239
254
  }
240
255
  };
241
256
  }
@@ -1,5 +1,5 @@
1
1
  import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
- import { BoardFormatter } from '../utils/formatters/index.js';
2
+ import { MarkdownRenderer } from '../mcp/markdown-renderer.js';
3
3
  // Helper function to normalize parameter names (support both snake_case and camelCase)
4
4
  function normalizeArgs(args) {
5
5
  const normalized = {};
@@ -108,7 +108,10 @@ async function handleGetBoard(jiraClient, args) {
108
108
  }
109
109
  // Convert to BoardData format
110
110
  const boardData = {
111
- ...board
111
+ id: board.id,
112
+ name: board.name,
113
+ type: board.type,
114
+ projectName: board.location?.projectName,
112
115
  };
113
116
  // Handle expansions
114
117
  if (expansionOptions.sprints) {
@@ -123,13 +126,24 @@ async function handleGetBoard(jiraClient, args) {
123
126
  // Continue even if sprints fail
124
127
  }
125
128
  }
126
- // Format the response
127
- const formattedResponse = BoardFormatter.formatBoard(boardData, expansionOptions);
129
+ // Render to markdown
130
+ const markdown = MarkdownRenderer.renderBoard({
131
+ id: boardData.id,
132
+ name: boardData.name,
133
+ type: boardData.type,
134
+ projectName: boardData.projectName,
135
+ sprints: boardData.sprints?.map((s) => ({
136
+ id: s.id,
137
+ name: s.name,
138
+ state: s.state,
139
+ goal: s.goal,
140
+ })),
141
+ });
128
142
  return {
129
143
  content: [
130
144
  {
131
145
  type: 'text',
132
- text: JSON.stringify(formattedResponse, null, 2),
146
+ text: markdown,
133
147
  },
134
148
  ],
135
149
  };
@@ -145,7 +159,10 @@ async function handleListBoards(jiraClient, args) {
145
159
  const paginatedBoards = boards.slice(startAt, startAt + maxResults);
146
160
  // Convert to BoardData format
147
161
  const boardDataList = paginatedBoards.map(board => ({
148
- ...board
162
+ id: board.id,
163
+ name: board.name,
164
+ type: board.type,
165
+ projectName: board.location?.projectName,
149
166
  }));
150
167
  // If sprints are requested, get them for each board
151
168
  if (includeSprints) {
@@ -163,25 +180,35 @@ async function handleListBoards(jiraClient, args) {
163
180
  }
164
181
  }
165
182
  }
166
- // Format the response
167
- const formattedBoards = boardDataList.map(board => BoardFormatter.formatBoard(board, { sprints: includeSprints }));
168
- // Create a response with pagination metadata
169
- const response = {
170
- data: formattedBoards,
171
- _metadata: {
172
- pagination: {
173
- startAt,
174
- maxResults,
175
- total: boards.length,
176
- hasMore: startAt + maxResults < boards.length,
177
- },
178
- },
179
- };
183
+ // Convert to markdown renderer format
184
+ const rendererBoards = boardDataList.map(board => ({
185
+ id: board.id,
186
+ name: board.name,
187
+ type: board.type,
188
+ projectName: board.projectName,
189
+ sprints: board.sprints?.map((s) => ({
190
+ id: s.id,
191
+ name: s.name,
192
+ state: s.state,
193
+ goal: s.goal,
194
+ })),
195
+ }));
196
+ // Render to markdown with pagination
197
+ let markdown = MarkdownRenderer.renderBoardList(rendererBoards);
198
+ // Add pagination guidance
199
+ markdown += '\n\n---\n';
200
+ if (startAt + maxResults < boards.length) {
201
+ markdown += `Showing ${startAt + 1}-${startAt + boardDataList.length} of ${boards.length}\n`;
202
+ markdown += `**Next page:** Use startAt=${startAt + maxResults}`;
203
+ }
204
+ else {
205
+ markdown += `Showing all ${boardDataList.length} board${boardDataList.length !== 1 ? 's' : ''}`;
206
+ }
180
207
  return {
181
208
  content: [
182
209
  {
183
210
  type: 'text',
184
- text: JSON.stringify(response, null, 2),
211
+ text: markdown,
185
212
  },
186
213
  ],
187
214
  };
@@ -1,5 +1,5 @@
1
1
  import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
- import { FilterFormatter, SearchFormatter } from '../utils/formatters/index.js';
2
+ import { MarkdownRenderer } from '../mcp/markdown-renderer.js';
3
3
  // Helper function to normalize parameter names (support both snake_case and camelCase)
4
4
  function normalizeArgs(args) {
5
5
  const normalized = {};
@@ -134,14 +134,9 @@ async function handleGetFilter(jiraClient, args) {
134
134
  name: filter.name || 'Unnamed Filter',
135
135
  owner: filter.owner || 'Unknown',
136
136
  favourite: filter.favourite || false,
137
- viewUrl: filter.viewUrl || '',
138
- description: filter.description || '',
139
- jql: filter.jql || '',
140
- sharePermissions: filter.sharePermissions?.map(perm => ({
141
- type: perm.type,
142
- group: perm.group,
143
- project: perm.project
144
- })) || []
137
+ viewUrl: filter.viewUrl,
138
+ description: filter.description,
139
+ jql: filter.jql,
145
140
  };
146
141
  // Handle expansions
147
142
  if (expansionOptions.issue_count) {
@@ -156,13 +151,13 @@ async function handleGetFilter(jiraClient, args) {
156
151
  // Continue even if issue count fails
157
152
  }
158
153
  }
159
- // Format the response
160
- const formattedResponse = FilterFormatter.formatFilter(filterData, expansionOptions);
154
+ // Render to markdown
155
+ const markdown = MarkdownRenderer.renderFilter(filterData);
161
156
  return {
162
157
  content: [
163
158
  {
164
159
  type: 'text',
165
- text: JSON.stringify(formattedResponse, null, 2),
160
+ text: markdown,
166
161
  },
167
162
  ],
168
163
  };
@@ -212,25 +207,22 @@ async function handleListFilters(jiraClient, args) {
212
207
  }
213
208
  }
214
209
  }
215
- // Format the response
216
- const formattedFilters = filterDataList.map(filter => FilterFormatter.formatFilter(filter, expansionOptions));
217
- // Create a response with pagination metadata
218
- const response = {
219
- data: formattedFilters,
220
- _metadata: {
221
- pagination: {
222
- startAt,
223
- maxResults,
224
- total: filters.length,
225
- hasMore: startAt + maxResults < filters.length,
226
- },
227
- },
228
- };
210
+ // Render to markdown with pagination info
211
+ let markdown = MarkdownRenderer.renderFilterList(filterDataList);
212
+ // Add pagination guidance
213
+ markdown += '\n\n---\n';
214
+ if (startAt + maxResults < filters.length) {
215
+ markdown += `Showing ${startAt + 1}-${startAt + filterDataList.length} of ${filters.length}\n`;
216
+ markdown += `**Next page:** Use startAt=${startAt + maxResults}`;
217
+ }
218
+ else {
219
+ markdown += `Showing all ${filterDataList.length} filter${filterDataList.length !== 1 ? 's' : ''}`;
220
+ }
229
221
  return {
230
222
  content: [
231
223
  {
232
224
  type: 'text',
233
- text: JSON.stringify(response, null, 2),
225
+ text: markdown,
234
226
  },
235
227
  ],
236
228
  };
@@ -319,17 +311,18 @@ async function handleExecuteFilter(jiraClient, _args) {
319
311
  const filterId = _args.filterId;
320
312
  // Get issues for the filter
321
313
  const issues = await jiraClient.getFilterIssues(filterId);
314
+ // Render to markdown
315
+ const markdown = MarkdownRenderer.renderIssueSearchResults(issues, {
316
+ startAt: 0,
317
+ maxResults: issues.length,
318
+ total: issues.length,
319
+ hasMore: false,
320
+ }, `filter = ${filterId}`);
322
321
  return {
323
322
  content: [
324
323
  {
325
324
  type: 'text',
326
- text: JSON.stringify({
327
- data: issues,
328
- _metadata: {
329
- filter_id: filterId,
330
- issue_count: issues.length
331
- }
332
- }, null, 2),
325
+ text: markdown,
333
326
  },
334
327
  ],
335
328
  };
@@ -343,24 +336,24 @@ async function handleExecuteJql(jiraClient, args) {
343
336
  const maxResults = args.maxResults !== undefined ? args.maxResults : 25;
344
337
  try {
345
338
  console.error(`Executing JQL search with args:`, JSON.stringify(args, null, 2));
346
- // Parse search expansion options
347
- const searchExpansionOptions = {};
339
+ // Parse search expansion options (not currently used but reserved for future)
340
+ const _searchExpansionOptions = {};
348
341
  if (args.expand) {
349
342
  for (const expansion of args.expand) {
350
343
  if (['issue_details', 'transitions', 'comments_preview'].includes(expansion)) {
351
- searchExpansionOptions[expansion] = true;
344
+ _searchExpansionOptions[expansion] = true;
352
345
  }
353
346
  }
354
347
  }
355
348
  // Execute the search
356
349
  const searchResult = await jiraClient.searchIssues(args.jql, startAt, maxResults);
357
- // Format the response using the SearchFormatter for enhanced results
358
- const formattedResponse = SearchFormatter.formatSearchResult(searchResult, searchExpansionOptions);
350
+ // Render directly to markdown for token efficiency
351
+ const markdown = MarkdownRenderer.renderIssueSearchResults(searchResult.issues, searchResult.pagination, args.jql);
359
352
  return {
360
353
  content: [
361
354
  {
362
355
  type: 'text',
363
- text: JSON.stringify(formattedResponse, null, 2),
356
+ text: markdown,
364
357
  },
365
358
  ],
366
359
  };
@@ -1,5 +1,5 @@
1
1
  import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
- import { IssueFormatter } from '../utils/formatters/index.js';
2
+ import { MarkdownRenderer } from '../mcp/markdown-renderer.js';
3
3
  // Helper function to normalize parameter names (support both snake_case and camelCase)
4
4
  function normalizeArgs(args) {
5
5
  const normalized = {};
@@ -141,13 +141,13 @@ async function handleGetIssue(jiraClient, args) {
141
141
  if (expansionOptions.transitions) {
142
142
  transitions = await jiraClient.getTransitions(args.issueKey);
143
143
  }
144
- // Format the response using the IssueFormatter
145
- const formattedResponse = IssueFormatter.formatIssue(issue, expansionOptions, transitions);
144
+ // Render to markdown
145
+ const markdown = MarkdownRenderer.renderIssue(issue, transitions);
146
146
  return {
147
147
  content: [
148
148
  {
149
149
  type: 'text',
150
- text: JSON.stringify(formattedResponse, null, 2),
150
+ text: markdown,
151
151
  },
152
152
  ],
153
153
  };
@@ -163,28 +163,37 @@ async function handleCreateIssue(jiraClient, args) {
163
163
  labels: args.labels,
164
164
  customFields: args.customFields
165
165
  });
166
- // Get the created issue to return
166
+ // Get the created issue and render to markdown
167
167
  const createdIssue = await jiraClient.getIssue(result.key, false, false);
168
- const formattedResponse = IssueFormatter.formatIssue(createdIssue);
168
+ const markdown = MarkdownRenderer.renderIssue(createdIssue);
169
169
  return {
170
170
  content: [
171
171
  {
172
172
  type: 'text',
173
- text: JSON.stringify(formattedResponse, null, 2),
173
+ text: `# Issue Created\n\n${markdown}`,
174
174
  },
175
175
  ],
176
176
  };
177
177
  }
178
178
  async function handleUpdateIssue(jiraClient, args) {
179
- await jiraClient.updateIssue(args.issueKey, args.summary, args.description, args.parent);
180
- // Get the updated issue to return
179
+ await jiraClient.updateIssue({
180
+ issueKey: args.issueKey,
181
+ summary: args.summary,
182
+ description: args.description,
183
+ parentKey: args.parent,
184
+ assignee: args.assignee,
185
+ priority: args.priority,
186
+ labels: args.labels,
187
+ customFields: args.customFields,
188
+ });
189
+ // Get the updated issue and render to markdown
181
190
  const updatedIssue = await jiraClient.getIssue(args.issueKey, false, false);
182
- const formattedResponse = IssueFormatter.formatIssue(updatedIssue);
191
+ const markdown = MarkdownRenderer.renderIssue(updatedIssue);
183
192
  return {
184
193
  content: [
185
194
  {
186
195
  type: 'text',
187
- text: JSON.stringify(formattedResponse, null, 2),
196
+ text: `# Issue Updated\n\n${markdown}`,
188
197
  },
189
198
  ],
190
199
  };
@@ -212,28 +221,28 @@ async function handleDeleteIssue(_jiraClient, _args) {
212
221
  }
213
222
  async function handleTransitionIssue(jiraClient, args) {
214
223
  await jiraClient.transitionIssue(args.issueKey, args.transitionId, args.comment);
215
- // Get the updated issue to return
224
+ // Get the updated issue and render to markdown
216
225
  const updatedIssue = await jiraClient.getIssue(args.issueKey, false, false);
217
- const formattedResponse = IssueFormatter.formatIssue(updatedIssue);
226
+ const markdown = MarkdownRenderer.renderIssue(updatedIssue);
218
227
  return {
219
228
  content: [
220
229
  {
221
230
  type: 'text',
222
- text: JSON.stringify(formattedResponse, null, 2),
231
+ text: `# Issue Transitioned\n\n${markdown}`,
223
232
  },
224
233
  ],
225
234
  };
226
235
  }
227
236
  async function handleCommentIssue(jiraClient, args) {
228
237
  await jiraClient.addComment(args.issueKey, args.comment);
229
- // Get the updated issue with comments to return
238
+ // Get the updated issue with comments and render to markdown
230
239
  const updatedIssue = await jiraClient.getIssue(args.issueKey, true, false);
231
- const formattedResponse = IssueFormatter.formatIssue(updatedIssue, { comments: true });
240
+ const markdown = MarkdownRenderer.renderIssue(updatedIssue);
232
241
  return {
233
242
  content: [
234
243
  {
235
244
  type: 'text',
236
- text: JSON.stringify(formattedResponse, null, 2),
245
+ text: `# Comment Added\n\n${markdown}`,
237
246
  },
238
247
  ],
239
248
  };
@@ -242,14 +251,14 @@ async function handleLinkIssue(jiraClient, args) {
242
251
  console.error(`Linking issue ${args.issueKey} to ${args.linkedIssueKey} with type ${args.linkType}`);
243
252
  // Link the issues
244
253
  await jiraClient.linkIssues(args.issueKey, args.linkedIssueKey, args.linkType, args.comment);
245
- // Get the updated issue to return
254
+ // Get the updated issue and render to markdown
246
255
  const updatedIssue = await jiraClient.getIssue(args.issueKey, false, false);
247
- const formattedResponse = IssueFormatter.formatIssue(updatedIssue);
256
+ const markdown = MarkdownRenderer.renderIssue(updatedIssue);
248
257
  return {
249
258
  content: [
250
259
  {
251
260
  type: 'text',
252
- text: JSON.stringify(formattedResponse, null, 2),
261
+ text: `# Issue Linked\n\n${markdown}`,
253
262
  },
254
263
  ],
255
264
  };
@@ -1,5 +1,5 @@
1
1
  import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
- import { ProjectFormatter } from '../utils/formatters/index.js';
2
+ import { MarkdownRenderer } from '../mcp/markdown-renderer.js';
3
3
  // Helper function to normalize parameter names (support both snake_case and camelCase)
4
4
  function normalizeArgs(args) {
5
5
  const normalized = {};
@@ -122,12 +122,10 @@ async function handleGetProject(jiraClient, args) {
122
122
  }
123
123
  // Convert to ProjectData format
124
124
  const projectData = {
125
- id: project.id,
126
125
  key: project.key,
127
126
  name: project.name,
128
- description: project.description,
129
- lead: project.lead,
130
- url: project.url
127
+ description: project.description || undefined,
128
+ lead: project.lead || undefined,
131
129
  };
132
130
  // If status counts are requested, get them
133
131
  if (includeStatusCounts) {
@@ -140,7 +138,7 @@ async function handleGetProject(jiraClient, args) {
140
138
  const status = issue.status;
141
139
  statusCounts[status] = (statusCounts[status] || 0) + 1;
142
140
  }
143
- projectData.status_counts = statusCounts;
141
+ projectData.statusCounts = statusCounts;
144
142
  }
145
143
  catch (error) {
146
144
  console.error(`Error getting status counts for project ${projectKey}:`, error);
@@ -167,20 +165,32 @@ async function handleGetProject(jiraClient, args) {
167
165
  // Get recent issues for this project
168
166
  const searchResult = await jiraClient.searchIssues(`project = ${projectKey} ORDER BY updated DESC`, 0, 5);
169
167
  // Add recent issues to the response
170
- projectData.recent_issues = searchResult.issues;
168
+ projectData.recentIssues = searchResult.issues.map(issue => ({
169
+ key: issue.key,
170
+ summary: issue.summary,
171
+ status: issue.status
172
+ }));
171
173
  }
172
174
  catch (error) {
173
175
  console.error(`Error getting recent issues for project ${projectKey}:`, error);
174
176
  // Continue even if recent issues fail
175
177
  }
176
178
  }
177
- // Format the response
178
- const formattedResponse = ProjectFormatter.formatProject(projectData, expansionOptions);
179
+ // Render to markdown
180
+ const markdown = MarkdownRenderer.renderProject({
181
+ key: projectData.key,
182
+ name: projectData.name,
183
+ description: projectData.description,
184
+ lead: projectData.lead,
185
+ statusCounts: projectData.statusCounts,
186
+ boards: projectData.boards,
187
+ recentIssues: projectData.recentIssues,
188
+ });
179
189
  return {
180
190
  content: [
181
191
  {
182
192
  type: 'text',
183
- text: JSON.stringify(formattedResponse, null, 2),
193
+ text: markdown,
184
194
  },
185
195
  ],
186
196
  };
@@ -271,12 +281,10 @@ async function handleListProjects(jiraClient, args) {
271
281
  const paginatedProjects = projects.slice(startAt, startAt + maxResults);
272
282
  // Convert to ProjectData format
273
283
  const projectDataList = paginatedProjects.map(project => ({
274
- id: project.id,
275
284
  key: project.key,
276
285
  name: project.name,
277
- description: project.description,
278
- lead: project.lead,
279
- url: project.url
286
+ description: project.description || undefined,
287
+ lead: project.lead || undefined,
280
288
  }));
281
289
  // If status counts are requested, get them for each project
282
290
  if (includeStatusCounts) {
@@ -291,7 +299,7 @@ async function handleListProjects(jiraClient, args) {
291
299
  const status = issue.status;
292
300
  statusCounts[status] = (statusCounts[status] || 0) + 1;
293
301
  }
294
- project.status_counts = statusCounts;
302
+ project.statusCounts = statusCounts;
295
303
  }
296
304
  catch (error) {
297
305
  console.error(`Error getting status counts for project ${project.key}:`, error);
@@ -299,25 +307,30 @@ async function handleListProjects(jiraClient, args) {
299
307
  }
300
308
  }
301
309
  }
302
- // Format the response
303
- const formattedProjects = projectDataList.map(project => ProjectFormatter.formatProject(project));
304
- // Create a response with pagination metadata
305
- const response = {
306
- data: formattedProjects,
307
- _metadata: {
308
- pagination: {
309
- startAt,
310
- maxResults,
311
- total: projects.length,
312
- hasMore: startAt + maxResults < projects.length,
313
- },
314
- },
315
- };
310
+ // Render to markdown with pagination
311
+ const rendererProjects = projectDataList.map(project => ({
312
+ key: project.key,
313
+ name: project.name,
314
+ description: project.description,
315
+ lead: project.lead,
316
+ statusCounts: project.statusCounts,
317
+ }));
318
+ // Render to markdown with pagination
319
+ let markdown = MarkdownRenderer.renderProjectList(rendererProjects);
320
+ // Add pagination guidance
321
+ markdown += '\n\n---\n';
322
+ if (startAt + maxResults < projects.length) {
323
+ markdown += `Showing ${startAt + 1}-${startAt + projectDataList.length} of ${projects.length}\n`;
324
+ markdown += `**Next page:** Use startAt=${startAt + maxResults}`;
325
+ }
326
+ else {
327
+ markdown += `Showing all ${projectDataList.length} project${projectDataList.length !== 1 ? 's' : ''}`;
328
+ }
316
329
  return {
317
330
  content: [
318
331
  {
319
332
  type: 'text',
320
- text: JSON.stringify(response, null, 2),
333
+ text: markdown,
321
334
  },
322
335
  ],
323
336
  };
@@ -1,5 +1,5 @@
1
1
  import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
- import { SearchFormatter } from '../utils/formatters/index.js';
2
+ import { MarkdownRenderer } from '../mcp/markdown-renderer.js';
3
3
  // Helper function to normalize parameter names (support both snake_case and camelCase)
4
4
  function normalizeArgs(args) {
5
5
  const normalized = {};
@@ -67,22 +67,22 @@ export async function setupSearchHandlers(server, jiraClient, request) {
67
67
  }
68
68
  try {
69
69
  console.error(`Executing search with args:`, JSON.stringify(normalizedArgs, null, 2));
70
- // Parse expansion options
71
- const expansionOptions = {};
70
+ // Parse expansion options (reserved for future use)
71
+ const _expansionOptions = {};
72
72
  if (normalizedArgs.expand) {
73
73
  for (const expansion of normalizedArgs.expand) {
74
- expansionOptions[expansion] = true;
74
+ _expansionOptions[expansion] = true;
75
75
  }
76
76
  }
77
77
  // Execute the search
78
78
  const searchResult = await jiraClient.searchIssues(normalizedArgs.jql, normalizedArgs.startAt, normalizedArgs.maxResults);
79
- // Format the response using the SearchFormatter
80
- const formattedResponse = SearchFormatter.formatSearchResult(searchResult, expansionOptions);
79
+ // Render to markdown for token efficiency
80
+ const markdown = MarkdownRenderer.renderIssueSearchResults(searchResult.issues, searchResult.pagination, normalizedArgs.jql);
81
81
  return {
82
82
  content: [
83
83
  {
84
84
  type: 'text',
85
- text: JSON.stringify(formattedResponse, null, 2),
85
+ text: markdown,
86
86
  },
87
87
  ],
88
88
  };
@@ -1,5 +1,5 @@
1
1
  import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
- import { SprintFormatter } from '../utils/formatters/sprint-formatter.js';
2
+ import { MarkdownRenderer } from '../mcp/markdown-renderer.js';
3
3
  // Helper function to normalize parameter names (support both snake_case and camelCase)
4
4
  function normalizeArgs(args) {
5
5
  const normalized = {};
@@ -164,24 +164,24 @@ async function handleGetSprint(jiraClient, args) {
164
164
  if (expansionOptions.issues) {
165
165
  issues = await jiraClient.getSprintIssues(args.sprintId);
166
166
  }
167
- // Get report if requested
168
- let report = undefined;
169
- if (expansionOptions.report && sprint.state === 'closed') {
170
- report = await jiraClient.getSprintReport(sprint.boardId, args.sprintId);
171
- }
172
- // Combine data
173
- const sprintData = {
174
- ...sprint,
175
- issues,
176
- report,
177
- };
178
- // Format the response
179
- const formattedResponse = SprintFormatter.formatSprint(sprintData, expansionOptions);
167
+ // Note: Sprint report expansion not yet supported in markdown renderer
168
+ // Render to markdown
169
+ const markdown = MarkdownRenderer.renderSprint({
170
+ id: sprint.id,
171
+ name: sprint.name,
172
+ state: sprint.state,
173
+ boardId: sprint.boardId,
174
+ goal: sprint.goal,
175
+ startDate: sprint.startDate,
176
+ endDate: sprint.endDate,
177
+ completeDate: sprint.completeDate,
178
+ issues: issues,
179
+ });
180
180
  return {
181
181
  content: [
182
182
  {
183
183
  type: 'text',
184
- text: JSON.stringify(formattedResponse, null, 2),
184
+ text: markdown,
185
185
  },
186
186
  ],
187
187
  };
@@ -189,13 +189,21 @@ async function handleGetSprint(jiraClient, args) {
189
189
  async function handleCreateSprint(jiraClient, args) {
190
190
  // Create the sprint
191
191
  const response = await jiraClient.createSprint(args.boardId, args.name, args.startDate, args.endDate, args.goal);
192
- // Format the response
193
- const formattedResponse = SprintFormatter.formatSprint(response);
192
+ // Render to markdown
193
+ const markdown = MarkdownRenderer.renderSprint({
194
+ id: response.id,
195
+ name: response.name,
196
+ state: response.state,
197
+ boardId: response.boardId,
198
+ goal: response.goal,
199
+ startDate: response.startDate,
200
+ endDate: response.endDate,
201
+ });
194
202
  return {
195
203
  content: [
196
204
  {
197
205
  type: 'text',
198
- text: JSON.stringify(formattedResponse, null, 2),
206
+ text: `# Sprint Created\n\n${markdown}`,
199
207
  },
200
208
  ],
201
209
  };
@@ -212,13 +220,21 @@ async function handleUpdateSprint(jiraClient, args) {
212
220
  await jiraClient.updateSprint(args.sprintId, args.name, args.goal, args.startDate, args.endDate, args.state);
213
221
  // Get the updated sprint
214
222
  const updatedSprint = await jiraClient.getSprint(args.sprintId);
215
- // Format the response
216
- const formattedResponse = SprintFormatter.formatSprint(updatedSprint);
223
+ // Render to markdown
224
+ const markdown = MarkdownRenderer.renderSprint({
225
+ id: updatedSprint.id,
226
+ name: updatedSprint.name,
227
+ state: updatedSprint.state,
228
+ boardId: updatedSprint.boardId,
229
+ goal: updatedSprint.goal,
230
+ startDate: updatedSprint.startDate,
231
+ endDate: updatedSprint.endDate,
232
+ });
217
233
  return {
218
234
  content: [
219
235
  {
220
236
  type: 'text',
221
- text: JSON.stringify(formattedResponse, null, 2),
237
+ text: `# Sprint Updated\n\n${markdown}`,
222
238
  },
223
239
  ],
224
240
  };
@@ -244,10 +260,7 @@ async function handleDeleteSprint(jiraClient, args) {
244
260
  content: [
245
261
  {
246
262
  type: 'text',
247
- text: JSON.stringify({
248
- success: true,
249
- message: `Sprint ${args.sprintId} has been deleted successfully.`,
250
- }, null, 2),
263
+ text: `# Sprint Deleted\n\nSprint ${args.sprintId} has been deleted successfully.`,
251
264
  },
252
265
  ],
253
266
  };
@@ -258,17 +271,46 @@ async function handleListSprints(jiraClient, args) {
258
271
  const maxResults = args.maxResults !== undefined ? args.maxResults : 50;
259
272
  // Get sprints
260
273
  const response = await jiraClient.listSprints(args.boardId, args.state, startAt, maxResults);
261
- // Format the response
262
- const formattedResponse = SprintFormatter.formatSprintList(response.sprints, {
263
- startAt,
264
- maxResults,
265
- total: response.total,
266
- });
274
+ // Render sprints to markdown
275
+ const lines = [];
276
+ lines.push(`# Sprints (${response.total})`);
277
+ if (args.state) {
278
+ lines.push(`**Filter:** ${args.state}`);
279
+ }
280
+ lines.push('');
281
+ // Group by state
282
+ const byState = {};
283
+ for (const sprint of response.sprints) {
284
+ const state = sprint.state || 'unknown';
285
+ if (!byState[state])
286
+ byState[state] = [];
287
+ byState[state].push(sprint);
288
+ }
289
+ for (const [state, sprints] of Object.entries(byState)) {
290
+ const stateIcon = state === 'active' ? '[>]' : state === 'closed' ? '[x]' : '[ ]';
291
+ lines.push(`## ${state.charAt(0).toUpperCase() + state.slice(1)} ${stateIcon}`);
292
+ for (const sprint of sprints) {
293
+ lines.push(`- **${sprint.name}** (id: ${sprint.id})`);
294
+ if (sprint.goal) {
295
+ lines.push(` Goal: ${sprint.goal.substring(0, 80)}${sprint.goal.length > 80 ? '...' : ''}`);
296
+ }
297
+ }
298
+ lines.push('');
299
+ }
300
+ // Pagination
301
+ lines.push('---');
302
+ if (startAt + maxResults < response.total) {
303
+ lines.push(`Showing ${startAt + 1}-${startAt + response.sprints.length} of ${response.total}`);
304
+ lines.push(`**Next page:** Use startAt=${startAt + maxResults}`);
305
+ }
306
+ else {
307
+ lines.push(`Showing all ${response.sprints.length} sprint${response.sprints.length !== 1 ? 's' : ''}`);
308
+ }
267
309
  return {
268
310
  content: [
269
311
  {
270
312
  type: 'text',
271
- text: JSON.stringify(formattedResponse, null, 2),
313
+ text: lines.join('\n'),
272
314
  },
273
315
  ],
274
316
  };
@@ -291,18 +333,22 @@ async function handleManageIssues(jiraClient, args) {
291
333
  // Get the updated sprint with issues
292
334
  const sprint = await jiraClient.getSprint(args.sprintId);
293
335
  const issues = await jiraClient.getSprintIssues(args.sprintId);
294
- // Combine data
295
- const sprintData = {
296
- ...sprint,
297
- issues,
298
- };
299
- // Format the response
300
- const formattedResponse = SprintFormatter.formatSprint(sprintData, { issues: true });
336
+ // Render to markdown
337
+ const markdown = MarkdownRenderer.renderSprint({
338
+ id: sprint.id,
339
+ name: sprint.name,
340
+ state: sprint.state,
341
+ boardId: sprint.boardId,
342
+ goal: sprint.goal,
343
+ startDate: sprint.startDate,
344
+ endDate: sprint.endDate,
345
+ issues: issues,
346
+ });
301
347
  return {
302
348
  content: [
303
349
  {
304
350
  type: 'text',
305
- text: JSON.stringify(formattedResponse, null, 2),
351
+ text: `# Sprint Issues Updated\n\n${markdown}`,
306
352
  },
307
353
  ],
308
354
  };
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Markdown renderer for MCP tool responses
3
+ *
4
+ * Converts structured JSON responses to token-efficient markdown
5
+ * that's optimized for AI assistant consumption.
6
+ *
7
+ * Design principles:
8
+ * - Minimal tokens, maximum clarity
9
+ * - Embedded navigation hints (suggested next actions)
10
+ * - Human-readable pagination guidance
11
+ * - Plain text structure over decorative formatting
12
+ */
13
+ // ============================================================================
14
+ // Helper Functions
15
+ // ============================================================================
16
+ /**
17
+ * Format a date string to a more readable format
18
+ */
19
+ function formatDate(dateStr) {
20
+ if (!dateStr)
21
+ return 'Not set';
22
+ const date = new Date(dateStr);
23
+ return date.toLocaleDateString('en-US', {
24
+ year: 'numeric',
25
+ month: 'short',
26
+ day: 'numeric'
27
+ });
28
+ }
29
+ /**
30
+ * Format status with visual indicator
31
+ */
32
+ function formatStatus(status) {
33
+ const statusIcons = {
34
+ 'Done': '[x]',
35
+ 'Closed': '[x]',
36
+ 'Resolved': '[x]',
37
+ 'In Progress': '[>]',
38
+ 'In Review': '[>]',
39
+ 'To Do': '[ ]',
40
+ 'Open': '[ ]',
41
+ 'Backlog': '[-]',
42
+ };
43
+ const icon = statusIcons[status] || '[?]';
44
+ return `${icon} ${status}`;
45
+ }
46
+ /**
47
+ * Truncate text to a maximum length with ellipsis
48
+ */
49
+ function truncate(text, maxLength = 150) {
50
+ if (!text)
51
+ return '';
52
+ const cleaned = text.replace(/\n+/g, ' ').trim();
53
+ if (cleaned.length <= maxLength)
54
+ return cleaned;
55
+ return cleaned.substring(0, maxLength).trim() + '...';
56
+ }
57
+ /**
58
+ * Strip HTML tags for plain text display
59
+ */
60
+ function stripHtml(html) {
61
+ return html
62
+ .replace(/<[^>]*>/g, '')
63
+ .replace(/&nbsp;/g, ' ')
64
+ .replace(/&amp;/g, '&')
65
+ .replace(/&lt;/g, '<')
66
+ .replace(/&gt;/g, '>')
67
+ .replace(/\s+/g, ' ')
68
+ .trim();
69
+ }
70
+ // ============================================================================
71
+ // Issue Rendering
72
+ // ============================================================================
73
+ /**
74
+ * Render a single issue as markdown
75
+ */
76
+ export function renderIssue(issue, transitions) {
77
+ const lines = [];
78
+ lines.push(`# ${issue.key}: ${issue.summary}`);
79
+ lines.push('');
80
+ // Core fields
81
+ lines.push(`**Status:** ${formatStatus(issue.status)}`);
82
+ if (issue.assignee) {
83
+ lines.push(`**Assignee:** ${issue.assignee}`);
84
+ }
85
+ else {
86
+ lines.push(`**Assignee:** Unassigned`);
87
+ }
88
+ lines.push(`**Reporter:** ${issue.reporter}`);
89
+ if (issue.parent) {
90
+ lines.push(`**Parent:** ${issue.parent}`);
91
+ }
92
+ if (issue.dueDate) {
93
+ lines.push(`**Due:** ${formatDate(issue.dueDate)}`);
94
+ }
95
+ if (issue.storyPoints) {
96
+ lines.push(`**Points:** ${issue.storyPoints}`);
97
+ }
98
+ if (issue.resolution) {
99
+ lines.push(`**Resolution:** ${issue.resolution}`);
100
+ }
101
+ // Description (truncated for token efficiency)
102
+ if (issue.description) {
103
+ lines.push('');
104
+ lines.push('## Description');
105
+ const desc = stripHtml(issue.description);
106
+ lines.push(truncate(desc, 300));
107
+ }
108
+ // Issue links
109
+ if (issue.issueLinks && issue.issueLinks.length > 0) {
110
+ lines.push('');
111
+ lines.push('## Links');
112
+ for (const link of issue.issueLinks) {
113
+ if (link.outward) {
114
+ lines.push(`- ${link.type} -> ${link.outward}`);
115
+ }
116
+ if (link.inward) {
117
+ lines.push(`- ${link.type} <- ${link.inward}`);
118
+ }
119
+ }
120
+ }
121
+ // Comments (if present)
122
+ if (issue.comments && issue.comments.length > 0) {
123
+ lines.push('');
124
+ lines.push(`## Comments (${issue.comments.length})`);
125
+ // Show last 3 comments
126
+ const recentComments = issue.comments.slice(-3);
127
+ for (const comment of recentComments) {
128
+ lines.push(`- **${comment.author}** (${formatDate(comment.created)}): ${truncate(stripHtml(comment.body), 100)}`);
129
+ }
130
+ if (issue.comments.length > 3) {
131
+ lines.push(` ... and ${issue.comments.length - 3} more comments`);
132
+ }
133
+ }
134
+ // Available transitions
135
+ if (transitions && transitions.length > 0) {
136
+ lines.push('');
137
+ lines.push('## Available Actions');
138
+ for (const t of transitions) {
139
+ lines.push(`- **${t.name}** -> ${t.to.name} (id: ${t.id})`);
140
+ }
141
+ }
142
+ return lines.join('\n');
143
+ }
144
+ /**
145
+ * Render issue search results as markdown
146
+ */
147
+ export function renderIssueSearchResults(issues, pagination, jql) {
148
+ const lines = [];
149
+ // Header
150
+ if (jql) {
151
+ lines.push(`# Search Results`);
152
+ lines.push(`**JQL:** \`${jql}\``);
153
+ }
154
+ else {
155
+ lines.push('# Issues');
156
+ }
157
+ lines.push(`Found ${pagination.total} issue${pagination.total !== 1 ? 's' : ''}`);
158
+ lines.push('');
159
+ // Status summary
160
+ const statusCounts = {};
161
+ for (const issue of issues) {
162
+ statusCounts[issue.status] = (statusCounts[issue.status] || 0) + 1;
163
+ }
164
+ if (Object.keys(statusCounts).length > 1) {
165
+ lines.push('**By Status:** ' + Object.entries(statusCounts)
166
+ .map(([status, count]) => `${status}: ${count}`)
167
+ .join(', '));
168
+ lines.push('');
169
+ }
170
+ // Issues list
171
+ for (let i = 0; i < issues.length; i++) {
172
+ const issue = issues[i];
173
+ const num = pagination.startAt + i + 1;
174
+ lines.push(`## ${num}. ${issue.key}: ${issue.summary}`);
175
+ lines.push(`${formatStatus(issue.status)} | ${issue.assignee || 'Unassigned'}`);
176
+ if (issue.dueDate) {
177
+ lines.push(`Due: ${formatDate(issue.dueDate)}`);
178
+ }
179
+ if (issue.description) {
180
+ const desc = stripHtml(issue.description);
181
+ if (desc.length > 0) {
182
+ lines.push(`> ${truncate(desc, 120)}`);
183
+ }
184
+ }
185
+ lines.push('');
186
+ }
187
+ // Pagination guidance
188
+ lines.push('---');
189
+ if (pagination.hasMore) {
190
+ const nextOffset = pagination.startAt + pagination.maxResults;
191
+ lines.push(`Showing ${pagination.startAt + 1}-${pagination.startAt + issues.length} of ${pagination.total}`);
192
+ lines.push(`**Next page:** Use startAt=${nextOffset}`);
193
+ }
194
+ else if (pagination.startAt > 0) {
195
+ lines.push(`Showing ${pagination.startAt + 1}-${pagination.startAt + issues.length} of ${pagination.total} (last page)`);
196
+ }
197
+ else {
198
+ lines.push(`Showing all ${issues.length} result${issues.length !== 1 ? 's' : ''}`);
199
+ }
200
+ return lines.join('\n');
201
+ }
202
+ export function renderProject(project) {
203
+ const lines = [];
204
+ lines.push(`# ${project.key}: ${project.name}`);
205
+ lines.push('');
206
+ if (project.lead) {
207
+ lines.push(`**Lead:** ${project.lead}`);
208
+ }
209
+ if (project.projectTypeKey) {
210
+ lines.push(`**Type:** ${project.projectTypeKey}`);
211
+ }
212
+ if (project.description) {
213
+ lines.push('');
214
+ lines.push(truncate(stripHtml(project.description), 200));
215
+ }
216
+ // Status counts
217
+ if (project.statusCounts && Object.keys(project.statusCounts).length > 0) {
218
+ lines.push('');
219
+ lines.push('## Issue Summary');
220
+ const total = Object.values(project.statusCounts).reduce((a, b) => a + b, 0);
221
+ lines.push(`Total: ${total} issues`);
222
+ for (const [status, count] of Object.entries(project.statusCounts)) {
223
+ lines.push(`- ${status}: ${count}`);
224
+ }
225
+ }
226
+ // Boards
227
+ if (project.boards && project.boards.length > 0) {
228
+ lines.push('');
229
+ lines.push('## Boards');
230
+ for (const board of project.boards) {
231
+ lines.push(`- ${board.name} (${board.type}, id: ${board.id})`);
232
+ }
233
+ }
234
+ // Recent issues
235
+ if (project.recentIssues && project.recentIssues.length > 0) {
236
+ lines.push('');
237
+ lines.push('## Recent Issues');
238
+ for (const issue of project.recentIssues) {
239
+ lines.push(`- ${issue.key}: ${issue.summary} [${issue.status}]`);
240
+ }
241
+ }
242
+ return lines.join('\n');
243
+ }
244
+ export function renderProjectList(projects) {
245
+ const lines = [];
246
+ lines.push(`# Projects (${projects.length})`);
247
+ lines.push('');
248
+ for (const project of projects) {
249
+ lines.push(`## ${project.key}: ${project.name}`);
250
+ if (project.lead) {
251
+ lines.push(`Lead: ${project.lead}`);
252
+ }
253
+ if (project.description) {
254
+ lines.push(`> ${truncate(stripHtml(project.description), 100)}`);
255
+ }
256
+ if (project.statusCounts) {
257
+ const total = Object.values(project.statusCounts).reduce((a, b) => a + b, 0);
258
+ lines.push(`Issues: ${total}`);
259
+ }
260
+ lines.push('');
261
+ }
262
+ lines.push('---');
263
+ lines.push(`Tip: Use manage_jira_project with operation="get" and projectKey="KEY" for details`);
264
+ return lines.join('\n');
265
+ }
266
+ export function renderBoard(board) {
267
+ const lines = [];
268
+ lines.push(`# Board: ${board.name}`);
269
+ lines.push(`**ID:** ${board.id}`);
270
+ lines.push(`**Type:** ${board.type}`);
271
+ if (board.projectKey) {
272
+ lines.push(`**Project:** ${board.projectKey} (${board.projectName || ''})`);
273
+ }
274
+ if (board.sprints && board.sprints.length > 0) {
275
+ lines.push('');
276
+ lines.push('## Sprints');
277
+ for (const sprint of board.sprints) {
278
+ const stateIcon = sprint.state === 'active' ? '[>]' : sprint.state === 'closed' ? '[x]' : '[ ]';
279
+ lines.push(`- ${stateIcon} ${sprint.name} (id: ${sprint.id})`);
280
+ if (sprint.goal) {
281
+ lines.push(` Goal: ${truncate(sprint.goal, 80)}`);
282
+ }
283
+ }
284
+ }
285
+ return lines.join('\n');
286
+ }
287
+ export function renderBoardList(boards) {
288
+ const lines = [];
289
+ lines.push(`# Boards (${boards.length})`);
290
+ lines.push('');
291
+ // Group by type
292
+ const byType = {};
293
+ for (const board of boards) {
294
+ const type = board.type || 'other';
295
+ if (!byType[type])
296
+ byType[type] = [];
297
+ byType[type].push(board);
298
+ }
299
+ for (const [type, typeBoards] of Object.entries(byType)) {
300
+ lines.push(`## ${type.charAt(0).toUpperCase() + type.slice(1)} Boards`);
301
+ for (const board of typeBoards) {
302
+ lines.push(`- **${board.name}** (id: ${board.id})${board.projectKey ? ` - ${board.projectKey}` : ''}`);
303
+ }
304
+ lines.push('');
305
+ }
306
+ return lines.join('\n');
307
+ }
308
+ export function renderSprint(sprint) {
309
+ const lines = [];
310
+ const stateIcon = sprint.state === 'active' ? '[ACTIVE]' : sprint.state === 'closed' ? '[CLOSED]' : '[FUTURE]';
311
+ lines.push(`# Sprint: ${sprint.name} ${stateIcon}`);
312
+ lines.push(`**ID:** ${sprint.id}`);
313
+ lines.push(`**Board:** ${sprint.boardId}`);
314
+ if (sprint.startDate) {
315
+ lines.push(`**Started:** ${formatDate(sprint.startDate)}`);
316
+ }
317
+ if (sprint.endDate) {
318
+ lines.push(`**Ends:** ${formatDate(sprint.endDate)}`);
319
+ }
320
+ if (sprint.completeDate) {
321
+ lines.push(`**Completed:** ${formatDate(sprint.completeDate)}`);
322
+ }
323
+ if (sprint.goal) {
324
+ lines.push('');
325
+ lines.push('## Goal');
326
+ lines.push(sprint.goal);
327
+ }
328
+ if (sprint.issues && sprint.issues.length > 0) {
329
+ lines.push('');
330
+ lines.push(`## Issues (${sprint.issues.length})`);
331
+ // Group by status
332
+ const byStatus = {};
333
+ for (const issue of sprint.issues) {
334
+ const status = issue.status || 'Unknown';
335
+ if (!byStatus[status])
336
+ byStatus[status] = [];
337
+ byStatus[status].push(issue);
338
+ }
339
+ for (const [status, statusIssues] of Object.entries(byStatus)) {
340
+ lines.push(`### ${status} (${statusIssues.length})`);
341
+ for (const issue of statusIssues) {
342
+ lines.push(`- ${issue.key}: ${issue.summary}${issue.assignee ? ` [${issue.assignee}]` : ''}`);
343
+ }
344
+ }
345
+ }
346
+ return lines.join('\n');
347
+ }
348
+ export function renderFilter(filter) {
349
+ const lines = [];
350
+ lines.push(`# Filter: ${filter.name}`);
351
+ lines.push(`**ID:** ${filter.id}`);
352
+ lines.push(`**Owner:** ${filter.owner}`);
353
+ lines.push(`**Favorite:** ${filter.favourite ? 'Yes' : 'No'}`);
354
+ if (filter.jql) {
355
+ lines.push('');
356
+ lines.push('## JQL');
357
+ lines.push('```');
358
+ lines.push(filter.jql);
359
+ lines.push('```');
360
+ }
361
+ if (filter.description) {
362
+ lines.push('');
363
+ lines.push('## Description');
364
+ lines.push(filter.description);
365
+ }
366
+ if (filter.issueCount !== undefined) {
367
+ lines.push('');
368
+ lines.push(`**Matches:** ${filter.issueCount} issues`);
369
+ }
370
+ lines.push('');
371
+ lines.push('---');
372
+ lines.push(`Tip: Use manage_jira_filter with operation="execute_filter" and filterId="${filter.id}" to run this filter`);
373
+ return lines.join('\n');
374
+ }
375
+ export function renderFilterList(filters) {
376
+ const lines = [];
377
+ lines.push(`# Filters (${filters.length})`);
378
+ lines.push('');
379
+ // Separate favorites
380
+ const favorites = filters.filter(f => f.favourite);
381
+ const others = filters.filter(f => !f.favourite);
382
+ if (favorites.length > 0) {
383
+ lines.push('## Favorites');
384
+ for (const filter of favorites) {
385
+ lines.push(`- **${filter.name}** (id: ${filter.id}) - ${filter.owner}`);
386
+ }
387
+ lines.push('');
388
+ }
389
+ if (others.length > 0) {
390
+ lines.push('## Other Filters');
391
+ for (const filter of others) {
392
+ lines.push(`- ${filter.name} (id: ${filter.id}) - ${filter.owner}`);
393
+ }
394
+ }
395
+ return lines.join('\n');
396
+ }
397
+ // ============================================================================
398
+ // Export convenience object
399
+ // ============================================================================
400
+ export const MarkdownRenderer = {
401
+ // Issues
402
+ renderIssue,
403
+ renderIssueSearchResults,
404
+ // Projects
405
+ renderProject,
406
+ renderProjectList,
407
+ // Boards
408
+ renderBoard,
409
+ renderBoardList,
410
+ // Sprints
411
+ renderSprint,
412
+ // Filters
413
+ renderFilter,
414
+ renderFilterList,
415
+ // Helpers (exposed for custom use)
416
+ helpers: {
417
+ formatDate,
418
+ formatStatus,
419
+ truncate,
420
+ stripHtml,
421
+ }
422
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
4
4
  "description": "Model Context Protocol (MCP) server for Jira Cloud - enables AI assistants to interact with Jira",
5
5
  "type": "module",
6
6
  "bin": {