@aaronsb/jira-cloud-mcp 0.5.0 → 0.5.1

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.
@@ -408,6 +408,18 @@ export class JiraClient {
408
408
  });
409
409
  }
410
410
  /** Lightweight search returning only fields needed for analysis (no description, links, rendered HTML) */
411
+ async countIssues(jql) {
412
+ try {
413
+ const cleanJql = jql.replace(/\\"/g, '"');
414
+ console.error(`Counting issues for JQL: ${cleanJql}`);
415
+ const result = await this.client.issueSearch.countIssues({ jql: cleanJql });
416
+ return result.count ?? 0;
417
+ }
418
+ catch (error) {
419
+ console.error('Error counting issues:', error);
420
+ throw error;
421
+ }
422
+ }
411
423
  async searchIssuesLean(jql, maxResults = 50, nextPageToken) {
412
424
  try {
413
425
  const cleanJql = jql.replace(/\\"/g, '"');
@@ -473,15 +473,21 @@ function generateAnalysisToolDocumentation(schema) {
473
473
  },
474
474
  metrics: {
475
475
  type: "array of strings",
476
- description: "Which metric groups to include. Default: all.",
477
- values: ["points", "time", "schedule", "cycle", "distribution"],
476
+ description: "Which metric groups to compute. summary uses count API (no cap). Others fetch issue data (subject to maxResults).",
477
+ values: ["summary", "points", "time", "schedule", "cycle", "distribution"],
478
+ },
479
+ groupBy: {
480
+ type: "string",
481
+ description: "Split summary counts by dimension. Use with metrics: ['summary'].",
482
+ values: ["project", "assignee", "priority", "issuetype"],
478
483
  },
479
484
  maxResults: {
480
485
  type: "integer",
481
- description: "Max issues to analyze (default 100, max 500).",
486
+ description: "Max issues to fetch for detail metrics (default 100, max 500). Does not apply to summary.",
482
487
  },
483
488
  },
484
489
  metric_groups: {
490
+ summary: "Exact counts — total, open, overdue, high priority, created/resolved last 7 days. No sampling cap. Supports groupBy for cross-project comparison.",
485
491
  points: "Earned Value — PV, EV, remaining, SPI, status breakdown, unestimated count",
486
492
  time: "Effort — original estimate, completed, remaining by status category",
487
493
  schedule: "Risk — date window, overdue count/slip, due soon, concentration risk, missing dates",
@@ -489,6 +495,13 @@ function generateAnalysisToolDocumentation(schema) {
489
495
  distribution: "Composition — counts by status, assignee, priority, issue type",
490
496
  },
491
497
  common_use_cases: [
498
+ {
499
+ title: "Cross-project comparison",
500
+ description: "Compare issue counts across multiple projects (exact, no cap):",
501
+ steps: [
502
+ { description: "Summary by project", code: { jql: "project in (AA, GC, GD, LGS)", metrics: ["summary"], groupBy: "project" } },
503
+ ],
504
+ },
492
505
  {
493
506
  title: "Sprint health check",
494
507
  description: "Analyze all issues in the current sprint:",
@@ -504,10 +517,10 @@ function generateAnalysisToolDocumentation(schema) {
504
517
  ],
505
518
  },
506
519
  {
507
- title: "Workload balance",
508
- description: "See how work is distributed across team members:",
520
+ title: "Quick project overview",
521
+ description: "Get exact issue counts without fetching data:",
509
522
  steps: [
510
- { description: "Distribution only", code: { jql: "project = AA AND resolution = Unresolved", metrics: ["distribution"] } },
523
+ { description: "Summary only", code: { jql: "project = AA", metrics: ["summary"] } },
511
524
  ],
512
525
  },
513
526
  ],
@@ -2,6 +2,7 @@ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { analysisNextSteps } from '../utils/next-steps.js';
3
3
  import { normalizeArgs } from '../utils/normalize-args.js';
4
4
  const ALL_METRICS = ['points', 'time', 'schedule', 'cycle', 'distribution'];
5
+ const VALID_GROUP_BY = ['project', 'assignee', 'priority', 'issuetype'];
5
6
  const MAX_ISSUES = 500;
6
7
  // ── Helpers ────────────────────────────────────────────────────────────
7
8
  function bucketStatus(category) {
@@ -217,6 +218,89 @@ export function renderDistribution(issues) {
217
218
  lines.push(`**By type:** ${mapToString(byType)}`);
218
219
  return lines.join('\n');
219
220
  }
221
+ /** Extract project keys from JQL like "project in (AA, GC, GD)" or "project = AA" */
222
+ export function extractProjectKeys(jql) {
223
+ // project in (AA, GC, GD)
224
+ const inMatch = jql.match(/project\s+in\s*\(([^)]+)\)/i);
225
+ if (inMatch) {
226
+ return inMatch[1].split(',').map(k => k.trim().replace(/['"]/g, ''));
227
+ }
228
+ // project = AA
229
+ const eqMatch = jql.match(/project\s*=\s*['"]?(\w+)['"]?/i);
230
+ if (eqMatch) {
231
+ return [eqMatch[1]];
232
+ }
233
+ return [];
234
+ }
235
+ /** Remove the project clause from JQL, returning remaining constraints */
236
+ export function removeProjectClause(jql) {
237
+ return jql
238
+ .replace(/project\s+in\s*\([^)]+\)\s*(AND\s*)?/i, '')
239
+ .replace(/project\s*=\s*['"]?\w+['"]?\s*(AND\s*)?/i, '')
240
+ .replace(/^\s*AND\s*/i, '')
241
+ .replace(/\s*AND\s*$/i, '')
242
+ .trim();
243
+ }
244
+ /** Build a scoped JQL by adding a condition to the base query */
245
+ function scopeJql(baseJql, condition) {
246
+ return `(${baseJql}) AND ${condition}`;
247
+ }
248
+ async function buildCountRow(jiraClient, label, baseJql) {
249
+ const [total, unresolved, overdue, highPriority, createdRecently, resolvedRecently] = await Promise.all([
250
+ jiraClient.countIssues(baseJql),
251
+ jiraClient.countIssues(scopeJql(baseJql, 'resolution = Unresolved')),
252
+ jiraClient.countIssues(scopeJql(baseJql, 'resolution = Unresolved AND dueDate < now()')),
253
+ jiraClient.countIssues(scopeJql(baseJql, 'priority in (High, Highest, Critical, Blocker)')),
254
+ jiraClient.countIssues(scopeJql(baseJql, 'created >= -7d')),
255
+ jiraClient.countIssues(scopeJql(baseJql, 'resolved >= -7d')),
256
+ ]);
257
+ return { label, total, unresolved, overdue, highPriority, createdRecently, resolvedRecently };
258
+ }
259
+ export function renderSummaryTable(rows) {
260
+ const lines = ['## Summary (exact counts)', ''];
261
+ lines.push('| Scope | Total | Open | Overdue | High+ | Created 7d | Resolved 7d |');
262
+ lines.push('|-------|------:|-----:|--------:|------:|-----------:|------------:|');
263
+ for (const r of rows) {
264
+ lines.push(`| ${r.label} | ${r.total} | ${r.unresolved} | ${r.overdue} | ${r.highPriority} | ${r.createdRecently} | ${r.resolvedRecently} |`);
265
+ }
266
+ // Totals row if multiple
267
+ if (rows.length > 1) {
268
+ const sum = (fn) => rows.reduce((s, r) => s + fn(r), 0);
269
+ lines.push(`| **Total** | **${sum(r => r.total)}** | **${sum(r => r.unresolved)}** | **${sum(r => r.overdue)}** | **${sum(r => r.highPriority)}** | **${sum(r => r.createdRecently)}** | **${sum(r => r.resolvedRecently)}** |`);
270
+ }
271
+ return lines.join('\n');
272
+ }
273
+ async function handleSummary(jiraClient, jql, groupBy) {
274
+ const lines = [];
275
+ lines.push(`# Summary: ${jql}`);
276
+ lines.push(`As of ${formatDateShort(new Date())} — counts are exact (no sampling cap)`);
277
+ if (groupBy === 'project') {
278
+ const keys = extractProjectKeys(jql);
279
+ if (keys.length === 0) {
280
+ throw new McpError(ErrorCode.InvalidParams, 'groupBy "project" requires project keys in JQL (e.g., project in (AA, GC))');
281
+ }
282
+ const remaining = removeProjectClause(jql);
283
+ const rows = await Promise.all(keys.map(k => buildCountRow(jiraClient, k, remaining ? `project = ${k} AND (${remaining})` : `project = ${k}`)));
284
+ rows.sort((a, b) => b.unresolved - a.unresolved);
285
+ lines.push('');
286
+ lines.push(renderSummaryTable(rows));
287
+ }
288
+ else if (groupBy) {
289
+ // For non-project groupBy, get the overall count first
290
+ const overallRow = await buildCountRow(jiraClient, 'All', jql);
291
+ lines.push('');
292
+ lines.push(renderSummaryTable([overallRow]));
293
+ lines.push('');
294
+ lines.push(`*groupBy "${groupBy}" — only "project" supports per-group breakdown currently. Other dimensions coming soon.*`);
295
+ }
296
+ else {
297
+ // No groupBy — single row for the whole JQL
298
+ const row = await buildCountRow(jiraClient, 'All', jql);
299
+ lines.push('');
300
+ lines.push(renderSummaryTable([row]));
301
+ }
302
+ return lines.join('\n');
303
+ }
220
304
  // ── Main Handler ───────────────────────────────────────────────────────
221
305
  export async function handleAnalysisRequest(jiraClient, request) {
222
306
  const args = normalizeArgs(request.params?.arguments || {});
@@ -224,16 +308,31 @@ export async function handleAnalysisRequest(jiraClient, request) {
224
308
  if (!jql || typeof jql !== 'string' || jql.trim() === '') {
225
309
  throw new McpError(ErrorCode.InvalidParams, 'jql parameter is required.');
226
310
  }
227
- const DEFAULT_MAX = 100;
228
- const maxResults = Math.min(Number(args.maxResults) || DEFAULT_MAX, MAX_ISSUES);
229
311
  // Parse requested metrics
230
- let metrics = ALL_METRICS;
231
- if (args.metrics && Array.isArray(args.metrics)) {
232
- const requested = args.metrics;
233
- const valid = requested.filter(m => ALL_METRICS.includes(m));
234
- if (valid.length > 0)
235
- metrics = valid;
312
+ const requested = (args.metrics && Array.isArray(args.metrics))
313
+ ? args.metrics
314
+ : [];
315
+ const hasSummary = requested.includes('summary');
316
+ const fetchMetrics = requested.length > 0
317
+ ? requested.filter(m => ALL_METRICS.includes(m))
318
+ : ALL_METRICS;
319
+ // Parse groupBy
320
+ const groupBy = (typeof args.groupBy === 'string' && VALID_GROUP_BY.includes(args.groupBy))
321
+ ? args.groupBy
322
+ : undefined;
323
+ // If only summary requested, skip issue fetching entirely
324
+ if (hasSummary && fetchMetrics.length === 0) {
325
+ const summaryText = await handleSummary(jiraClient, jql, groupBy);
326
+ const nextSteps = analysisNextSteps(jql, []);
327
+ return {
328
+ content: [{
329
+ type: 'text',
330
+ text: summaryText + '\n' + nextSteps,
331
+ }],
332
+ };
236
333
  }
334
+ const DEFAULT_MAX = 100;
335
+ const maxResults = Math.min(Number(args.maxResults) || DEFAULT_MAX, MAX_ISSUES);
237
336
  // Fetch issues using cursor-based pagination (50 per page, Jira enhanced search API)
238
337
  const allIssues = [];
239
338
  const seen = new Set();
@@ -260,7 +359,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
260
359
  break;
261
360
  }
262
361
  }
263
- if (allIssues.length === 0) {
362
+ if (allIssues.length === 0 && !hasSummary) {
264
363
  return {
265
364
  content: [{
266
365
  type: 'text',
@@ -270,23 +369,32 @@ export async function handleAnalysisRequest(jiraClient, request) {
270
369
  }
271
370
  const now = new Date();
272
371
  const lines = [];
273
- // Header
274
- lines.push(`# Analysis: ${jql}`);
275
- lines.push(`Analyzed ${allIssues.length} issues (as of ${formatDateShort(now)})`);
276
- if (truncated) {
277
- lines.push(`*Results capped at ${maxResults} issues — query may match more.*`);
372
+ // Summary first if requested alongside other metrics
373
+ if (hasSummary) {
374
+ const summaryText = await handleSummary(jiraClient, jql, groupBy);
375
+ lines.push(summaryText);
278
376
  }
279
- // Render requested metrics
280
- const renderers = {
281
- points: () => renderPoints(allIssues),
282
- time: () => renderTime(allIssues),
283
- schedule: () => renderSchedule(allIssues, now),
284
- cycle: () => renderCycle(allIssues, now),
285
- distribution: () => renderDistribution(allIssues),
286
- };
287
- for (const metric of metrics) {
288
- lines.push('');
289
- lines.push(renderers[metric]());
377
+ // Header for fetch-based metrics
378
+ if (fetchMetrics.length > 0 && allIssues.length > 0) {
379
+ if (hasSummary)
380
+ lines.push('');
381
+ lines.push(`# Detail: ${jql}`);
382
+ lines.push(`Analyzed ${allIssues.length} issues (as of ${formatDateShort(now)})`);
383
+ if (truncated) {
384
+ lines.push(`*Results capped at ${maxResults} issues — query may match more.*`);
385
+ }
386
+ // Render requested metrics
387
+ const renderers = {
388
+ points: () => renderPoints(allIssues),
389
+ time: () => renderTime(allIssues),
390
+ schedule: () => renderSchedule(allIssues, now),
391
+ cycle: () => renderCycle(allIssues, now),
392
+ distribution: () => renderDistribution(allIssues),
393
+ };
394
+ for (const metric of fetchMetrics) {
395
+ lines.push('');
396
+ lines.push(renderers[metric]());
397
+ }
290
398
  }
291
399
  // Next steps
292
400
  const nextSteps = analysisNextSteps(jql, allIssues.slice(0, 3).map(i => i.key));
@@ -1,7 +1,7 @@
1
1
  export const toolSchemas = {
2
2
  manage_jira_filter: {
3
3
  name: 'manage_jira_filter',
4
- description: 'Search for issues using JQL queries, or manage saved filters',
4
+ description: 'Search for issues using JQL queries, or manage saved filters. Returns issue details (title, status, description). For quantitative questions (counts, totals, overdue, workload), use analyze_jira_issues instead.',
5
5
  inputSchema: {
6
6
  type: 'object',
7
7
  properties: {
@@ -242,7 +242,7 @@ export const toolSchemas = {
242
242
  },
243
243
  manage_jira_project: {
244
244
  name: 'manage_jira_project',
245
- description: 'List projects or get project details including status counts',
245
+ description: 'List projects or get project configuration and metadata. For issue counts, workload, or cross-project comparison, use analyze_jira_issues with metrics: ["summary"] instead.',
246
246
  inputSchema: {
247
247
  type: 'object',
248
248
  properties: {
@@ -326,25 +326,30 @@ export const toolSchemas = {
326
326
  },
327
327
  analyze_jira_issues: {
328
328
  name: 'analyze_jira_issues',
329
- description: 'Compute project metrics over a set of issues selected by JQL. Returns deterministic calculations: earned value (story points), effort tracking, schedule risk, flow/cycle metrics, and distribution breakdowns. Use this when asked about totals, progress, overdue items, workload balance, or any quantitative question about issues.',
329
+ description: 'Compute project metrics over issues selected by JQL. Use "summary" for exact counts across projects (no sampling cap). Use other metrics for detailed analysis of individual issue data. Always prefer this tool over manage_jira_filter or manage_jira_project for quantitative questions (counts, totals, overdue, workload).',
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. Examples: "sprint in openSprints()", "project = AA AND fixVersion = 2.0", "assignee = currentUser() AND resolution = Unresolved".',
335
+ description: 'JQL query selecting the issues to analyze. Examples: "project in (AA, GC, LGS)", "sprint in openSprints()", "assignee = currentUser() AND resolution = Unresolved".',
336
336
  },
337
337
  metrics: {
338
338
  type: 'array',
339
339
  items: {
340
340
  type: 'string',
341
- enum: ['points', 'time', 'schedule', 'cycle', 'distribution'],
341
+ enum: ['summary', 'points', 'time', 'schedule', 'cycle', 'distribution'],
342
342
  },
343
- description: 'Which metric groups to include. Default: all. points = earned value/SPI, time = effort estimates, schedule = due dates/overdue/risk, cycle = lead time/throughput/age, distribution = counts by status/assignee/priority/type.',
343
+ description: 'Which metric groups to compute. summary = exact issue counts via count API (no cap, fastest). points = earned value/SPI. time = effort estimates. schedule = overdue/risk. cycle = lead time/throughput. distribution = counts by status/assignee/priority/type. Default: all except summary.',
344
+ },
345
+ groupBy: {
346
+ type: 'string',
347
+ enum: ['project', 'assignee', 'priority', 'issuetype'],
348
+ description: 'Split summary counts by this dimension. Use with metrics: ["summary"]. "project" produces a per-project comparison table.',
344
349
  },
345
350
  maxResults: {
346
351
  type: 'integer',
347
- description: 'Max issues to analyze (default 100, max 500).',
352
+ description: 'Max issues to fetch for detail metrics (default 100, max 500). Does not apply to summary (which uses count API).',
348
353
  default: 100,
349
354
  },
350
355
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
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",