@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.
- package/build/client/jira-client.js +33 -7
- package/build/docs/tool-documentation.js +1 -1
- package/build/handlers/analysis-handler.js +47 -2
- package/build/handlers/resource-handlers.js +8 -1
- package/build/index.js +16 -1
- package/build/mcp/markdown-renderer.js +17 -4
- package/build/prompts/prompt-definitions.js +34 -0
- package/build/prompts/prompt-messages.js +111 -0
- package/build/schemas/tool-schemas.js +1 -1
- package/build/utils/next-steps.js +4 -1
- package/build/utils/text-processing.js +197 -1
- package/package.json +1 -1
|
@@ -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:
|
|
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 ? '
|
|
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.
|
|
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: '
|
|
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 —
|
|
125
|
+
// Description — already markdown from ADF conversion
|
|
126
126
|
if (issue.description) {
|
|
127
127
|
lines.push('');
|
|
128
128
|
lines.push('Description:');
|
|
129
|
-
lines.push(
|
|
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
|
-
|
|
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 =
|
|
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", "
|
|
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