@aaronsb/jira-cloud-mcp 0.5.8 → 0.5.10

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.
@@ -410,6 +410,11 @@ export class JiraClient {
410
410
  url: attachment.content || '',
411
411
  }));
412
412
  }
413
+ async getFilter(filterId) {
414
+ return await this.client.filters.getFilter({
415
+ id: parseInt(filterId, 10),
416
+ });
417
+ }
413
418
  async getFilterIssues(filterId) {
414
419
  const filter = await this.client.filters.getFilter({
415
420
  id: parseInt(filterId, 10)
@@ -1003,6 +1008,52 @@ export class JiraClient {
1003
1008
  const response = await this.client.issues.createIssue({ fields });
1004
1009
  return { key: response.key };
1005
1010
  }
1011
+ async createFilter(name, jql, description, favourite) {
1012
+ const result = await this.client.filters.createFilter({
1013
+ name,
1014
+ jql,
1015
+ description: description || '',
1016
+ favourite: favourite ?? false,
1017
+ });
1018
+ if (!result.id || !result.name) {
1019
+ throw new Error('Invalid filter response from Jira');
1020
+ }
1021
+ return {
1022
+ id: result.id,
1023
+ name: result.name,
1024
+ owner: result.owner?.displayName || 'Unknown',
1025
+ favourite: result.favourite || false,
1026
+ viewUrl: result.viewUrl || '',
1027
+ description: result.description || '',
1028
+ jql: result.jql || '',
1029
+ };
1030
+ }
1031
+ async updateFilter(filterId, updates) {
1032
+ // Fetch existing filter to merge with updates (API requires name)
1033
+ const existing = await this.client.filters.getFilter({ id: parseInt(filterId, 10) });
1034
+ const result = await this.client.filters.updateFilter({
1035
+ id: parseInt(filterId, 10),
1036
+ name: updates.name || existing.name || '',
1037
+ jql: updates.jql ?? existing.jql,
1038
+ description: updates.description ?? existing.description,
1039
+ favourite: updates.favourite ?? existing.favourite,
1040
+ });
1041
+ if (!result.id || !result.name) {
1042
+ throw new Error('Invalid filter response from Jira');
1043
+ }
1044
+ return {
1045
+ id: result.id,
1046
+ name: result.name,
1047
+ owner: result.owner?.displayName || 'Unknown',
1048
+ favourite: result.favourite || false,
1049
+ viewUrl: result.viewUrl || '',
1050
+ description: result.description || '',
1051
+ jql: result.jql || '',
1052
+ };
1053
+ }
1054
+ async deleteFilter(filterId) {
1055
+ await this.client.filters.deleteFilter(filterId);
1056
+ }
1006
1057
  async listMyFilters(expand = false) {
1007
1058
  const filters = await this.client.filters.getMyFilters();
1008
1059
  return Promise.all(filters.map(async (filter) => {
@@ -592,9 +592,29 @@ async function handleCubeSetup(jiraClient, jql) {
592
592
  // ── Main Handler ───────────────────────────────────────────────────────
593
593
  export async function handleAnalysisRequest(jiraClient, request) {
594
594
  const args = normalizeArgs(request.params?.arguments || {});
595
- const jql = args.jql;
596
- if (!jql || typeof jql !== 'string' || jql.trim() === '') {
597
- throw new McpError(ErrorCode.InvalidParams, 'jql parameter is required.');
595
+ // Resolve JQL: filterId takes precedence over inline jql
596
+ let jql;
597
+ let filterSource;
598
+ const filterId = args.filterId;
599
+ if (filterId && typeof filterId === 'string' && filterId.trim() !== '') {
600
+ let filter;
601
+ try {
602
+ filter = await jiraClient.getFilter(filterId);
603
+ }
604
+ catch {
605
+ throw new McpError(ErrorCode.InvalidParams, `Filter ${filterId} not found or not accessible. Use manage_jira_filter with operation "list" to see available filters.`);
606
+ }
607
+ if (!filter.jql) {
608
+ throw new McpError(ErrorCode.InvalidParams, `Filter ${filterId} has no JQL query.`);
609
+ }
610
+ jql = filter.jql;
611
+ filterSource = `${filter.name || filterId} (filter ${filterId})`;
612
+ }
613
+ else {
614
+ jql = args.jql;
615
+ if (!jql || typeof jql !== 'string' || jql.trim() === '') {
616
+ throw new McpError(ErrorCode.InvalidParams, 'Either jql or filterId parameter is required.');
617
+ }
598
618
  }
599
619
  // Parse requested metrics
600
620
  const requested = (args.metrics && Array.isArray(args.metrics))
@@ -620,22 +640,24 @@ export async function handleAnalysisRequest(jiraClient, request) {
620
640
  // Cube setup — discover dimensions from sample, no issue fetching
621
641
  if (hasCubeSetup) {
622
642
  const cubeText = await handleCubeSetup(jiraClient, jql);
623
- const nextSteps = analysisNextSteps(jql, []);
643
+ const nextSteps = analysisNextSteps(jql, [], false, undefined, filterSource);
644
+ const banner = filterSource ? `*Using saved filter: ${filterSource}*\n\n` : '';
624
645
  return {
625
646
  content: [{
626
647
  type: 'text',
627
- text: cubeText + '\n' + nextSteps,
648
+ text: banner + cubeText + '\n' + nextSteps,
628
649
  }],
629
650
  };
630
651
  }
631
652
  // If only summary requested, skip issue fetching entirely
632
653
  if (hasSummary && fetchMetrics.length === 0) {
633
654
  const summaryText = await handleSummary(jiraClient, jql, groupBy, compute, groupLimit);
634
- const nextSteps = analysisNextSteps(jql, [], false, groupBy);
655
+ const nextSteps = analysisNextSteps(jql, [], false, groupBy, filterSource);
656
+ const banner = filterSource ? `*Using saved filter: ${filterSource}*\n\n` : '';
635
657
  return {
636
658
  content: [{
637
659
  type: 'text',
638
- text: summaryText + '\n' + nextSteps,
660
+ text: banner + summaryText + '\n' + nextSteps,
639
661
  }],
640
662
  };
641
663
  }
@@ -676,6 +698,10 @@ export async function handleAnalysisRequest(jiraClient, request) {
676
698
  }
677
699
  const now = new Date();
678
700
  const lines = [];
701
+ if (filterSource) {
702
+ lines.push(`*Using saved filter: ${filterSource}*`);
703
+ lines.push('');
704
+ }
679
705
  // Summary first if requested alongside other metrics
680
706
  if (hasSummary) {
681
707
  const summaryText = await handleSummary(jiraClient, jql, groupBy, compute, groupLimit);
@@ -704,7 +730,7 @@ export async function handleAnalysisRequest(jiraClient, request) {
704
730
  }
705
731
  }
706
732
  // Next steps
707
- const nextSteps = analysisNextSteps(jql, allIssues.slice(0, 3).map(i => i.key), truncated, groupBy);
733
+ const nextSteps = analysisNextSteps(jql, allIssues.slice(0, 3).map(i => i.key), truncated, groupBy, filterSource);
708
734
  lines.push(nextSteps);
709
735
  return {
710
736
  content: [{
@@ -207,14 +207,62 @@ async function handleListFilters(jiraClient, args) {
207
207
  ],
208
208
  };
209
209
  }
210
- async function handleCreateFilter(_jiraClient, _args) {
211
- throw new McpError(ErrorCode.InternalError, 'Create filter operation is not yet implemented');
210
+ async function handleCreateFilter(jiraClient, args) {
211
+ if (!args.name) {
212
+ throw new McpError(ErrorCode.InvalidParams, 'name is required for create operation');
213
+ }
214
+ if (!args.jql) {
215
+ throw new McpError(ErrorCode.InvalidParams, 'jql is required for create operation');
216
+ }
217
+ const filter = await jiraClient.createFilter(args.name, args.jql, args.description, args.favourite);
218
+ const lines = [
219
+ `# Filter Created: ${filter.name}`,
220
+ '',
221
+ `**ID:** ${filter.id}`,
222
+ `**JQL:** \`${filter.jql}\``,
223
+ `**Owner:** ${filter.owner}`,
224
+ filter.description ? `**Description:** ${filter.description}` : '',
225
+ `**Favourite:** ${filter.favourite ? 'Yes' : 'No'}`,
226
+ '',
227
+ `[View in Jira](${filter.viewUrl})`,
228
+ ].filter(Boolean);
229
+ return {
230
+ content: [{ type: 'text', text: lines.join('\n') + filterNextSteps('create', filter.id) }],
231
+ };
212
232
  }
213
- async function handleUpdateFilter(_jiraClient, _args) {
214
- throw new McpError(ErrorCode.InternalError, 'Update filter operation is not yet implemented');
233
+ async function handleUpdateFilter(jiraClient, args) {
234
+ if (!args.filterId) {
235
+ throw new McpError(ErrorCode.InvalidParams, 'filterId is required for update operation');
236
+ }
237
+ const updates = {};
238
+ if (args.name)
239
+ updates.name = args.name;
240
+ if (args.jql)
241
+ updates.jql = args.jql;
242
+ if (args.description !== undefined)
243
+ updates.description = args.description;
244
+ if (args.favourite !== undefined)
245
+ updates.favourite = args.favourite;
246
+ const filter = await jiraClient.updateFilter(args.filterId, updates);
247
+ const lines = [
248
+ `# Filter Updated: ${filter.name}`,
249
+ '',
250
+ `**ID:** ${filter.id}`,
251
+ `**JQL:** \`${filter.jql}\``,
252
+ `**Owner:** ${filter.owner}`,
253
+ ];
254
+ return {
255
+ content: [{ type: 'text', text: lines.join('\n') + filterNextSteps('get', filter.id) }],
256
+ };
215
257
  }
216
- async function handleDeleteFilter(_jiraClient, _args) {
217
- throw new McpError(ErrorCode.InternalError, 'Delete filter operation is not yet implemented');
258
+ async function handleDeleteFilter(jiraClient, args) {
259
+ if (!args.filterId) {
260
+ throw new McpError(ErrorCode.InvalidParams, 'filterId is required for delete operation');
261
+ }
262
+ await jiraClient.deleteFilter(args.filterId);
263
+ return {
264
+ content: [{ type: 'text', text: `Filter ${args.filterId} deleted.` }],
265
+ };
218
266
  }
219
267
  async function handleExecuteFilter(jiraClient, _args) {
220
268
  const filterId = _args.filterId;
package/build/index.js CHANGED
@@ -95,6 +95,9 @@ class JiraServer {
95
95
  const { name, arguments: args } = request.params;
96
96
  return getPrompt(name, args);
97
97
  });
98
+ // Track consecutive single-issue calls to suggest queue tool
99
+ const QUEUE_HINT_THRESHOLD = 3;
100
+ let consecutiveIssueCalls = 0;
98
101
  // Set up tool handlers
99
102
  this.server.setRequestHandler(CallToolRequestSchema, async (request, _extra) => {
100
103
  console.error('Received request:', JSON.stringify(request, null, 2));
@@ -121,6 +124,17 @@ class JiraServer {
121
124
  if (!response) {
122
125
  throw new McpError(ErrorCode.InternalError, `No response from handler for tool: ${name}`);
123
126
  }
127
+ // Track consecutive manage_jira_issue calls and suggest queue tool
128
+ if (name === 'manage_jira_issue') {
129
+ consecutiveIssueCalls++;
130
+ if (consecutiveIssueCalls >= QUEUE_HINT_THRESHOLD && response.content?.[0]?.text) {
131
+ 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
+ consecutiveIssueCalls = 0;
133
+ }
134
+ }
135
+ else {
136
+ consecutiveIssueCalls = 0;
137
+ }
124
138
  return response;
125
139
  }
126
140
  catch (error) {
@@ -10,40 +10,50 @@ const builders = {
10
10
  backlog_health({ project }) {
11
11
  return {
12
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.
13
+ messages: [msg(`Analyze backlog health for project ${project}. Use the queue_jira_operations tool to run this as a pipeline.
14
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"]}
15
+ Use queue_jira_operations with this pipeline:
16
+ Operation 0 Save the query as a reusable filter:
17
+ {"tool":"manage_jira_filter","args":{"operation":"create","name":"${project} backlog health","jql":"project = ${project} AND resolution = Unresolved"}}
17
18
 
18
- Step 2Cycle metrics for staleness distribution and status age:
19
- {"jql":"project = ${project} AND resolution = Unresolved","metrics":["cycle"],"maxResults":100}
19
+ Operation 1Summary with data quality signals (uses $0.filterId):
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"]}}
20
21
 
21
- Step 3Summarize findings:
22
+ Operation 2Cycle metrics for staleness distribution:
23
+ {"tool":"analyze_jira_issues","args":{"filterId":"$0.filterId","metrics":["cycle"],"maxResults":100}}
24
+
25
+ After the pipeline completes, summarize findings:
22
26
  - What percentage of the backlog is rotting (no owner, no dates, untouched)?
23
27
  - What's stuck in the same status for 30+ days?
24
28
  - What's missing estimates or start dates?
25
29
  - Flag the worst offenders by issue key.
26
- - Recommend specific triage actions.`)],
30
+ - Recommend specific triage actions.
31
+ - The saved filter can be reused for follow-up analysis or shared with the team.`)],
27
32
  };
28
33
  },
29
34
  contributor_workload({ project }) {
30
35
  return {
31
36
  description: `Contributor workload for ${project}`,
32
- messages: [msg(`Analyze contributor workload for project ${project}. Use the analyze_jira_issues tool.
37
+ messages: [msg(`Analyze contributor workload for project ${project}. Use queue_jira_operations to run the analysis pipeline in a single call.
38
+
39
+ Use queue_jira_operations with this pipeline:
40
+ Operation 0 — Save the base query as a filter:
41
+ {"tool":"manage_jira_filter","args":{"operation":"create","name":"${project} workload","jql":"project = ${project} AND resolution = Unresolved"}}
33
42
 
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"]}
43
+ Operation 1 — Assignee distribution with quality signals (uses $0.filterId):
44
+ {"tool":"analyze_jira_issues","args":{"filterId":"$0.filterId","metrics":["summary"],"groupBy":"assignee","compute":["stale_pct = stale / open * 100","blocked_pct = blocked / open * 100"]}}
36
45
 
37
- Step 2 For the top 3 assignees by open issue count, run scoped detail metrics:
46
+ After the pipeline, for the top 3 assignees by open count, run scoped detail:
38
47
  {"jql":"project = ${project} AND resolution = Unresolved AND assignee = '{name}'","metrics":["cycle","schedule"]}
39
48
 
40
- This pattern keeps each detail query within the sample cap for precise results.
49
+ This keeps each detail query within the sample cap for precise results.
41
50
 
42
- Step 3 — Summarize:
51
+ Summarize:
43
52
  - Who has the most open work?
44
53
  - Who has the most stale or at-risk issues?
45
54
  - Are there load imbalances?
46
- - What needs reassignment or triage?`)],
55
+ - What needs reassignment or triage?
56
+ - The saved filter can be reused for follow-up workload checks.`)],
47
57
  };
48
58
  },
49
59
  sprint_review({ board_id }) {
@@ -52,16 +62,21 @@ Step 3 — Summarize:
52
62
  messages: [msg(`Prepare a sprint review for board ${board_id}. Use manage_jira_sprint and analyze_jira_issues tools.
53
63
 
54
64
  Step 1 — Find the active sprint:
55
- {"operation":"list","boardId":${board_id},"state":"active"}
65
+ Use manage_jira_sprint: {"operation":"list","boardId":${board_id},"state":"active"}
66
+
67
+ Step 2 — Use queue_jira_operations to run the analysis pipeline (use the sprint ID from step 1):
68
+ Operation 0 — Save the sprint query as a filter:
69
+ {"tool":"manage_jira_filter","args":{"operation":"create","name":"Sprint review board ${board_id}","jql":"sprint = {sprintId}"}}
56
70
 
57
- Step 2Analyze sprint issues (use the sprint ID from step 1):
58
- {"jql":"sprint = {sprintId}","metrics":["summary","points","schedule"],"compute":["done_pct = resolved_7d / total * 100"]}
71
+ Operation 1Summary + velocity metrics:
72
+ {"tool":"analyze_jira_issues","args":{"filterId":"$0.filterId","metrics":["summary","points","schedule"],"compute":["done_pct = resolved_7d / total * 100"]}}
59
73
 
60
74
  Step 3 — Summarize:
61
75
  - Current velocity vs planned
62
76
  - Scope changes (items added/removed mid-sprint)
63
77
  - At-risk items (overdue, blocked, stale)
64
- - Completion forecast — will the sprint goal be met?`)],
78
+ - Completion forecast — will the sprint goal be met?
79
+ - The saved filter persists for daily standups or end-of-sprint reporting.`)],
65
80
  };
66
81
  },
67
82
  narrow_analysis({ jql }) {
@@ -326,13 +326,17 @@ 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. 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. 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) 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.',
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: "project in (AA, GC, LGS)", "sprint in openSprints()", "assignee = currentUser() AND resolution = Unresolved".',
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".',
336
+ },
337
+ filterId: {
338
+ type: 'string',
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.',
336
340
  },
337
341
  metrics: {
338
342
  type: 'array',
@@ -364,12 +368,12 @@ export const toolSchemas = {
364
368
  default: 100,
365
369
  },
366
370
  },
367
- required: ['jql'],
371
+ required: [],
368
372
  },
369
373
  },
370
374
  queue_jira_operations: {
371
375
  name: 'queue_jira_operations',
372
- description: 'Execute multiple Jira operations in a single call. Operations run sequentially with result references ($0.key) and per-operation error strategies (bail/continue).',
376
+ 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.',
373
377
  inputSchema: {
374
378
  type: 'object',
375
379
  properties: {
@@ -61,7 +61,7 @@ export function filterNextSteps(operation, filterId, jql) {
61
61
  steps.push({ description: 'Run a filter', tool: 'manage_jira_filter', example: { operation: 'execute_filter', filterId: '<id>' } }, { description: 'Search with JQL directly', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql: '<query>' } });
62
62
  break;
63
63
  case 'create':
64
- steps.push({ description: 'Execute the new filter', tool: 'manage_jira_filter', example: { operation: 'execute_filter', filterId } });
64
+ steps.push({ description: 'Execute the new filter', tool: 'manage_jira_filter', example: { operation: 'execute_filter', filterId } }, { description: 'Run analysis against this filter', tool: 'analyze_jira_issues', example: { filterId, metrics: ['summary'], groupBy: 'assignee' } });
65
65
  break;
66
66
  }
67
67
  return steps.length > 0 ? formatSteps(steps) : '';
@@ -125,7 +125,7 @@ export function boardNextSteps(operation, boardId) {
125
125
  }
126
126
  return steps.length > 0 ? formatSteps(steps) : '';
127
127
  }
128
- export function analysisNextSteps(jql, issueKeys, truncated = false, groupBy) {
128
+ export function analysisNextSteps(jql, issueKeys, truncated = false, groupBy, filterSource) {
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] } });
@@ -144,5 +144,9 @@ export function analysisNextSteps(jql, issueKeys, truncated = false, groupBy) {
144
144
  if (truncated) {
145
145
  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
146
  }
147
+ // Suggest saving as filter if not already using one
148
+ if (!filterSource) {
149
+ steps.push({ description: 'Save this query as a filter for reuse across analyses', tool: 'manage_jira_filter', example: { operation: 'create', name: '<descriptive name>', jql } });
150
+ }
147
151
  return formatSteps(steps) + '\n- Read `jira://analysis/recipes` for data cube patterns and compute DSL examples';
148
152
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
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",