@aaronsb/jira-cloud-mcp 0.5.10 → 0.6.0
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/graph-object-cache.js +127 -0
- package/build/client/graphql-client.js +95 -0
- package/build/client/graphql-hierarchy.js +253 -0
- package/build/client/jira-client.js +33 -0
- package/build/docs/tool-documentation.js +8 -0
- package/build/handlers/analysis-handler.js +300 -9
- package/build/handlers/plan-handler.js +351 -0
- package/build/index.js +65 -1
- package/build/prompts/prompt-messages.js +2 -2
- package/build/schemas/tool-schemas.js +46 -6
- package/build/utils/next-steps.js +51 -1
- package/package.json +1 -1
- package/build/worker.js +0 -200
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import { GraphQLHierarchyWalker, collectLeaves, computeDepth, walkTree } from '../client/graphql-hierarchy.js';
|
|
3
|
+
import { planNextSteps } from '../utils/next-steps.js';
|
|
4
|
+
import { normalizeArgs } from '../utils/normalize-args.js';
|
|
5
|
+
const ALL_ROLLUPS = ['dates', 'points', 'progress', 'assignees'];
|
|
6
|
+
const MAX_CHILDREN_DISPLAY = 20;
|
|
7
|
+
export async function handlePlanRequest(_jiraClient, graphqlClient, request, cache) {
|
|
8
|
+
const args = normalizeArgs(request.params?.arguments ?? {});
|
|
9
|
+
const issueKey = args.issueKey;
|
|
10
|
+
if (!issueKey) {
|
|
11
|
+
throw new McpError(ErrorCode.InvalidParams, 'issueKey is required for analyze_jira_plan');
|
|
12
|
+
}
|
|
13
|
+
const operation = args.operation ?? 'analyze';
|
|
14
|
+
// Handle release operation
|
|
15
|
+
if (operation === 'release') {
|
|
16
|
+
if (!cache) {
|
|
17
|
+
return { content: [{ type: 'text', text: 'Cache not available.' }] };
|
|
18
|
+
}
|
|
19
|
+
const released = cache.release(issueKey);
|
|
20
|
+
return {
|
|
21
|
+
content: [{
|
|
22
|
+
type: 'text',
|
|
23
|
+
text: released
|
|
24
|
+
? `Released cached walk for ${issueKey}.`
|
|
25
|
+
: `No cached walk found for ${issueKey}.`,
|
|
26
|
+
}],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const rollups = (Array.isArray(args.rollups) ? args.rollups : ALL_ROLLUPS);
|
|
30
|
+
const mode = args.mode ?? 'rollup';
|
|
31
|
+
const focus = args.focus;
|
|
32
|
+
// Try cache-first path
|
|
33
|
+
if (cache) {
|
|
34
|
+
const status = cache.getStatus(issueKey);
|
|
35
|
+
if (status.state === 'error') {
|
|
36
|
+
cache.release(issueKey);
|
|
37
|
+
return {
|
|
38
|
+
content: [{
|
|
39
|
+
type: 'text',
|
|
40
|
+
text: `Walk failed for ${issueKey}: ${status.error ?? 'unknown error'}. Cleared from cache — call again to retry.`,
|
|
41
|
+
}],
|
|
42
|
+
isError: true,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (status.state === 'walking') {
|
|
46
|
+
return {
|
|
47
|
+
content: [{
|
|
48
|
+
type: 'text',
|
|
49
|
+
text: `Walking hierarchy for ${issueKey}... ${status.itemCount} items collected so far.\nCall again to check progress or wait for completion.`,
|
|
50
|
+
}],
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (status.state === 'not_found') {
|
|
54
|
+
cache.startWalk(issueKey, graphqlClient);
|
|
55
|
+
return {
|
|
56
|
+
content: [{
|
|
57
|
+
type: 'text',
|
|
58
|
+
text: `Started hierarchy walk for ${issueKey}. Call again to check progress.\n\n*The walk runs in the background — subsequent calls will show progress or full results once complete.*`,
|
|
59
|
+
}],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (status.state === 'complete' || status.state === 'stale') {
|
|
63
|
+
const cached = cache.get(issueKey);
|
|
64
|
+
const staleNote = status.stale
|
|
65
|
+
? '> **Note:** This data may be stale. Call again to refresh, or use `operation: "release"` to clear.\n\n'
|
|
66
|
+
: '';
|
|
67
|
+
if (status.stale) {
|
|
68
|
+
cache.startWalk(issueKey, graphqlClient);
|
|
69
|
+
}
|
|
70
|
+
// Focus mode: windowed view of a specific node
|
|
71
|
+
if (focus) {
|
|
72
|
+
const output = renderFocusView(cached.tree, focus, rollups);
|
|
73
|
+
return { content: [{ type: 'text', text: staleNote + output }] };
|
|
74
|
+
}
|
|
75
|
+
// Default: summary + entry points (bounded)
|
|
76
|
+
const rollupResult = GraphQLHierarchyWalker.computeRollups(cached.tree);
|
|
77
|
+
const output = mode === 'gaps'
|
|
78
|
+
? renderGapsSummary(cached.tree, rollups, rollupResult)
|
|
79
|
+
: renderOverview(cached.tree, issueKey, cached.itemCount, rollups, rollupResult);
|
|
80
|
+
return {
|
|
81
|
+
content: [{
|
|
82
|
+
type: 'text',
|
|
83
|
+
text: staleNote + output + planNextSteps(issueKey, mode, rollupResult.conflicts, rollupResult),
|
|
84
|
+
}],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Fallback: no cache, walk synchronously with original limits
|
|
89
|
+
const walker = new GraphQLHierarchyWalker(graphqlClient);
|
|
90
|
+
let tree;
|
|
91
|
+
let totalItems;
|
|
92
|
+
try {
|
|
93
|
+
({ tree, totalItems } = await walker.walkDown(issueKey));
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
const message = err.message;
|
|
97
|
+
if (message.includes('not found')) {
|
|
98
|
+
throw new McpError(ErrorCode.InvalidParams, `Issue ${issueKey} not found.`);
|
|
99
|
+
}
|
|
100
|
+
throw new McpError(ErrorCode.InternalError, `Hierarchy walk failed: ${message}`);
|
|
101
|
+
}
|
|
102
|
+
const rollupResult = GraphQLHierarchyWalker.computeRollups(tree);
|
|
103
|
+
const output = renderOverview(tree, issueKey, totalItems, rollups);
|
|
104
|
+
return {
|
|
105
|
+
content: [{
|
|
106
|
+
type: 'text',
|
|
107
|
+
text: output + planNextSteps(issueKey, mode, rollupResult.conflicts, rollupResult),
|
|
108
|
+
}],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// --- Rendering: Overview (summary + entry points) ---
|
|
112
|
+
function renderOverview(tree, issueKey, totalItems, rollups, rollupResult) {
|
|
113
|
+
const lines = [];
|
|
114
|
+
const depth = computeDepth(tree);
|
|
115
|
+
rollupResult ??= GraphQLHierarchyWalker.computeRollups(tree);
|
|
116
|
+
lines.push(`# Plan: ${issueKey} — ${tree.issue.summary}`);
|
|
117
|
+
lines.push(`${totalItems} items, ${depth} levels deep | cached`);
|
|
118
|
+
lines.push('');
|
|
119
|
+
renderSummaryBlock(tree, lines, rollups, rollupResult);
|
|
120
|
+
lines.push('');
|
|
121
|
+
// Entry points: immediate children with their rollup summaries
|
|
122
|
+
if (tree.children.length > 0) {
|
|
123
|
+
lines.push('## Children');
|
|
124
|
+
lines.push('');
|
|
125
|
+
const shown = tree.children.slice(0, MAX_CHILDREN_DISPLAY);
|
|
126
|
+
for (const child of shown) {
|
|
127
|
+
renderNodeLine(child, lines, rollups);
|
|
128
|
+
}
|
|
129
|
+
if (tree.children.length > MAX_CHILDREN_DISPLAY) {
|
|
130
|
+
lines.push(`*...and ${tree.children.length - MAX_CHILDREN_DISPLAY} more — use \`focus\` to navigate*`);
|
|
131
|
+
}
|
|
132
|
+
lines.push('');
|
|
133
|
+
lines.push('*Use `focus: "ISSUE-KEY"` to explore any node and its neighborhood.*');
|
|
134
|
+
}
|
|
135
|
+
return lines.join('\n');
|
|
136
|
+
}
|
|
137
|
+
// --- Rendering: Focus (windowed view of a specific node) ---
|
|
138
|
+
function findInTree(node, key, parent = null) {
|
|
139
|
+
if (node.issue.key === key)
|
|
140
|
+
return { node, parent };
|
|
141
|
+
for (const child of node.children) {
|
|
142
|
+
const found = findInTree(child, key, node);
|
|
143
|
+
if (found)
|
|
144
|
+
return found;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
function renderFocusView(tree, focusKey, rollups) {
|
|
149
|
+
const found = findInTree(tree, focusKey);
|
|
150
|
+
if (!found) {
|
|
151
|
+
return `Issue ${focusKey} not found in cached hierarchy. Available root: ${tree.issue.key}`;
|
|
152
|
+
}
|
|
153
|
+
const focusNode = found.node;
|
|
154
|
+
const parentNode = found.parent;
|
|
155
|
+
const lines = [];
|
|
156
|
+
const rollupResult = GraphQLHierarchyWalker.computeRollups(focusNode);
|
|
157
|
+
lines.push(`# Focus: ${focusNode.issue.key} — ${focusNode.issue.summary}`);
|
|
158
|
+
lines.push(`[${focusNode.issue.issueType}] ${focusNode.issue.status}`);
|
|
159
|
+
lines.push('');
|
|
160
|
+
// Parent context
|
|
161
|
+
if (parentNode) {
|
|
162
|
+
const parentRollup = GraphQLHierarchyWalker.computeRollups(parentNode);
|
|
163
|
+
lines.push(`**Parent:** ${parentNode.issue.key} — ${parentNode.issue.summary} [${parentNode.issue.issueType}]`);
|
|
164
|
+
lines.push(` Progress: ${parentRollup.resolvedItems}/${parentRollup.totalItems} (${parentRollup.progressPct}%)`);
|
|
165
|
+
lines.push('');
|
|
166
|
+
}
|
|
167
|
+
// This node's details
|
|
168
|
+
renderSummaryBlock(focusNode, lines, rollups, rollupResult);
|
|
169
|
+
lines.push('');
|
|
170
|
+
// Siblings (if has parent)
|
|
171
|
+
if (parentNode) {
|
|
172
|
+
const siblings = parentNode.children.filter(c => c.issue.key !== focusKey);
|
|
173
|
+
if (siblings.length > 0) {
|
|
174
|
+
lines.push(`## Siblings (${siblings.length})`);
|
|
175
|
+
lines.push('');
|
|
176
|
+
const shown = siblings.slice(0, MAX_CHILDREN_DISPLAY);
|
|
177
|
+
for (const sib of shown) {
|
|
178
|
+
renderNodeLine(sib, lines, rollups);
|
|
179
|
+
}
|
|
180
|
+
if (siblings.length > MAX_CHILDREN_DISPLAY) {
|
|
181
|
+
lines.push(`*...and ${siblings.length - MAX_CHILDREN_DISPLAY} more*`);
|
|
182
|
+
}
|
|
183
|
+
lines.push('');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// Children
|
|
187
|
+
if (focusNode.children.length > 0) {
|
|
188
|
+
lines.push(`## Children (${focusNode.children.length})`);
|
|
189
|
+
lines.push('');
|
|
190
|
+
const shown = focusNode.children.slice(0, MAX_CHILDREN_DISPLAY);
|
|
191
|
+
for (const child of shown) {
|
|
192
|
+
renderNodeLine(child, lines, rollups);
|
|
193
|
+
}
|
|
194
|
+
if (focusNode.children.length > MAX_CHILDREN_DISPLAY) {
|
|
195
|
+
lines.push(`*...and ${focusNode.children.length - MAX_CHILDREN_DISPLAY} more*`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
lines.push('*Leaf node — no children*');
|
|
200
|
+
}
|
|
201
|
+
return lines.join('\n');
|
|
202
|
+
}
|
|
203
|
+
/** Render a single node as a compact line with rollup summary */
|
|
204
|
+
function renderNodeLine(node, lines, rollups) {
|
|
205
|
+
const statusCat = node.issue.statusCategory.toLowerCase();
|
|
206
|
+
const icon = statusCat === 'done' ? '✓' : statusCat === 'in progress' ? '●' : '○';
|
|
207
|
+
const parts = [];
|
|
208
|
+
parts.push(`${icon} **${node.issue.key}**: ${node.issue.summary} [${node.issue.issueType}]`);
|
|
209
|
+
const details = [];
|
|
210
|
+
if (node.children.length > 0) {
|
|
211
|
+
const rollup = GraphQLHierarchyWalker.computeRollups(node);
|
|
212
|
+
if (rollups.includes('progress')) {
|
|
213
|
+
details.push(`${rollup.resolvedItems}/${rollup.totalItems} (${rollup.progressPct}%)`);
|
|
214
|
+
}
|
|
215
|
+
if (rollups.includes('points') && rollup.totalPoints > 0) {
|
|
216
|
+
details.push(`${rollup.earnedPoints}/${rollup.totalPoints} pts`);
|
|
217
|
+
}
|
|
218
|
+
if (rollups.includes('dates') && (rollup.rolledUpStart || rollup.rolledUpEnd)) {
|
|
219
|
+
details.push(`${rollup.rolledUpStart ?? '—'} – ${rollup.rolledUpEnd ?? '—'}`);
|
|
220
|
+
}
|
|
221
|
+
if (rollup.conflicts.length > 0) {
|
|
222
|
+
details.push(`${rollup.conflicts.length} conflicts`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
if (node.issue.assignee)
|
|
227
|
+
details.push(node.issue.assignee);
|
|
228
|
+
if (rollups.includes('dates') && (node.issue.startDate || node.issue.dueDate)) {
|
|
229
|
+
details.push(`${node.issue.startDate ?? '—'} – ${node.issue.dueDate ?? '—'}`);
|
|
230
|
+
}
|
|
231
|
+
if (rollups.includes('points') && node.issue.storyPoints != null) {
|
|
232
|
+
details.push(`${node.issue.storyPoints} pts`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (details.length > 0) {
|
|
236
|
+
lines.push(`- ${parts[0]}`);
|
|
237
|
+
lines.push(` ${details.join(' | ')}`);
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
lines.push(`- ${parts[0]}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// --- Rendering: Gaps summary (bounded) ---
|
|
244
|
+
function renderGapsSummary(tree, rollups, rollupResult) {
|
|
245
|
+
const lines = [];
|
|
246
|
+
rollupResult ??= GraphQLHierarchyWalker.computeRollups(tree);
|
|
247
|
+
lines.push(`# Gaps: ${tree.issue.key} — ${tree.issue.summary}`);
|
|
248
|
+
lines.push('');
|
|
249
|
+
const gaps = [];
|
|
250
|
+
for (const c of rollupResult.conflicts) {
|
|
251
|
+
gaps.push(`- **${c.issueKey}** [${c.type}]: ${c.message}`);
|
|
252
|
+
}
|
|
253
|
+
walkTree(tree, (node) => {
|
|
254
|
+
if (node.children.length === 0)
|
|
255
|
+
return;
|
|
256
|
+
if (rollups.includes('dates')) {
|
|
257
|
+
const undated = node.children.filter(c => !c.issue.startDate && !c.issue.dueDate);
|
|
258
|
+
const dated = node.children.length - undated.length;
|
|
259
|
+
if (undated.length > 0 && dated > 0) {
|
|
260
|
+
gaps.push(`- **${node.issue.key}**: ${undated.length}/${node.children.length} children have no dates`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
if (rollups.includes('points')) {
|
|
264
|
+
const unestimated = node.children.filter(c => c.issue.storyPoints == null);
|
|
265
|
+
const estimated = node.children.length - unestimated.length;
|
|
266
|
+
if (unestimated.length > 0 && estimated > 0) {
|
|
267
|
+
gaps.push(`- **${node.issue.key}**: ${unestimated.length}/${node.children.length} children have no story points`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (rollups.includes('assignees')) {
|
|
271
|
+
const unassigned = node.children.filter(c => !c.issue.assignee && !c.issue.isResolved);
|
|
272
|
+
if (unassigned.length > 0) {
|
|
273
|
+
gaps.push(`- **${node.issue.key}**: ${unassigned.length} active children unassigned`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
if (gaps.length === 0) {
|
|
278
|
+
lines.push('No gaps or conflicts detected.');
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
const unique = [...new Set(gaps)];
|
|
282
|
+
// Cap output to first 30 gaps
|
|
283
|
+
const shown = unique.slice(0, 30);
|
|
284
|
+
lines.push(...shown);
|
|
285
|
+
if (unique.length > 30) {
|
|
286
|
+
lines.push(`\n*...and ${unique.length - 30} more. Use \`focus\` on a specific subtree to narrow down.*`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return lines.join('\n');
|
|
290
|
+
}
|
|
291
|
+
// --- Shared rendering ---
|
|
292
|
+
function renderSummaryBlock(tree, lines, rollups, result) {
|
|
293
|
+
if (rollups.includes('dates')) {
|
|
294
|
+
const own = `${tree.issue.startDate ?? '—'} – ${tree.issue.dueDate ?? '—'}`;
|
|
295
|
+
const derived = `${result.rolledUpStart ?? '—'} – ${result.rolledUpEnd ?? '—'}`;
|
|
296
|
+
lines.push(`**Dates:** own ${own} | rolled-up ${derived}`);
|
|
297
|
+
}
|
|
298
|
+
if (rollups.includes('points') && result.totalPoints > 0) {
|
|
299
|
+
lines.push(`**Points:** ${result.totalPoints} total, ${result.earnedPoints} earned`);
|
|
300
|
+
}
|
|
301
|
+
if (rollups.includes('progress')) {
|
|
302
|
+
lines.push(`**Progress:** ${result.resolvedItems}/${result.totalItems} resolved (${result.progressPct}%)`);
|
|
303
|
+
}
|
|
304
|
+
if (rollups.includes('assignees') && result.assignees.length > 0) {
|
|
305
|
+
lines.push(`**Team:** ${result.assignees.join(', ')}${result.unassignedCount > 0 ? ` | ${result.unassignedCount} unassigned` : ''}`);
|
|
306
|
+
}
|
|
307
|
+
if (result.conflicts.length > 0) {
|
|
308
|
+
lines.push(`**Conflicts:** ${result.conflicts.length} detected`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/** Full tree renderer — kept for analysis-handler hierarchy metric (small trees only) */
|
|
312
|
+
export function renderRollupTree(node, lines, rollups, prefix, isLast) {
|
|
313
|
+
const connector = prefix === '' ? '' : (isLast ? '└── ' : '├── ');
|
|
314
|
+
const statusCat = node.issue.statusCategory.toLowerCase();
|
|
315
|
+
const icon = statusCat === 'done' ? '✓' : statusCat === 'in progress' ? '●' : '○';
|
|
316
|
+
const label = `${icon} **${node.issue.key}**: ${node.issue.summary} [${node.issue.issueType}]`;
|
|
317
|
+
lines.push(`${prefix}${connector}${label}`);
|
|
318
|
+
const indent = prefix + (prefix === '' ? '' : (isLast ? ' ' : '│ '));
|
|
319
|
+
if (rollups.includes('dates')) {
|
|
320
|
+
const start = node.issue.startDate ?? '—';
|
|
321
|
+
const due = node.issue.dueDate ?? '—';
|
|
322
|
+
if (start !== '—' || due !== '—' || node.children.length > 0) {
|
|
323
|
+
let dateLine = `${indent} ${start} – ${due}`;
|
|
324
|
+
if (node.children.length > 0) {
|
|
325
|
+
const childRollup = GraphQLHierarchyWalker.computeRollups(node);
|
|
326
|
+
if (childRollup.rolledUpStart || childRollup.rolledUpEnd) {
|
|
327
|
+
dateLine += ` | rolled-up ${childRollup.rolledUpStart ?? '—'} – ${childRollup.rolledUpEnd ?? '—'}`;
|
|
328
|
+
}
|
|
329
|
+
const conflict = childRollup.conflicts.find(c => c.issueKey === node.issue.key);
|
|
330
|
+
if (conflict)
|
|
331
|
+
dateLine += ` ⚠️ ${conflict.message}`;
|
|
332
|
+
}
|
|
333
|
+
lines.push(dateLine);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (rollups.includes('points') && node.issue.storyPoints != null) {
|
|
337
|
+
lines.push(`${indent} ${node.issue.storyPoints} pts`);
|
|
338
|
+
}
|
|
339
|
+
if (rollups.includes('progress') && node.children.length > 0) {
|
|
340
|
+
const leaves = collectLeaves(node);
|
|
341
|
+
const resolved = leaves.filter(l => l.isResolved).length;
|
|
342
|
+
lines.push(`${indent} Progress: ${resolved}/${leaves.length} (${leaves.length > 0 ? Math.round(resolved / leaves.length * 100) : 0}%)`);
|
|
343
|
+
}
|
|
344
|
+
if (rollups.includes('assignees') && node.children.length === 0 && node.issue.assignee) {
|
|
345
|
+
lines.push(`${indent} ${node.issue.assignee}`);
|
|
346
|
+
}
|
|
347
|
+
const childPrefix = prefix + (prefix === '' ? '' : (isLast ? ' ' : '│ '));
|
|
348
|
+
node.children.forEach((child, i) => {
|
|
349
|
+
renderRollupTree(child, lines, rollups, childPrefix, i === node.children.length - 1);
|
|
350
|
+
});
|
|
351
|
+
}
|
package/build/index.js
CHANGED
|
@@ -4,11 +4,14 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
4
4
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
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
|
+
import { GraphObjectCache } from './client/graph-object-cache.js';
|
|
8
|
+
import { discoverCloudId, GraphQLClient } from './client/graphql-client.js';
|
|
7
9
|
import { JiraClient } from './client/jira-client.js';
|
|
8
10
|
import { handleAnalysisRequest } from './handlers/analysis-handler.js';
|
|
9
11
|
import { handleBoardRequest } from './handlers/board-handlers.js';
|
|
10
12
|
import { handleFilterRequest } from './handlers/filter-handlers.js';
|
|
11
13
|
import { handleIssueRequest } from './handlers/issue-handlers.js';
|
|
14
|
+
import { handlePlanRequest } from './handlers/plan-handler.js';
|
|
12
15
|
import { handleProjectRequest } from './handlers/project-handlers.js';
|
|
13
16
|
import { createQueueHandler } from './handlers/queue-handler.js';
|
|
14
17
|
import { setupResourceHandlers } from './handlers/resource-handlers.js';
|
|
@@ -32,9 +35,34 @@ if (!JIRA_EMAIL || !JIRA_API_TOKEN || !JIRA_HOST) {
|
|
|
32
35
|
}
|
|
33
36
|
const require = createRequire(import.meta.url);
|
|
34
37
|
const { version } = require('../package.json');
|
|
38
|
+
/** Map manage_jira_issue update args to GraphIssue field patches */
|
|
39
|
+
function extractChangedFields(args) {
|
|
40
|
+
const fields = {};
|
|
41
|
+
if ('dueDate' in args)
|
|
42
|
+
fields.dueDate = args.dueDate;
|
|
43
|
+
if ('summary' in args)
|
|
44
|
+
fields.summary = args.summary;
|
|
45
|
+
if ('assignee' in args)
|
|
46
|
+
fields.assignee = args.assignee;
|
|
47
|
+
if ('storyPoints' in args)
|
|
48
|
+
fields.storyPoints = args.storyPoints;
|
|
49
|
+
// startDate may come via customFields — check both
|
|
50
|
+
if ('startDate' in args)
|
|
51
|
+
fields.startDate = args.startDate;
|
|
52
|
+
const customFields = args.customFields;
|
|
53
|
+
if (customFields) {
|
|
54
|
+
for (const [key, val] of Object.entries(customFields)) {
|
|
55
|
+
if (key.toLowerCase().includes('start'))
|
|
56
|
+
fields.startDate = val;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return fields;
|
|
60
|
+
}
|
|
35
61
|
class JiraServer {
|
|
36
62
|
server;
|
|
37
63
|
jiraClient;
|
|
64
|
+
graphqlClient = null;
|
|
65
|
+
cache = new GraphObjectCache();
|
|
38
66
|
constructor() {
|
|
39
67
|
const serverName = process.env.MCP_SERVER_NAME || 'jira-cloud';
|
|
40
68
|
console.error(`Initializing Jira MCP server: ${serverName}`);
|
|
@@ -56,6 +84,8 @@ class JiraServer {
|
|
|
56
84
|
this.setupHandlers();
|
|
57
85
|
// Start async field discovery (non-blocking)
|
|
58
86
|
fieldDiscovery.startAsync(this.jiraClient.v3Client);
|
|
87
|
+
// CloudId discovery happens in run() before server connects — must complete
|
|
88
|
+
// before ListTools so analyze_jira_plan is registered if available.
|
|
59
89
|
this.server.onerror = (error) => console.error('[MCP Error]', error);
|
|
60
90
|
process.on('SIGINT', async () => {
|
|
61
91
|
await this.server.close();
|
|
@@ -66,6 +96,7 @@ class JiraServer {
|
|
|
66
96
|
// Set up required MCP protocol handlers
|
|
67
97
|
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
68
98
|
tools: Object.entries(toolSchemas)
|
|
99
|
+
.filter(([key]) => key !== 'analyze_jira_plan' || this.graphqlClient !== null)
|
|
69
100
|
.map(([key, schema]) => ({
|
|
70
101
|
name: key,
|
|
71
102
|
description: schema.description,
|
|
@@ -101,6 +132,7 @@ class JiraServer {
|
|
|
101
132
|
// Set up tool handlers
|
|
102
133
|
this.server.setRequestHandler(CallToolRequestSchema, async (request, _extra) => {
|
|
103
134
|
console.error('Received request:', JSON.stringify(request, null, 2));
|
|
135
|
+
this.cache.tick();
|
|
104
136
|
const { name } = request.params;
|
|
105
137
|
console.error(`Handling tool request: ${name}`);
|
|
106
138
|
try {
|
|
@@ -110,11 +142,14 @@ class JiraServer {
|
|
|
110
142
|
manage_jira_board: handleBoardRequest,
|
|
111
143
|
manage_jira_sprint: handleSprintRequest,
|
|
112
144
|
manage_jira_filter: handleFilterRequest,
|
|
113
|
-
analyze_jira_issues: handleAnalysisRequest,
|
|
145
|
+
analyze_jira_issues: (client, req) => handleAnalysisRequest(client, req, this.graphqlClient, this.cache),
|
|
114
146
|
};
|
|
115
147
|
const handlers = {
|
|
116
148
|
...toolHandlers,
|
|
117
149
|
queue_jira_operations: createQueueHandler(toolHandlers, JIRA_HOST),
|
|
150
|
+
...(this.graphqlClient ? {
|
|
151
|
+
analyze_jira_plan: (_client, req) => handlePlanRequest(this.jiraClient, this.graphqlClient, req, this.cache),
|
|
152
|
+
} : {}),
|
|
118
153
|
};
|
|
119
154
|
const handler = handlers[name];
|
|
120
155
|
if (!handler) {
|
|
@@ -131,6 +166,21 @@ class JiraServer {
|
|
|
131
166
|
response.content[0].text += `\n\n---\n**💡 Efficiency tip:** You've made ${consecutiveIssueCalls} consecutive \`manage_jira_issue\` calls. Consider using \`queue_jira_operations\` to batch multiple issue operations into a single call — it's faster and uses less context.`;
|
|
132
167
|
consecutiveIssueCalls = 0;
|
|
133
168
|
}
|
|
169
|
+
// Surgical cache patching — update cached issues on mutations
|
|
170
|
+
const reqArgs = request.params.arguments;
|
|
171
|
+
const op = reqArgs?.operation;
|
|
172
|
+
if ((op === 'update' || op === 'transition') && this.cache.walks.size > 0) {
|
|
173
|
+
const issueKey = reqArgs?.issueKey;
|
|
174
|
+
if (issueKey) {
|
|
175
|
+
const changedFields = extractChangedFields(reqArgs);
|
|
176
|
+
if (Object.keys(changedFields).length > 0) {
|
|
177
|
+
const patched = this.cache.patch(issueKey, changedFields);
|
|
178
|
+
if (patched) {
|
|
179
|
+
console.error(`[graph-cache] Patched ${issueKey} in cache`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
134
184
|
}
|
|
135
185
|
else {
|
|
136
186
|
consecutiveIssueCalls = 0;
|
|
@@ -178,6 +228,20 @@ class JiraServer {
|
|
|
178
228
|
});
|
|
179
229
|
}
|
|
180
230
|
async run() {
|
|
231
|
+
// Discover cloudId before connecting — must complete before ListTools
|
|
232
|
+
try {
|
|
233
|
+
const cloudId = await discoverCloudId(JIRA_HOST, JIRA_EMAIL, JIRA_API_TOKEN);
|
|
234
|
+
if (cloudId) {
|
|
235
|
+
this.graphqlClient = new GraphQLClient(JIRA_EMAIL, JIRA_API_TOKEN, cloudId);
|
|
236
|
+
console.error(`[jira-cloud] GraphQL client ready (cloudId: ${cloudId.slice(0, 8)}...)`);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
console.error('[jira-cloud] GraphQL/Plans unavailable — analyze_jira_plan disabled');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
console.error('[jira-cloud] GraphQL discovery failed — analyze_jira_plan disabled');
|
|
244
|
+
}
|
|
181
245
|
const transport = new StdioServerTransport();
|
|
182
246
|
await this.server.connect(transport);
|
|
183
247
|
console.error('Jira MCP server running on stdio');
|
|
@@ -19,8 +19,8 @@ Operation 0 — Save the query as a reusable filter:
|
|
|
19
19
|
Operation 1 — Summary with data quality signals (uses $0.filterId):
|
|
20
20
|
{"tool":"analyze_jira_issues","args":{"filterId":"$0.filterId","metrics":["summary"],"groupBy":"priority","compute":["rot_pct = backlog_rot / open * 100","stale_pct = stale / open * 100","gap_pct = no_estimate / open * 100"]}}
|
|
21
21
|
|
|
22
|
-
Operation 2 —
|
|
23
|
-
{"tool":"analyze_jira_issues","args":{"filterId":"$0.filterId","metrics":["
|
|
22
|
+
Operation 2 — Flow analysis for transition patterns and bottlenecks:
|
|
23
|
+
{"tool":"analyze_jira_issues","args":{"filterId":"$0.filterId","metrics":["flow"],"maxResults":100}}
|
|
24
24
|
|
|
25
25
|
After the pipeline completes, summarize findings:
|
|
26
26
|
- What percentage of the backlog is rotting (no owner, no dates, untouched)?
|
|
@@ -326,30 +326,34 @@ export const toolSchemas = {
|
|
|
326
326
|
},
|
|
327
327
|
analyze_jira_issues: {
|
|
328
328
|
name: 'analyze_jira_issues',
|
|
329
|
-
description: 'Compute project metrics over issues selected by JQL or a saved filter. For counting and breakdown questions ("how many by status/assignee/priority"), use metrics: ["summary"] with groupBy — this gives exact counts with no issue cap. Use detail metrics (points, time, schedule, cycle, distribution)
|
|
329
|
+
description: 'Compute project metrics over issues selected by JQL or a saved filter. For counting and breakdown questions ("how many by status/assignee/priority"), use metrics: ["summary"] with groupBy — this gives exact counts with no issue cap. Use detail metrics (points, time, schedule, cycle, distribution) for per-issue analysis (capped at maxResults). Use flow for status transition patterns — how issues move through statuses, where they bounce, and how long they stay. Tip: save complex JQL as a filter with manage_jira_filter, then reuse the filterId here for repeated analysis. Read jira://analysis/recipes for composition patterns.',
|
|
330
330
|
inputSchema: {
|
|
331
331
|
type: 'object',
|
|
332
332
|
properties: {
|
|
333
333
|
jql: {
|
|
334
334
|
type: 'string',
|
|
335
|
-
description: 'JQL query selecting the issues to analyze. Either jql or
|
|
335
|
+
description: 'JQL query selecting the issues to analyze. Either jql, filterId, or dataRef is required (dataRef > filterId > jql precedence). Examples: "project in (AA, GC, LGS)", "sprint in openSprints()", "assignee = currentUser() AND resolution = Unresolved".',
|
|
336
336
|
},
|
|
337
337
|
filterId: {
|
|
338
338
|
type: 'string',
|
|
339
339
|
description: 'ID of a saved Jira filter to use as the query source. The filter\'s JQL is resolved automatically. Use this to run different analyses against a saved query without repeating the JQL. Create filters with manage_jira_filter.',
|
|
340
340
|
},
|
|
341
|
+
dataRef: {
|
|
342
|
+
type: 'string',
|
|
343
|
+
description: 'Root issue key of a cached hierarchy walk. Analyzes cached plan data without re-fetching from Jira. Start a walk with analyze_jira_plan first. Supports all metrics except flow. Takes precedence over jql/filterId.',
|
|
344
|
+
},
|
|
341
345
|
metrics: {
|
|
342
346
|
type: 'array',
|
|
343
347
|
items: {
|
|
344
348
|
type: 'string',
|
|
345
|
-
enum: ['summary', 'points', 'time', 'schedule', 'cycle', 'distribution', 'cube_setup'],
|
|
349
|
+
enum: ['summary', 'points', 'time', 'schedule', 'cycle', 'distribution', 'flow', 'hierarchy', 'cube_setup'],
|
|
346
350
|
},
|
|
347
|
-
description: 'Which metric groups to compute. summary = exact counts via count API (no issue cap, fastest) — use with groupBy for "how many by assignee/status/priority" questions. distribution = approximate counts from fetched issues (capped by maxResults — use summary + groupBy instead when you need exact counts). cube_setup = discover dimensions before cube queries. points = earned value/SPI. time = effort estimates. schedule = overdue/risk. cycle = lead time/throughput. Default: all detail metrics. For counting/breakdown questions, always prefer summary + groupBy over distribution.',
|
|
351
|
+
description: 'Which metric groups to compute. summary = exact counts via count API (no issue cap, fastest) — use with groupBy for "how many by assignee/status/priority" questions. distribution = approximate counts from fetched issues (capped by maxResults — use summary + groupBy instead when you need exact counts). flow = status transition analysis from bulk changelogs — entries per status, avg time in each, bounce rates, top bouncers. hierarchy = tree visualization with rollups for parent-child structures (requires GraphQL — opt-in like flow). cube_setup = discover dimensions before cube queries. points = earned value/SPI. time = effort estimates. schedule = overdue/risk. cycle = lead time/throughput. Default: all detail metrics (excluding flow and hierarchy — request explicitly). For counting/breakdown questions, always prefer summary + groupBy over distribution.',
|
|
348
352
|
},
|
|
349
353
|
groupBy: {
|
|
350
354
|
type: 'string',
|
|
351
|
-
enum: ['project', 'assignee', 'priority', 'issuetype'],
|
|
352
|
-
description: 'Split counts by this dimension — produces a breakdown table. Use with metrics: ["summary"] for exact counts. This is the correct approach for "how many issues per assignee/priority/type" questions. "project" produces a per-project comparison.',
|
|
355
|
+
enum: ['project', 'assignee', 'priority', 'issuetype', 'parent'],
|
|
356
|
+
description: 'Split counts by this dimension — produces a breakdown table. Use with metrics: ["summary"] for exact counts. This is the correct approach for "how many issues per assignee/priority/type" questions. "project" produces a per-project comparison. "parent" groups by parent issue — useful for seeing rollups per epic/initiative.',
|
|
353
357
|
},
|
|
354
358
|
compute: {
|
|
355
359
|
type: 'array',
|
|
@@ -371,6 +375,42 @@ export const toolSchemas = {
|
|
|
371
375
|
required: [],
|
|
372
376
|
},
|
|
373
377
|
},
|
|
378
|
+
analyze_jira_plan: {
|
|
379
|
+
name: 'analyze_jira_plan',
|
|
380
|
+
description: 'Analyze hierarchy rollups for any parent issue. Walks the issue tree via GraphQL, computes rolled-up dates, points, progress, assignees, and detects date conflicts. Results are cached server-side for fast re-analysis. Works on any Jira instance (no Plans/Premium required). For flat-set metrics use analyze_jira_issues (with dataRef to analyze cached plan data); for structure without rollups use manage_jira_issue hierarchy.',
|
|
381
|
+
inputSchema: {
|
|
382
|
+
type: 'object',
|
|
383
|
+
properties: {
|
|
384
|
+
operation: {
|
|
385
|
+
type: 'string',
|
|
386
|
+
enum: ['analyze', 'release'],
|
|
387
|
+
description: 'Operation to perform. analyze (default): walk hierarchy and compute rollups. release: free cached walk data for this issueKey.',
|
|
388
|
+
},
|
|
389
|
+
issueKey: {
|
|
390
|
+
type: 'string',
|
|
391
|
+
description: 'Issue key at the root of the plan tree (e.g., PROJ-100). Required.',
|
|
392
|
+
},
|
|
393
|
+
rollups: {
|
|
394
|
+
type: 'array',
|
|
395
|
+
items: {
|
|
396
|
+
type: 'string',
|
|
397
|
+
enum: ['dates', 'points', 'progress', 'assignees'],
|
|
398
|
+
},
|
|
399
|
+
description: 'Which rollup dimensions to include. Default: all.',
|
|
400
|
+
},
|
|
401
|
+
focus: {
|
|
402
|
+
type: 'string',
|
|
403
|
+
description: 'Issue key to focus on within the cached plan. Shows the node, its parent, siblings, and children — a windowed view for navigating large plans. Requires a completed walk.',
|
|
404
|
+
},
|
|
405
|
+
mode: {
|
|
406
|
+
type: 'string',
|
|
407
|
+
enum: ['rollup', 'gaps'],
|
|
408
|
+
description: 'Output mode. rollup (default): summary + entry points. gaps: conflicts and missing data only.',
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
required: ['issueKey'],
|
|
412
|
+
},
|
|
413
|
+
},
|
|
374
414
|
queue_jira_operations: {
|
|
375
415
|
name: 'queue_jira_operations',
|
|
376
416
|
description: 'Execute multiple Jira operations in a single call. Operations run sequentially with result references ($0.key) and per-operation error strategies (bail/continue). Powerful for analysis pipelines: create a filter, then run multiple analyze_jira_issues calls against $0.filterId with different groupBy/compute — all in one call.',
|
|
@@ -39,7 +39,7 @@ export function issueNextSteps(operation, issueKey) {
|
|
|
39
39
|
steps.push({ description: 'View the linked issue', tool: 'manage_jira_issue', example: { operation: 'get', issueKey } }, { description: 'Read available link types from jira://issue-link-types resource' });
|
|
40
40
|
break;
|
|
41
41
|
case 'hierarchy':
|
|
42
|
-
steps.push({ description: 'View a specific issue from the tree', tool: 'manage_jira_issue', example: { operation: 'get', issueKey } }, { description: 'Search for issues in this project', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql: `project = "${issueKey?.split('-')[0]}"` } });
|
|
42
|
+
steps.push({ description: 'View a specific issue from the tree', tool: 'manage_jira_issue', example: { operation: 'get', issueKey } }, { description: 'Analyze plan rollups (requires Jira Plans)', tool: 'analyze_jira_plan', example: { issueKey } }, { description: 'Search for issues in this project', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql: `project = "${issueKey?.split('-')[0]}"` } });
|
|
43
43
|
break;
|
|
44
44
|
}
|
|
45
45
|
return steps.length > 0 ? formatSteps(steps) : '';
|
|
@@ -125,6 +125,52 @@ export function boardNextSteps(operation, boardId) {
|
|
|
125
125
|
}
|
|
126
126
|
return steps.length > 0 ? formatSteps(steps) : '';
|
|
127
127
|
}
|
|
128
|
+
export function planNextSteps(issueKey, mode, conflicts, rollup) {
|
|
129
|
+
const steps = [];
|
|
130
|
+
steps.push({ description: 'View the issue details', tool: 'manage_jira_issue', example: { operation: 'get', issueKey } }, { description: 'Explore the hierarchy tree', tool: 'manage_jira_issue', example: { operation: 'hierarchy', issueKey } });
|
|
131
|
+
if (mode !== 'gaps') {
|
|
132
|
+
steps.push({ description: 'Check for data gaps and conflicts', tool: 'analyze_jira_plan', example: { issueKey, mode: 'gaps' } });
|
|
133
|
+
}
|
|
134
|
+
if (mode !== 'timeline') {
|
|
135
|
+
steps.push({ description: 'View the timeline', tool: 'analyze_jira_plan', example: { issueKey, mode: 'timeline' } });
|
|
136
|
+
}
|
|
137
|
+
steps.push({ description: 'Run flat metrics on children', tool: 'analyze_jira_issues', example: { jql: `parent = ${issueKey}`, metrics: ['summary'], groupBy: 'assignee' } });
|
|
138
|
+
let result = formatSteps(steps);
|
|
139
|
+
// Append conflict fix operations if conflicts exist
|
|
140
|
+
if (conflicts && conflicts.length > 0 && rollup) {
|
|
141
|
+
result += conflictFixSteps(conflicts, rollup);
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
export function conflictFixSteps(conflicts, rollup) {
|
|
146
|
+
const fixOps = [];
|
|
147
|
+
const lines = ['\n\n**Conflict fixes:**'];
|
|
148
|
+
for (const conflict of conflicts) {
|
|
149
|
+
switch (conflict.type) {
|
|
150
|
+
case 'due_date':
|
|
151
|
+
if (rollup.rolledUpEnd) {
|
|
152
|
+
lines.push(`- Update ${conflict.issueKey} due date to ${rollup.rolledUpEnd} — \`manage_jira_issue\` \`{ operation: "update", issueKey: "${conflict.issueKey}", dueDate: "${rollup.rolledUpEnd}" }\``);
|
|
153
|
+
fixOps.push({ tool: 'manage_jira_issue', args: { operation: 'update', issueKey: conflict.issueKey, dueDate: rollup.rolledUpEnd } });
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
case 'start_date':
|
|
157
|
+
if (rollup.rolledUpStart) {
|
|
158
|
+
lines.push(`- Update ${conflict.issueKey} start date to ${rollup.rolledUpStart} — read \`jira://custom-fields\` to find the start date field ID, then use \`manage_jira_issue update\``);
|
|
159
|
+
// Don't auto-generate queue op for start date — field ID is instance-specific
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
case 'resolved_with_open_children':
|
|
163
|
+
lines.push(`- ${conflict.issueKey}: ${conflict.message} — reopen parent or resolve open children (use \`manage_jira_issue get\` with \`expand: ["transitions"]\` to find transition IDs)`);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (fixOps.length > 0) {
|
|
168
|
+
lines.push('');
|
|
169
|
+
lines.push('**Fix all date conflicts in one call:**');
|
|
170
|
+
lines.push(`\`queue_jira_operations\` — \`${JSON.stringify({ operations: fixOps.map(op => ({ ...op, onError: 'continue' })) })}\``);
|
|
171
|
+
}
|
|
172
|
+
return lines.join('\n');
|
|
173
|
+
}
|
|
128
174
|
export function analysisNextSteps(jql, issueKeys, truncated = false, groupBy, filterSource) {
|
|
129
175
|
const steps = [];
|
|
130
176
|
if (issueKeys.length > 0) {
|
|
@@ -144,6 +190,10 @@ export function analysisNextSteps(jql, issueKeys, truncated = false, groupBy, fi
|
|
|
144
190
|
if (truncated) {
|
|
145
191
|
steps.push({ description: 'Distribution counts above are approximate (issue cap hit). For exact breakdowns use summary + groupBy', tool: 'analyze_jira_issues', example: { jql, metrics: ['summary'], groupBy: 'assignee' } }, { description: 'Or narrow JQL for precise detail metrics', tool: 'analyze_jira_issues', example: { jql: `${jql} AND assignee = currentUser()`, metrics: ['cycle'] } });
|
|
146
192
|
}
|
|
193
|
+
// Suggest plan analysis when issue keys suggest hierarchical structure
|
|
194
|
+
if (issueKeys.length > 0) {
|
|
195
|
+
steps.push({ description: 'Analyze plan rollups for a parent issue (requires Jira Plans)', tool: 'analyze_jira_plan', example: { issueKey: issueKeys[0] } });
|
|
196
|
+
}
|
|
147
197
|
// Suggest saving as filter if not already using one
|
|
148
198
|
if (!filterSource) {
|
|
149
199
|
steps.push({ description: 'Save this query as a filter for reuse across analyses', tool: 'manage_jira_filter', example: { operation: 'create', name: '<descriptive name>', jql } });
|