@aaronsb/jira-cloud-mcp 0.5.3 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,
@@ -110,6 +111,7 @@ export class JiraClient {
110
111
  created: fields?.created || '',
111
112
  updated: fields?.updated || '',
112
113
  resolutionDate: fields?.resolutiondate || null,
114
+ statusCategoryChanged: fields?.statuscategorychangedate ?? fields?.statuscategorychangeddate ?? null,
113
115
  dueDate: fields?.duedate || null,
114
116
  startDate: fields?.[this.customFields.startDate] || null,
115
117
  storyPoints: fields?.[this.customFields.storyPoints] ?? null,
@@ -431,7 +433,7 @@ export class JiraClient {
431
433
  const leanFields = [
432
434
  'summary', 'issuetype', 'priority', 'assignee', 'reporter',
433
435
  'status', 'resolution', 'labels', 'created', 'updated',
434
- 'resolutiondate', 'duedate', 'timeestimate',
436
+ 'resolutiondate', 'statuscategorychangedate', 'duedate', 'timeestimate',
435
437
  this.customFields.startDate, this.customFields.storyPoints,
436
438
  ];
437
439
  const params = {
@@ -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));
@@ -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", "planning_gap = no_estimate / open * 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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "mcpName": "io.github.aaronsb/jira-cloud",
5
5
  "description": "Model Context Protocol (MCP) server for Jira Cloud - enables AI assistants to interact with Jira",
6
6
  "type": "module",