@aaronsb/jira-cloud-mcp 0.6.0 → 0.6.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.
@@ -14,10 +14,18 @@ const SCREEN_WEIGHT = 10;
14
14
  const RECENCY_WEIGHT = 5;
15
15
  const RECENCY_HALF_LIFE_DAYS = 30;
16
16
  // ── Field Discovery ────────────────────────────────────────────────────
17
+ /** Well-known locked fields identified by schema custom type */
18
+ const WELL_KNOWN_FIELDS = {
19
+ 'com.pyxis.greenhopper.jira:gh-sprint': 'sprint',
20
+ 'com.pyxis.greenhopper.jira:jsw-story-points': 'storyPoints',
21
+ 'com.atlassian.jpo:jpo-custom-field-baseline-start': 'startDate',
22
+ 'com.atlassian.jpo:jpo-custom-field-baseline-end': 'targetDate',
23
+ };
17
24
  export class FieldDiscovery {
18
25
  catalog = [];
19
26
  nameToId = new Map();
20
27
  idToField = new Map();
28
+ wellKnown = new Map(); // logical name → field ID
21
29
  stats = null;
22
30
  ready = false;
23
31
  error = null;
@@ -37,6 +45,14 @@ export class FieldDiscovery {
37
45
  getStats() {
38
46
  return this.stats;
39
47
  }
48
+ /** Get a well-known field ID by logical name (e.g., 'sprint', 'storyPoints') */
49
+ getWellKnownFieldId(logicalName) {
50
+ return this.wellKnown.get(logicalName) ?? null;
51
+ }
52
+ /** All discovered well-known field mappings */
53
+ getWellKnownFields() {
54
+ return Object.fromEntries(this.wellKnown);
55
+ }
40
56
  /** Resolve a human-readable field name to its Jira field ID */
41
57
  resolveNameToId(name) {
42
58
  return this.nameToId.get(name.toLowerCase()) ?? null;
@@ -115,6 +131,14 @@ export class FieldDiscovery {
115
131
  console.error('[field-discovery] Starting custom field discovery...');
116
132
  const rawFields = await this.fetchAllCustomFields(client);
117
133
  console.error(`[field-discovery] Fetched ${rawFields.length} custom fields`);
134
+ // Detect well-known locked fields by schema type (before filtering)
135
+ for (const field of rawFields) {
136
+ const logicalName = WELL_KNOWN_FIELDS[field.schemaCustom];
137
+ if (logicalName) {
138
+ this.wellKnown.set(logicalName, field.id);
139
+ console.error(`[field-discovery] Well-known: ${logicalName} → ${field.id} (${field.name})`);
140
+ }
141
+ }
118
142
  const { qualified, stats } = this.filterAndClassify(rawFields);
119
143
  console.error(`[field-discovery] ${qualified.length} fields passed filters`);
120
144
  const scored = this.scoreFields(qualified);
@@ -28,8 +28,13 @@ export class JiraClient {
28
28
  this.customFields = {
29
29
  startDate: config.customFields?.startDate ?? 'customfield_10015',
30
30
  storyPoints: config.customFields?.storyPoints ?? 'customfield_10016',
31
+ sprint: config.customFields?.sprint ?? null, // Discovered at runtime via field-discovery
31
32
  };
32
33
  }
34
+ /** Update sprint field ID after runtime discovery */
35
+ setSprintFieldId(fieldId) {
36
+ this.customFields.sprint = fieldId;
37
+ }
33
38
  /** Standard Jira fields that require ADF format in v3 API */
34
39
  static ADF_FIELDS = new Set(['environment']);
35
40
  /** Convert any ADF-type fields in customFields from markdown to ADF */
@@ -89,9 +94,24 @@ export class JiraClient {
89
94
  this.customFields.startDate,
90
95
  this.customFields.storyPoints,
91
96
  'timeestimate',
97
+ ...(this.customFields.sprint ? [this.customFields.sprint] : []),
92
98
  'issuelinks',
93
99
  ];
94
100
  }
101
+ /** Extract the most relevant sprint name from the sprint field array */
102
+ extractSprintName(sprints) {
103
+ if (!Array.isArray(sprints) || sprints.length === 0)
104
+ return null;
105
+ // Prefer active sprint, then future, then most recent closed
106
+ const active = sprints.find((s) => s.state === 'active');
107
+ if (active)
108
+ return active.name ?? null;
109
+ const future = sprints.find((s) => s.state === 'future');
110
+ if (future)
111
+ return future.name ?? null;
112
+ // Fall back to last sprint in array (most recent)
113
+ return sprints[sprints.length - 1]?.name ?? null;
114
+ }
95
115
  /** Maps a raw Jira API issue to our JiraIssueDetails shape */
96
116
  mapIssueFields(issue) {
97
117
  const fields = issue.fields ?? issue.fields;
@@ -127,6 +147,7 @@ export class JiraClient {
127
147
  startDate: fields?.[this.customFields.startDate] || null,
128
148
  storyPoints: fields?.[this.customFields.storyPoints] ?? null,
129
149
  timeEstimate: fields?.timeestimate ?? null,
150
+ sprint: this.customFields.sprint ? this.extractSprintName(fields?.[this.customFields.sprint]) : null,
130
151
  issueLinks: (fields?.issuelinks || []).map((link) => ({
131
152
  type: link.type?.name || '',
132
153
  outward: link.outwardIssue?.key || null,
@@ -517,6 +538,7 @@ export class JiraClient {
517
538
  'status', 'resolution', 'labels', 'created', 'updated',
518
539
  'resolutiondate', 'statuscategorychangedate', 'duedate', 'timeestimate',
519
540
  this.customFields.startDate, this.customFields.storyPoints,
541
+ ...(this.customFields.sprint ? [this.customFields.sprint] : []),
520
542
  ];
521
543
  const params = {
522
544
  jql: cleanJql,
@@ -6,7 +6,7 @@ import { analysisNextSteps } from '../utils/next-steps.js';
6
6
  import { normalizeArgs } from '../utils/normalize-args.js';
7
7
  const ALL_METRICS = ['points', 'time', 'schedule', 'cycle', 'distribution'];
8
8
  // flow and hierarchy are opt-in only
9
- const VALID_GROUP_BY = ['project', 'assignee', 'priority', 'issuetype', 'parent'];
9
+ const VALID_GROUP_BY = ['project', 'assignee', 'priority', 'issuetype', 'parent', 'sprint'];
10
10
  const MAX_ISSUES_HARD = 500; // absolute ceiling for detail metrics — beyond this, context explodes
11
11
  const MAX_ISSUES_DEFAULT = 100;
12
12
  const CUBE_SAMPLE_PCT = 0.2; // 20% of total issues
@@ -272,6 +272,10 @@ export function renderDistribution(issues, groupLimit = DEFAULT_GROUP_LIMIT) {
272
272
  lines.push(`**By priority:** ${mapToString(byPriority, ' | ', groupLimit)}`);
273
273
  const byType = countBy(issues, i => i.issueType || 'Unknown');
274
274
  lines.push(`**By type:** ${mapToString(byType, ' | ', groupLimit)}`);
275
+ const bySprint = countBy(issues, i => i.sprint || '(no sprint)');
276
+ if (bySprint.size > 1 || !bySprint.has('(no sprint)')) {
277
+ lines.push(`**By sprint:** ${mapToString(bySprint, ' | ', groupLimit)}`);
278
+ }
275
279
  return lines.join('\n');
276
280
  }
277
281
  export async function renderFlow(jiraClient, issues) {
@@ -437,6 +441,8 @@ export function groupByJqlClause(dimension, values) {
437
441
  return values.map(v => `issuetype = "${v}"`);
438
442
  case 'parent':
439
443
  return values.map(v => v === '(no parent)' ? 'issue not in childIssuesOf("")' : `parent = ${v}`);
444
+ case 'sprint':
445
+ return values.map(v => v === '(no sprint)' ? 'sprint is EMPTY' : `sprint = "${v}"`);
440
446
  }
441
447
  }
442
448
  async function buildCountRow(jiraClient, label, baseJql, implicitMeasureNames, implicitMeasureDefs) {
@@ -597,6 +603,7 @@ export function extractDimensions(issues, groupLimit = DEFAULT_GROUP_LIMIT) {
597
603
  { name: 'priority', extractor: i => i.priority || 'None' },
598
604
  { name: 'issuetype', extractor: i => i.issueType || 'Unknown' },
599
605
  { name: 'parent', extractor: i => i.parent || '(no parent)' },
606
+ { name: 'sprint', extractor: i => i.sprint || '(no sprint)' },
600
607
  ];
601
608
  return dims.map(({ name, extractor }) => {
602
609
  const counts = new Map();
@@ -770,6 +777,7 @@ function graphIssueToDetails(issue) {
770
777
  dueDate: issue.dueDate,
771
778
  startDate: issue.startDate,
772
779
  storyPoints: issue.storyPoints,
780
+ sprint: null,
773
781
  timeEstimate: null,
774
782
  issueLinks: [],
775
783
  };
package/build/index.js CHANGED
@@ -83,7 +83,13 @@ class JiraServer {
83
83
  });
84
84
  this.setupHandlers();
85
85
  // Start async field discovery (non-blocking)
86
- fieldDiscovery.startAsync(this.jiraClient.v3Client);
86
+ fieldDiscovery.startAsync(this.jiraClient.v3Client).then(() => {
87
+ const sprintId = fieldDiscovery.getWellKnownFieldId('sprint');
88
+ if (sprintId) {
89
+ this.jiraClient.setSprintFieldId(sprintId);
90
+ console.error(`[jira-cloud] Sprint field: ${sprintId}`);
91
+ }
92
+ }).catch(() => { });
87
93
  // CloudId discovery happens in run() before server connects — must complete
88
94
  // before ListTools so analyze_jira_plan is registered if available.
89
95
  this.server.onerror = (error) => console.error('[MCP Error]', error);
@@ -352,8 +352,8 @@ export const toolSchemas = {
352
352
  },
353
353
  groupBy: {
354
354
  type: 'string',
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.',
355
+ enum: ['project', 'assignee', 'priority', 'issuetype', 'parent', 'sprint'],
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. "sprint" groups by sprint name.',
357
357
  },
358
358
  compute: {
359
359
  type: 'array',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.6.0",
3
+ "version": "0.6.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",