@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.
@@ -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 — Cycle metrics for staleness distribution:
23
- {"tool":"analyze_jira_issues","args":{"filterId":"$0.filterId","metrics":["cycle"],"maxResults":100}}
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) only when you need per-issue analysis; these are capped at maxResults issues. Always prefer this tool over manage_jira_filter or manage_jira_project for quantitative questions. 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.',
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 filterId is required (filterId takes precedence). Examples: "project in (AA, GC, LGS)", "sprint in openSprints()", "assignee = currentUser() AND resolution = Unresolved".',
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 } });