@aaronsb/jira-cloud-mcp 0.5.1 → 0.5.2

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.
@@ -473,21 +473,26 @@ function generateAnalysisToolDocumentation(schema) {
473
473
  },
474
474
  metrics: {
475
475
  type: "array of strings",
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"],
476
+ description: "Which metric groups to compute. summary uses count API (no cap). cube_setup discovers dimensions. Others fetch issue data (subject to maxResults).",
477
+ values: ["summary", "cube_setup", "points", "time", "schedule", "cycle", "distribution"],
478
478
  },
479
479
  groupBy: {
480
480
  type: "string",
481
- description: "Split summary counts by dimension. Use with metrics: ['summary'].",
481
+ description: "Split summary counts by dimension. Supports all dimensions. Use cube_setup to discover values first.",
482
482
  values: ["project", "assignee", "priority", "issuetype"],
483
483
  },
484
+ compute: {
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, blocked.",
487
+ },
484
488
  maxResults: {
485
489
  type: "integer",
486
490
  description: "Max issues to fetch for detail metrics (default 100, max 500). Does not apply to summary.",
487
491
  },
488
492
  },
489
493
  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.",
494
+ summary: "Exact counts — total, open, overdue, high priority, created/resolved last 7 days. No sampling cap. Supports groupBy and compute for data cube queries.",
495
+ cube_setup: "Discover available dimensions and values from a sample. Returns dimension catalog and cost estimates. Use before cube execute.",
491
496
  points: "Earned Value — PV, EV, remaining, SPI, status breakdown, unestimated count",
492
497
  time: "Effort — original estimate, completed, remaining by status category",
493
498
  schedule: "Risk — date window, overdue count/slip, due soon, concentration risk, missing dates",
@@ -523,8 +528,16 @@ function generateAnalysisToolDocumentation(schema) {
523
528
  { description: "Summary only", code: { jql: "project = AA", metrics: ["summary"] } },
524
529
  ],
525
530
  },
531
+ {
532
+ title: "Data cube — discover then compute",
533
+ description: "Two-phase analysis with computed columns:",
534
+ steps: [
535
+ { description: "Discover dimensions", code: { jql: "project in (AA, GC, GD, LGS) AND resolution = Unresolved", metrics: ["cube_setup"] } },
536
+ { description: "Execute with compute", code: { jql: "project in (AA, GC, GD, LGS) AND resolution = Unresolved", metrics: ["summary"], groupBy: "project", compute: ["bug_pct = bugs / total * 100", "net_flow = created_7d - resolved_7d", "clearing = resolved_7d > created_7d"] } },
537
+ ],
538
+ },
526
539
  ],
527
- related_resources: [],
540
+ related_resources: ["jira://analysis/recipes"],
528
541
  };
529
542
  }
530
543
  function generateGenericToolDocumentation(toolName, schema) {
@@ -1,9 +1,16 @@
1
1
  import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
+ import { evaluateRow, extractColumnRefs, parseComputeList } from '../utils/cube-dsl.js';
2
3
  import { analysisNextSteps } from '../utils/next-steps.js';
3
4
  import { normalizeArgs } from '../utils/normalize-args.js';
4
5
  const ALL_METRICS = ['points', 'time', 'schedule', 'cycle', 'distribution'];
5
6
  const VALID_GROUP_BY = ['project', 'assignee', 'priority', 'issuetype'];
6
7
  const MAX_ISSUES = 500;
8
+ const CUBE_SAMPLE_PCT = 0.2; // 20% of total issues
9
+ const CUBE_SAMPLE_MIN = 50; // floor — enough for rare dimension values
10
+ const CUBE_SAMPLE_MAX = 500; // ceiling — proven fast with lean search
11
+ const MAX_CUBE_GROUPS = 20;
12
+ const MAX_COUNT_QUERIES = 150; // ADR-206 budget: max count API calls per execution
13
+ const STANDARD_MEASURES = 6; // total, open, overdue, high+, created_7d, resolved_7d
7
14
  // ── Helpers ────────────────────────────────────────────────────────────
8
15
  function bucketStatus(category) {
9
16
  switch (category) {
@@ -245,7 +252,27 @@ export function removeProjectClause(jql) {
245
252
  function scopeJql(baseJql, condition) {
246
253
  return `(${baseJql}) AND ${condition}`;
247
254
  }
248
- async function buildCountRow(jiraClient, label, baseJql) {
255
+ /** Implicit measures lazily resolved if referenced in compute expressions */
256
+ const IMPLICIT_MEASURES = {
257
+ bugs: 'issuetype = Bug AND resolution = Unresolved',
258
+ unassigned: 'assignee is EMPTY AND resolution = Unresolved',
259
+ no_due_date: 'dueDate is EMPTY AND resolution = Unresolved',
260
+ blocked: 'status = Blocked',
261
+ };
262
+ /** Map dimension values to JQL clauses for groupBy scoping */
263
+ export function groupByJqlClause(dimension, values) {
264
+ switch (dimension) {
265
+ case 'project':
266
+ return values.map(v => `project = ${v}`);
267
+ case 'assignee':
268
+ return values.map(v => v === 'Unassigned' ? 'assignee is EMPTY' : `assignee = "${v}"`);
269
+ case 'priority':
270
+ return values.map(v => `priority = "${v}"`);
271
+ case 'issuetype':
272
+ return values.map(v => `issuetype = "${v}"`);
273
+ }
274
+ }
275
+ async function buildCountRow(jiraClient, label, baseJql, implicitMeasureNames) {
249
276
  const [total, unresolved, overdue, highPriority, createdRecently, resolvedRecently] = await Promise.all([
250
277
  jiraClient.countIssues(baseJql),
251
278
  jiraClient.countIssues(scopeJql(baseJql, 'resolution = Unresolved')),
@@ -254,53 +281,231 @@ async function buildCountRow(jiraClient, label, baseJql) {
254
281
  jiraClient.countIssues(scopeJql(baseJql, 'created >= -7d')),
255
282
  jiraClient.countIssues(scopeJql(baseJql, 'resolved >= -7d')),
256
283
  ]);
257
- return { label, total, unresolved, overdue, highPriority, createdRecently, resolvedRecently };
284
+ let implicitMeasures;
285
+ if (implicitMeasureNames && implicitMeasureNames.length > 0) {
286
+ const counts = await Promise.all(implicitMeasureNames.map(name => jiraClient.countIssues(scopeJql(baseJql, IMPLICIT_MEASURES[name]))));
287
+ implicitMeasures = {};
288
+ for (let i = 0; i < implicitMeasureNames.length; i++) {
289
+ implicitMeasures[implicitMeasureNames[i]] = counts[i];
290
+ }
291
+ }
292
+ return { label, total, unresolved, overdue, highPriority, createdRecently, resolvedRecently, implicitMeasures };
258
293
  }
259
- export function renderSummaryTable(rows) {
294
+ export function renderSummaryTable(rows, computeColumns) {
260
295
  const lines = ['## Summary (exact counts)', ''];
261
- lines.push('| Scope | Total | Open | Overdue | High+ | Created 7d | Resolved 7d |');
262
- lines.push('|-------|------:|-----:|--------:|------:|-----------:|------------:|');
296
+ const extraHeaders = computeColumns?.map(c => c.name) ?? [];
297
+ const headerExtra = extraHeaders.map(h => ` ${h} |`).join('');
298
+ const alignExtra = extraHeaders.map(() => '---:|').join('');
299
+ lines.push(`| Scope | Total | Open | Overdue | High+ | Created 7d | Resolved 7d |${headerExtra}`);
300
+ lines.push(`|-------|------:|-----:|--------:|------:|-----------:|------------:|${alignExtra}`);
263
301
  for (const r of rows) {
264
- lines.push(`| ${r.label} | ${r.total} | ${r.unresolved} | ${r.overdue} | ${r.highPriority} | ${r.createdRecently} | ${r.resolvedRecently} |`);
302
+ let computed = '';
303
+ if (computeColumns && computeColumns.length > 0) {
304
+ const rowMap = countRowToMap(r);
305
+ const results = evaluateRow(computeColumns, rowMap);
306
+ computed = results.map(res => {
307
+ const val = typeof res.value === 'number' ? formatComputed(res.value) : res.value;
308
+ return ` ${val} |`;
309
+ }).join('');
310
+ }
311
+ lines.push(`| ${r.label} | ${r.total} | ${r.unresolved} | ${r.overdue} | ${r.highPriority} | ${r.createdRecently} | ${r.resolvedRecently} |${computed}`);
265
312
  }
266
313
  // Totals row if multiple
267
314
  if (rows.length > 1) {
268
315
  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)}** |`);
316
+ const totalExtra = extraHeaders.map(() => ' |').join('');
317
+ 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)}** |${totalExtra}`);
270
318
  }
271
319
  return lines.join('\n');
272
320
  }
273
- async function handleSummary(jiraClient, jql, groupBy) {
321
+ /** Convert a CountRow to a Map for DSL evaluation */
322
+ function countRowToMap(row) {
323
+ const m = new Map();
324
+ m.set('total', row.total);
325
+ m.set('open', row.unresolved);
326
+ m.set('overdue', row.overdue);
327
+ m.set('high', row.highPriority);
328
+ m.set('created_7d', row.createdRecently);
329
+ m.set('resolved_7d', row.resolvedRecently);
330
+ // Add implicit measure values if present
331
+ if (row.implicitMeasures) {
332
+ for (const [k, v] of Object.entries(row.implicitMeasures)) {
333
+ m.set(k, v);
334
+ }
335
+ }
336
+ return m;
337
+ }
338
+ /** Format a computed number — round to 1 decimal if fractional */
339
+ function formatComputed(n) {
340
+ return Number.isInteger(n) ? String(n) : n.toFixed(1);
341
+ }
342
+ /** Compute max groups that fit within the query budget */
343
+ function maxGroupsForBudget(implicitCount) {
344
+ const queriesPerGroup = STANDARD_MEASURES + implicitCount;
345
+ return Math.floor(MAX_COUNT_QUERIES / queriesPerGroup);
346
+ }
347
+ async function handleSummary(jiraClient, jql, groupBy, compute) {
274
348
  const lines = [];
275
349
  lines.push(`# Summary: ${jql}`);
276
350
  lines.push(`As of ${formatDateShort(new Date())} — counts are exact (no sampling cap)`);
351
+ // Detect implicit measures needed by compute expressions
352
+ const neededImplicits = compute ? detectImplicitMeasures(compute) : [];
353
+ const groupBudget = maxGroupsForBudget(neededImplicits.length);
277
354
  if (groupBy === 'project') {
278
- const keys = extractProjectKeys(jql);
355
+ let keys = extractProjectKeys(jql);
279
356
  if (keys.length === 0) {
280
357
  throw new McpError(ErrorCode.InvalidParams, 'groupBy "project" requires project keys in JQL (e.g., project in (AA, GC))');
281
358
  }
359
+ const capped = keys.length > groupBudget;
360
+ if (capped)
361
+ keys = keys.slice(0, groupBudget);
282
362
  const remaining = removeProjectClause(jql);
283
- const rows = await Promise.all(keys.map(k => buildCountRow(jiraClient, k, remaining ? `project = ${k} AND (${remaining})` : `project = ${k}`)));
363
+ const rows = await Promise.all(keys.map(k => buildCountRow(jiraClient, k, remaining ? `project = ${k} AND (${remaining})` : `project = ${k}`, neededImplicits)));
284
364
  rows.sort((a, b) => b.unresolved - a.unresolved);
285
365
  lines.push('');
286
- lines.push(renderSummaryTable(rows));
366
+ lines.push(renderSummaryTable(rows, compute));
367
+ if (capped) {
368
+ lines.push(`*Capped at ${groupBudget} groups to stay within ${MAX_COUNT_QUERIES}-query budget*`);
369
+ }
287
370
  }
288
371
  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]));
372
+ // For non-project groupBy, sample per-project for representative dimension values
373
+ const issues = await samplePerProject(jiraClient, jql);
374
+ if (issues.length === 0) {
375
+ throw new McpError(ErrorCode.InvalidParams, `No issues matched JQL — cannot discover ${groupBy} values`);
376
+ }
377
+ const dims = extractDimensions(issues);
378
+ const dim = dims.find(d => d.name === groupBy);
379
+ if (!dim || dim.values.length === 0) {
380
+ throw new McpError(ErrorCode.InvalidParams, `No ${groupBy} values found in sampled issues`);
381
+ }
382
+ // Cap groups to query budget
383
+ const cappedValues = dim.values.slice(0, groupBudget);
384
+ const jqlClause = groupByJqlClause(groupBy, cappedValues);
385
+ const rows = await Promise.all(cappedValues.map((value, idx) => buildCountRow(jiraClient, value, `(${jql}) AND ${jqlClause[idx]}`, neededImplicits)));
386
+ rows.sort((a, b) => b.unresolved - a.unresolved);
293
387
  lines.push('');
294
- lines.push(`*groupBy "${groupBy}" — only "project" supports per-group breakdown currently. Other dimensions coming soon.*`);
388
+ lines.push(renderSummaryTable(rows, compute));
389
+ if (dim.count > cappedValues.length) {
390
+ const reason = cappedValues.length < dim.values.length
391
+ ? `capped at ${groupBudget} groups (${MAX_COUNT_QUERIES}-query budget)`
392
+ : `from ${issues.length}-issue sample`;
393
+ lines.push(`*Showing top ${cappedValues.length} of ${dim.count} ${groupBy} values (${reason})*`);
394
+ }
295
395
  }
296
396
  else {
297
397
  // No groupBy — single row for the whole JQL
298
- const row = await buildCountRow(jiraClient, 'All', jql);
398
+ const row = await buildCountRow(jiraClient, 'All', jql, neededImplicits);
299
399
  lines.push('');
300
- lines.push(renderSummaryTable([row]));
400
+ lines.push(renderSummaryTable([row], compute));
401
+ }
402
+ return lines.join('\n');
403
+ }
404
+ /** Detect which implicit measures are referenced by compute expressions */
405
+ function detectImplicitMeasures(compute) {
406
+ const refs = extractColumnRefs(compute);
407
+ return Object.keys(IMPLICIT_MEASURES).filter(name => refs.has(name));
408
+ }
409
+ /** Extract distinct dimension values from sampled issues */
410
+ export function extractDimensions(issues) {
411
+ const dims = [
412
+ { name: 'project', extractor: i => i.key.split('-')[0] },
413
+ { name: 'status', extractor: i => i.status },
414
+ { name: 'assignee', extractor: i => i.assignee || 'Unassigned' },
415
+ { name: 'priority', extractor: i => i.priority || 'None' },
416
+ { name: 'issuetype', extractor: i => i.issueType || 'Unknown' },
417
+ ];
418
+ return dims.map(({ name, extractor }) => {
419
+ const counts = new Map();
420
+ for (const issue of issues) {
421
+ const val = extractor(issue);
422
+ counts.set(val, (counts.get(val) || 0) + 1);
423
+ }
424
+ // Sort by count descending, cap at MAX_CUBE_GROUPS
425
+ const sorted = [...counts.entries()]
426
+ .sort((a, b) => b[1] - a[1])
427
+ .slice(0, MAX_CUBE_GROUPS);
428
+ return {
429
+ name,
430
+ values: sorted.map(([v]) => v),
431
+ count: counts.size,
432
+ };
433
+ });
434
+ }
435
+ /** Render cube setup response with dimension catalog and cost estimates */
436
+ export function renderCubeSetup(jql, sampleSize, dimensions) {
437
+ const lines = [`# Cube Setup: ${jql}`, `Sampled ${sampleSize} issues to discover dimensions.`, ''];
438
+ // Dimension table
439
+ lines.push('## Available Dimensions');
440
+ lines.push('| Dimension | Distinct Values | Count |');
441
+ lines.push('|-----------|----------------|------:|');
442
+ for (const dim of dimensions) {
443
+ const displayed = dim.values.slice(0, 5).join(', ');
444
+ const more = dim.count > 5 ? ` +${dim.count - 5}` : '';
445
+ lines.push(`| ${dim.name} | ${displayed}${more} | ${dim.count} |`);
446
+ }
447
+ // Available measures
448
+ lines.push('');
449
+ lines.push('## Available Measures');
450
+ lines.push('Standard columns per group (via count API):');
451
+ lines.push('- total, open, overdue, high+, created_7d, resolved_7d');
452
+ lines.push('');
453
+ lines.push('Implicit measures (lazily resolved if referenced in `compute`):');
454
+ lines.push('- bugs, unassigned, no_due_date, blocked');
455
+ // Suggested cubes with cost estimates
456
+ lines.push('');
457
+ lines.push(`## Suggested Cubes (budget: ${MAX_COUNT_QUERIES} queries)`);
458
+ for (const dim of dimensions) {
459
+ const groups = Math.min(dim.count, MAX_CUBE_GROUPS);
460
+ const queries = groups * STANDARD_MEASURES;
461
+ const estSeconds = Math.max(1, Math.round(queries / 12)); // ~12 parallel queries/sec
462
+ const withinBudget = queries <= MAX_COUNT_QUERIES;
463
+ const badge = withinBudget ? '' : ' ⚠️ add compute measures to stay in budget';
464
+ lines.push(`- \`groupBy: "${dim.name}"\` — ${groups} groups, ${queries} base queries (~${estSeconds}s)${badge}`);
301
465
  }
302
466
  return lines.join('\n');
303
467
  }
468
+ /** Compute dynamic sample size: 20% of total, clamped to [50, 500] */
469
+ async function computeSampleSize(jiraClient, jql) {
470
+ const total = await jiraClient.countIssues(jql);
471
+ return Math.max(CUBE_SAMPLE_MIN, Math.min(CUBE_SAMPLE_MAX, Math.ceil(total * CUBE_SAMPLE_PCT)));
472
+ }
473
+ /** Sample issues across all projects in scope for representative dimension discovery */
474
+ async function samplePerProject(jiraClient, jql) {
475
+ let projectKeys = extractProjectKeys(jql);
476
+ if (projectKeys.length === 0) {
477
+ const allProjects = await jiraClient.listProjects();
478
+ projectKeys = allProjects.map(p => p.key);
479
+ }
480
+ const sampleSize = await computeSampleSize(jiraClient, jql);
481
+ if (projectKeys.length <= 1) {
482
+ const result = await jiraClient.searchIssuesLean(jql, sampleSize);
483
+ return result.issues;
484
+ }
485
+ const remaining = removeProjectClause(jql);
486
+ const perProject = Math.max(5, Math.floor(sampleSize / projectKeys.length));
487
+ const samples = await Promise.all(projectKeys.map(async (k) => {
488
+ const scopedJql = remaining ? `project = ${k} AND (${remaining})` : `project = ${k}`;
489
+ try {
490
+ const result = await jiraClient.searchIssuesLean(scopedJql, perProject);
491
+ return result.issues;
492
+ }
493
+ catch {
494
+ return [];
495
+ }
496
+ }));
497
+ return samples.flat();
498
+ }
499
+ async function handleCubeSetup(jiraClient, jql) {
500
+ const issues = await samplePerProject(jiraClient, jql);
501
+ if (issues.length === 0) {
502
+ return `# Cube Setup: ${jql}\n\nNo issues matched this query. Cannot discover dimensions.`;
503
+ }
504
+ // Extract dimensions — project dimension uses actual keys from sample
505
+ // (samplePerProject ensures coverage across all projects in scope)
506
+ const dimensions = extractDimensions(issues);
507
+ return renderCubeSetup(jql, issues.length, dimensions);
508
+ }
304
509
  // ── Main Handler ───────────────────────────────────────────────────────
305
510
  export async function handleAnalysisRequest(jiraClient, request) {
306
511
  const args = normalizeArgs(request.params?.arguments || {});
@@ -313,6 +518,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
313
518
  ? args.metrics
314
519
  : [];
315
520
  const hasSummary = requested.includes('summary');
521
+ const hasCubeSetup = requested.includes('cube_setup');
316
522
  const fetchMetrics = requested.length > 0
317
523
  ? requested.filter(m => ALL_METRICS.includes(m))
318
524
  : ALL_METRICS;
@@ -320,9 +526,25 @@ export async function handleAnalysisRequest(jiraClient, request) {
320
526
  const groupBy = (typeof args.groupBy === 'string' && VALID_GROUP_BY.includes(args.groupBy))
321
527
  ? args.groupBy
322
528
  : undefined;
529
+ // Parse compute expressions
530
+ let compute;
531
+ if (args.compute && Array.isArray(args.compute) && args.compute.length > 0) {
532
+ compute = parseComputeList(args.compute);
533
+ }
534
+ // Cube setup — discover dimensions from sample, no issue fetching
535
+ if (hasCubeSetup) {
536
+ const cubeText = await handleCubeSetup(jiraClient, jql);
537
+ const nextSteps = analysisNextSteps(jql, []);
538
+ return {
539
+ content: [{
540
+ type: 'text',
541
+ text: cubeText + '\n' + nextSteps,
542
+ }],
543
+ };
544
+ }
323
545
  // If only summary requested, skip issue fetching entirely
324
546
  if (hasSummary && fetchMetrics.length === 0) {
325
- const summaryText = await handleSummary(jiraClient, jql, groupBy);
547
+ const summaryText = await handleSummary(jiraClient, jql, groupBy, compute);
326
548
  const nextSteps = analysisNextSteps(jql, []);
327
549
  return {
328
550
  content: [{
@@ -371,7 +593,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
371
593
  const lines = [];
372
594
  // Summary first if requested alongside other metrics
373
595
  if (hasSummary) {
374
- const summaryText = await handleSummary(jiraClient, jql, groupBy);
596
+ const summaryText = await handleSummary(jiraClient, jql, groupBy, compute);
375
597
  lines.push(summaryText);
376
598
  }
377
599
  // Header for fetch-based metrics
@@ -41,6 +41,12 @@ export function setupResourceHandlers(jiraClient) {
41
41
  mimeType: 'application/json',
42
42
  description: 'Discovered custom fields ranked by usage — names, types, descriptions, writability'
43
43
  },
44
+ {
45
+ uri: 'jira://analysis/recipes',
46
+ name: 'Analysis Query Recipes',
47
+ mimeType: 'text/markdown',
48
+ description: 'Composition patterns for analyze_jira_issues — how to combine summary counts, groupBy, and JQL for PM dashboards'
49
+ },
44
50
  // Add tool resources
45
51
  ...toolResources.resources
46
52
  ]
@@ -92,6 +98,9 @@ export function setupResourceHandlers(jiraClient) {
92
98
  if (uri === 'jira://custom-fields') {
93
99
  return getCustomFieldsCatalog();
94
100
  }
101
+ if (uri === 'jira://analysis/recipes') {
102
+ return getAnalysisRecipes();
103
+ }
95
104
  // Handle resource templates
96
105
  const projectMatch = uri.match(/^jira:\/\/projects\/([^/]+)\/overview$/);
97
106
  if (projectMatch) {
@@ -356,6 +365,118 @@ async function getContextCustomFields(jiraClient, projectKey, issueType) {
356
365
  }],
357
366
  };
358
367
  }
368
+ /**
369
+ * Returns analysis query recipes — composition patterns for the analyze_jira_issues tool
370
+ */
371
+ function getAnalysisRecipes() {
372
+ const markdown = `# Analysis Query Recipes
373
+
374
+ ## Two Layers
375
+
376
+ Stack these for magnitude + detail:
377
+
378
+ 1. **Count layer** — \`analyze_jira_issues\` with \`metrics: ["summary"]\` + \`groupBy: "project"\` → exact counts, no cap
379
+ 2. **Detail layer** — \`analyze_jira_issues\` with other metrics, or \`manage_jira_filter\` execute_jql → individual issues
380
+
381
+ ## Recipes
382
+
383
+ ### Project Health Snapshot
384
+ **Question:** Which projects are busiest / most at-risk?
385
+ \`\`\`json
386
+ { "jql": "project in (AA, LGS, GD, GC) AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project" }
387
+ \`\`\`
388
+
389
+ ### Net Flow / Velocity
390
+ **Question:** Are we resolving faster than creating?
391
+ Run two summary queries and subtract:
392
+ - \`created >= -7d\` with \`groupBy: "project"\` → created this week
393
+ - \`resolved >= -7d\` with \`groupBy: "project"\` → resolved this week
394
+ Net = created - resolved. Positive = growing backlog. Negative = clearing.
395
+
396
+ ### Bug Ratio
397
+ **Question:** How much open work is bugs vs planned?
398
+ \`\`\`json
399
+ { "jql": "issuetype = Bug AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project" }
400
+ \`\`\`
401
+
402
+ ### Overdue Breakdown
403
+ **Question:** Where are we most behind?
404
+ \`\`\`json
405
+ { "jql": "resolution = Unresolved AND dueDate < now()", "metrics": ["summary"], "groupBy": "project" }
406
+ \`\`\`
407
+
408
+ ### Deadline Pressure Window
409
+ **Question:** What's due in the next 2 weeks?
410
+ \`\`\`json
411
+ { "jql": "resolution = Unresolved AND dueDate >= now() AND dueDate <= 14d", "metrics": ["summary"], "groupBy": "project" }
412
+ \`\`\`
413
+
414
+ ### Stale Backlog
415
+ **Question:** How much backlog needs grooming?
416
+ \`\`\`json
417
+ { "jql": "status = Backlog AND created <= -90d", "metrics": ["summary"], "groupBy": "project" }
418
+ \`\`\`
419
+
420
+ ### Ownership Gaps
421
+ **Question:** What has no owner?
422
+ \`\`\`json
423
+ { "jql": "resolution = Unresolved AND assignee is EMPTY", "metrics": ["summary"], "groupBy": "project" }
424
+ \`\`\`
425
+
426
+ ### Planning Coverage
427
+ **Question:** How much work has no due date?
428
+ \`\`\`json
429
+ { "jql": "resolution = Unresolved AND dueDate is EMPTY", "metrics": ["summary"], "groupBy": "project" }
430
+ \`\`\`
431
+
432
+ ## Data Cube (Advanced)
433
+
434
+ For multi-dimensional analysis, use the two-phase cube pattern:
435
+
436
+ ### Phase 1: Discover dimensions
437
+ \`\`\`json
438
+ { "jql": "project in (AA, LGS, GD, GC) AND resolution = Unresolved", "metrics": ["cube_setup"] }
439
+ \`\`\`
440
+ Returns available dimensions, their values, and cost estimates for each groupBy option.
441
+
442
+ ### Phase 2: Execute with computed columns
443
+ \`\`\`json
444
+ { "jql": "project in (AA, LGS, GD, GC) AND resolution = Unresolved", "metrics": ["summary"], "groupBy": "project", "compute": ["bug_pct = bugs / total * 100", "net_flow = created_7d - resolved_7d", "clearing = resolved_7d > created_7d"] }
445
+ \`\`\`
446
+
447
+ ### Compute DSL
448
+ - Arithmetic: \`+\`, \`-\`, \`*\`, \`/\`
449
+ - Comparisons: \`>\`, \`<\`, \`>=\`, \`<=\`, \`==\`, \`!=\` (produce Yes/No)
450
+ - Standard columns: total, open, overdue, high, created_7d, resolved_7d
451
+ - Implicit measures (lazily resolved): bugs, unassigned, no_due_date, blocked
452
+ - Max 5 expressions per query
453
+
454
+ ### Example computed columns
455
+ - \`bug_pct = bugs / total * 100\` — bug ratio as percentage
456
+ - \`net_flow = created_7d - resolved_7d\` — positive = growing backlog
457
+ - \`clearing = resolved_7d > created_7d\` — Yes/No: is backlog shrinking?
458
+ - \`risk = overdue > 10\` — Yes/No flag for at-risk projects
459
+ - \`velocity = resolved_7d / 7\` — daily throughput
460
+
461
+ ## Key Patterns
462
+
463
+ - \`groupBy: "project"\` turns any query into a cross-project comparison table with exact counts
464
+ - \`groupBy: "assignee"\` / \`"priority"\` / \`"issuetype"\` — slice by any dimension
465
+ - \`compute\` adds derived columns without extra tool calls
466
+ - Two summary queries = velocity (created vs resolved over same window)
467
+ - \`dueDate is EMPTY\` surfaces planning gaps that overdue queries miss
468
+ - \`assignee is EMPTY AND priority in (High, Highest)\` = high-priority work with no owner (most actionable)
469
+ - Use \`cube_setup\` first to discover dimensions, then \`summary\` + \`groupBy\` + \`compute\` to execute
470
+ - Use \`summary\` for cross-project scope, then \`distribution\`/\`schedule\` per-project for detail
471
+ `;
472
+ return {
473
+ contents: [{
474
+ uri: 'jira://analysis/recipes',
475
+ mimeType: 'text/markdown',
476
+ text: markdown,
477
+ }],
478
+ };
479
+ }
359
480
  /**
360
481
  * Gets all available issue link types
361
482
  */
@@ -326,7 +326,7 @@ 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. 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).',
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). Read jira://analysis/recipes for composition patterns.',
330
330
  inputSchema: {
331
331
  type: 'object',
332
332
  properties: {
@@ -338,15 +338,21 @@ export const toolSchemas = {
338
338
  type: 'array',
339
339
  items: {
340
340
  type: 'string',
341
- enum: ['summary', 'points', 'time', 'schedule', 'cycle', 'distribution'],
341
+ enum: ['summary', 'points', 'time', 'schedule', 'cycle', 'distribution', 'cube_setup'],
342
342
  },
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.',
343
+ description: 'Which metric groups to compute. summary = exact issue counts via count API (no cap, fastest). cube_setup = sample issues and discover available dimensions/measures for cube queries. 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 and cube_setup.',
344
344
  },
345
345
  groupBy: {
346
346
  type: 'string',
347
347
  enum: ['project', 'assignee', 'priority', 'issuetype'],
348
348
  description: 'Split summary counts by this dimension. Use with metrics: ["summary"]. "project" produces a per-project comparison table.',
349
349
  },
350
+ compute: {
351
+ type: 'array',
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, blocked. Max 5 expressions. Example: ["bug_pct = bugs / total * 100", "clearing = resolved_7d > created_7d"].',
354
+ maxItems: 5,
355
+ },
350
356
  maxResults: {
351
357
  type: 'integer',
352
358
  description: 'Max issues to fetch for detail metrics (default 100, max 500). Does not apply to summary (which uses count API).',
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Bounded DSL for computed columns in data cube queries.
3
+ *
4
+ * Expressions: `name = expr` where expr supports:
5
+ * - Column references (any existing column name)
6
+ * - Numeric literals
7
+ * - Arithmetic: + - * /
8
+ * - Comparisons: > < >= <= == != (produce Yes/No)
9
+ *
10
+ * Evaluation is linear — each expression can reference columns from
11
+ * earlier expressions. No functions, no loops, no nesting beyond parens.
12
+ */
13
+ const MAX_EXPRESSIONS = 5;
14
+ const OP_CHARS = new Set(['+', '-', '*', '/', '>', '<', '=', '!']);
15
+ /** Parse a compute expression string into name + expr */
16
+ export function parseComputeExpr(raw) {
17
+ const eqIdx = raw.indexOf('=');
18
+ // Must have = that isn't part of == or != or >= or <=
19
+ if (eqIdx === -1) {
20
+ throw new Error(`Invalid compute expression: missing "=" in "${raw}"`);
21
+ }
22
+ // Find the first = that is an assignment (not ==, !=, >=, <=)
23
+ let assignIdx = -1;
24
+ for (let i = 0; i < raw.length; i++) {
25
+ if (raw[i] === '=') {
26
+ const prev = i > 0 ? raw[i - 1] : '';
27
+ const next = i + 1 < raw.length ? raw[i + 1] : '';
28
+ if (prev !== '!' && prev !== '>' && prev !== '<' && prev !== '=' && next !== '=') {
29
+ assignIdx = i;
30
+ break;
31
+ }
32
+ }
33
+ }
34
+ if (assignIdx === -1) {
35
+ throw new Error(`Invalid compute expression: no assignment "=" found in "${raw}"`);
36
+ }
37
+ const name = raw.slice(0, assignIdx).trim();
38
+ const expr = raw.slice(assignIdx + 1).trim();
39
+ if (!name || !/^[a-zA-Z_]\w*$/.test(name)) {
40
+ throw new Error(`Invalid column name: "${name}"`);
41
+ }
42
+ if (!expr) {
43
+ throw new Error(`Empty expression for column "${name}"`);
44
+ }
45
+ return { name, expr };
46
+ }
47
+ /** Tokenize an expression string */
48
+ function tokenize(expr) {
49
+ const tokens = [];
50
+ let i = 0;
51
+ while (i < expr.length) {
52
+ // Skip whitespace
53
+ if (expr[i] === ' ' || expr[i] === '\t') {
54
+ i++;
55
+ continue;
56
+ }
57
+ // Parentheses
58
+ if (expr[i] === '(' || expr[i] === ')') {
59
+ tokens.push({ type: 'paren', value: expr[i] });
60
+ i++;
61
+ continue;
62
+ }
63
+ // Operators (multi-char first: >=, <=, ==, !=)
64
+ if (OP_CHARS.has(expr[i])) {
65
+ const two = expr.slice(i, i + 2);
66
+ if (['>=', '<=', '==', '!='].includes(two)) {
67
+ tokens.push({ type: 'op', value: two });
68
+ i += 2;
69
+ continue;
70
+ }
71
+ if (['+', '-', '*', '/', '>', '<'].includes(expr[i])) {
72
+ tokens.push({ type: 'op', value: expr[i] });
73
+ i++;
74
+ continue;
75
+ }
76
+ }
77
+ // Numbers (integers and decimals)
78
+ if (/\d/.test(expr[i])) {
79
+ let num = '';
80
+ while (i < expr.length && /[\d.]/.test(expr[i])) {
81
+ num += expr[i];
82
+ i++;
83
+ }
84
+ tokens.push({ type: 'number', value: num });
85
+ continue;
86
+ }
87
+ // Identifiers (column names)
88
+ if (/[a-zA-Z_]/.test(expr[i])) {
89
+ let ident = '';
90
+ while (i < expr.length && /\w/.test(expr[i])) {
91
+ ident += expr[i];
92
+ i++;
93
+ }
94
+ tokens.push({ type: 'ident', value: ident });
95
+ continue;
96
+ }
97
+ throw new Error(`Unexpected character "${expr[i]}" in expression`);
98
+ }
99
+ return tokens;
100
+ }
101
+ /**
102
+ * Evaluate a tokenized expression with operator precedence.
103
+ * Uses a simple recursive descent: comparison < additive < multiplicative < primary
104
+ */
105
+ function evaluate(tokens, columns) {
106
+ let pos = 0;
107
+ function peek() { return tokens[pos]; }
108
+ function advance() { return tokens[pos++]; }
109
+ function primary() {
110
+ const tok = peek();
111
+ if (!tok)
112
+ throw new Error('Unexpected end of expression');
113
+ if (tok.type === 'paren' && tok.value === '(') {
114
+ advance(); // consume (
115
+ const val = additiveExpr();
116
+ const close = peek();
117
+ if (!close || close.value !== ')')
118
+ throw new Error('Missing closing parenthesis');
119
+ advance(); // consume )
120
+ return val;
121
+ }
122
+ if (tok.type === 'number') {
123
+ advance();
124
+ const num = parseFloat(tok.value);
125
+ if (isNaN(num))
126
+ throw new Error(`Invalid number: "${tok.value}"`);
127
+ return num;
128
+ }
129
+ if (tok.type === 'ident') {
130
+ advance();
131
+ const val = columns.get(tok.value);
132
+ if (val === undefined)
133
+ throw new Error(`Unknown column: "${tok.value}"`);
134
+ return val;
135
+ }
136
+ throw new Error(`Unexpected token: "${tok.value}"`);
137
+ }
138
+ function multiplicativeExpr() {
139
+ let left = primary();
140
+ while (peek()?.type === 'op' && (peek().value === '*' || peek().value === '/')) {
141
+ const op = advance().value;
142
+ const right = primary();
143
+ if (op === '*')
144
+ left *= right;
145
+ else
146
+ left = right === 0 ? 0 : left / right; // division by zero → 0
147
+ }
148
+ return left;
149
+ }
150
+ function additiveExpr() {
151
+ let left = multiplicativeExpr();
152
+ while (peek()?.type === 'op' && (peek().value === '+' || peek().value === '-')) {
153
+ const op = advance().value;
154
+ const right = multiplicativeExpr();
155
+ if (op === '+')
156
+ left += right;
157
+ else
158
+ left -= right;
159
+ }
160
+ return left;
161
+ }
162
+ function comparisonExpr() {
163
+ const left = additiveExpr();
164
+ const tok = peek();
165
+ if (tok?.type === 'op' && ['>', '<', '>=', '<=', '==', '!='].includes(tok.value)) {
166
+ const op = advance().value;
167
+ const right = additiveExpr();
168
+ let result;
169
+ switch (op) {
170
+ case '>':
171
+ result = left > right;
172
+ break;
173
+ case '<':
174
+ result = left < right;
175
+ break;
176
+ case '>=':
177
+ result = left >= right;
178
+ break;
179
+ case '<=':
180
+ result = left <= right;
181
+ break;
182
+ case '==':
183
+ result = left === right;
184
+ break;
185
+ case '!=':
186
+ result = left !== right;
187
+ break;
188
+ default: throw new Error(`Unknown operator: "${op}"`);
189
+ }
190
+ return result ? 'Yes' : 'No';
191
+ }
192
+ return left;
193
+ }
194
+ const result = comparisonExpr();
195
+ if (pos < tokens.length) {
196
+ throw new Error(`Unexpected token after expression: "${tokens[pos].value}"`);
197
+ }
198
+ return result;
199
+ }
200
+ /** Parse and validate a list of compute expressions */
201
+ export function parseComputeList(rawExpressions) {
202
+ if (rawExpressions.length > MAX_EXPRESSIONS) {
203
+ throw new Error(`Too many compute expressions (max ${MAX_EXPRESSIONS}, got ${rawExpressions.length})`);
204
+ }
205
+ const columns = [];
206
+ const names = new Set();
207
+ for (const raw of rawExpressions) {
208
+ const col = parseComputeExpr(raw);
209
+ if (names.has(col.name)) {
210
+ throw new Error(`Duplicate column name: "${col.name}"`);
211
+ }
212
+ names.add(col.name);
213
+ columns.push(col);
214
+ }
215
+ return columns;
216
+ }
217
+ /** Evaluate compute expressions against a row of column values */
218
+ export function evaluateRow(columns, rowValues) {
219
+ // Work on a copy so computed columns accumulate
220
+ const ctx = new Map(rowValues);
221
+ const results = [];
222
+ for (const col of columns) {
223
+ const tokens = tokenize(col.expr);
224
+ const value = evaluate(tokens, ctx);
225
+ // Store numeric value in context for subsequent expressions
226
+ if (typeof value === 'number') {
227
+ ctx.set(col.name, value);
228
+ }
229
+ else {
230
+ // Boolean results stored as 1/0 for downstream references
231
+ ctx.set(col.name, value === 'Yes' ? 1 : 0);
232
+ }
233
+ results.push({ name: col.name, value });
234
+ }
235
+ return results;
236
+ }
237
+ /** Extract column references from expressions (for lazy measure detection) */
238
+ export function extractColumnRefs(columns) {
239
+ const refs = new Set();
240
+ for (const col of columns) {
241
+ const tokens = tokenize(col.expr);
242
+ for (const tok of tokens) {
243
+ if (tok.type === 'ident') {
244
+ refs.add(tok.value);
245
+ }
246
+ }
247
+ }
248
+ return refs;
249
+ }
@@ -130,6 +130,6 @@ export function analysisNextSteps(jql, issueKeys) {
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
- steps.push({ 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
- return formatSteps(steps);
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
+ return formatSteps(steps) + '\n- Read `jira://analysis/recipes` for data cube patterns and compute DSL examples';
135
135
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
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",