@aaronsb/jira-cloud-mcp 0.8.1 → 0.9.0

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.
@@ -57,22 +57,42 @@ export async function discoverCloudId(host, email, apiToken) {
57
57
  export class GraphQLClient {
58
58
  authHeader;
59
59
  cloudId;
60
- constructor(email, apiToken, cloudId) {
60
+ tenantedEndpoint;
61
+ constructor(email, apiToken, cloudId, host) {
61
62
  this.authHeader = buildAuthHeader(email, apiToken);
62
63
  this.cloudId = cloudId;
64
+ this.tenantedEndpoint = host
65
+ ? `https://${extractHostname(host)}/gateway/api/graphql`
66
+ : null;
63
67
  }
64
68
  getCloudId() {
65
69
  return this.cloudId;
66
70
  }
71
+ /** Site container ARI for Townsquare queries */
72
+ getSiteAri() {
73
+ return `ari:cloud:townsquare::site/${this.cloudId}`;
74
+ }
67
75
  async query(query, variables = {}) {
76
+ return this._fetch(AGG_ENDPOINT, query, variables, {
77
+ 'X-ExperimentalApi': 'JiraPlan,JiraPlansSupport',
78
+ });
79
+ }
80
+ /** Query the tenanted endpoint ({host}/gateway/api/graphql) for site-scoped APIs like Townsquare */
81
+ async queryTenanted(query, variables = {}) {
82
+ if (!this.tenantedEndpoint) {
83
+ return { success: false, error: 'Tenanted endpoint not available (no host configured)' };
84
+ }
85
+ return this._fetch(this.tenantedEndpoint, query, variables);
86
+ }
87
+ async _fetch(endpoint, query, variables, extraHeaders) {
68
88
  try {
69
- const response = await fetch(AGG_ENDPOINT, {
89
+ const response = await fetch(endpoint, {
70
90
  method: 'POST',
71
91
  headers: {
72
92
  'Authorization': this.authHeader,
73
93
  'Content-Type': 'application/json',
74
94
  'Accept': 'application/json',
75
- 'X-ExperimentalApi': 'JiraPlan,JiraPlansSupport',
95
+ ...extraHeaders,
76
96
  },
77
97
  body: JSON.stringify({
78
98
  query,
@@ -0,0 +1,398 @@
1
+ // --- Queries ---
2
+ const GOALS_SEARCH_QUERY = `
3
+ query GoalsSearch($containerId: ID!, $searchString: String!, $sort: [TownsquareGoalSortEnum], $first: Int, $after: String) {
4
+ goals_search(containerId: $containerId, searchString: $searchString, sort: $sort, first: $first, after: $after) {
5
+ edges {
6
+ node {
7
+ id
8
+ name
9
+ key
10
+ url
11
+ state { value label }
12
+ owner { name }
13
+ parentGoal { name key }
14
+ subGoals(first: 50) {
15
+ edges { node { name key state { value } } }
16
+ }
17
+ workItems(first: 50) @optIn(to: "GraphStoreJiraEpicContributesToAtlasGoal") {
18
+ edges { node { ... on JiraIssue { key } } }
19
+ }
20
+ }
21
+ }
22
+ pageInfo {
23
+ hasNextPage
24
+ endCursor
25
+ }
26
+ }
27
+ }
28
+ `;
29
+ const GOAL_BY_KEY_QUERY = `
30
+ query GoalByKey($containerId: ID!, $goalKey: String!) {
31
+ goals_byKey(containerId: $containerId, goalKey: $goalKey) {
32
+ id
33
+ name
34
+ key
35
+ url
36
+ state { value label }
37
+ owner { name }
38
+ parentGoal { name key }
39
+ description
40
+ subGoals(first: 50) {
41
+ edges {
42
+ node {
43
+ id name key url
44
+ state { value label }
45
+ owner { name }
46
+ parentGoal { name key }
47
+ description
48
+ }
49
+ }
50
+ }
51
+ projects(first: 20) {
52
+ edges { node { name state { value } } }
53
+ }
54
+ workItems(first: 100) @optIn(to: "GraphStoreJiraEpicContributesToAtlasGoal") {
55
+ edges {
56
+ node {
57
+ ... on JiraIssue {
58
+ key
59
+ summary
60
+ status { name }
61
+ issueType { name }
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
67
+ }
68
+ `;
69
+ // --- Helpers ---
70
+ /** Extract plain text from an ADF JSON string or return as-is if not ADF */
71
+ function extractAdfText(raw) {
72
+ if (!raw)
73
+ return null;
74
+ try {
75
+ const doc = JSON.parse(raw);
76
+ if (doc?.type !== 'doc' || !Array.isArray(doc.content))
77
+ return raw;
78
+ return extractTextFromNodes(doc.content).trim() || null;
79
+ }
80
+ catch {
81
+ return raw;
82
+ }
83
+ }
84
+ function extractTextFromNodes(nodes) {
85
+ let text = '';
86
+ for (const node of nodes) {
87
+ if (node.text)
88
+ text += node.text;
89
+ if (Array.isArray(node.content))
90
+ text += extractTextFromNodes(node.content);
91
+ if (node.type === 'paragraph' || node.type === 'heading')
92
+ text += '\n';
93
+ if (node.type === 'hardBreak')
94
+ text += '\n';
95
+ if (node.type === 'listItem')
96
+ text += '- ';
97
+ }
98
+ return text;
99
+ }
100
+ /** Wrap plain text in ADF document format (required for Townsquare description/summary fields) */
101
+ function toAdf(text) {
102
+ return JSON.stringify({
103
+ version: 1, type: 'doc',
104
+ content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
105
+ });
106
+ }
107
+ // --- Functions ---
108
+ export async function searchGoals(client, searchString, sort = 'HIERARCHY_ASC', first = 50) {
109
+ const result = await client.queryTenanted(GOALS_SEARCH_QUERY, {
110
+ containerId: client.getSiteAri(),
111
+ searchString,
112
+ sort: [sort],
113
+ first,
114
+ });
115
+ if (!result.success || !result.data) {
116
+ return { success: false, error: result.error ?? 'No data returned' };
117
+ }
118
+ const edges = result.data.goals_search.edges;
119
+ const goals = edges.map(e => ({
120
+ id: e.node.id,
121
+ name: e.node.name,
122
+ key: e.node.key,
123
+ url: e.node.url,
124
+ state: e.node.state,
125
+ owner: e.node.owner,
126
+ parentGoal: e.node.parentGoal,
127
+ description: null,
128
+ }));
129
+ const workItemCounts = new Map();
130
+ for (const e of edges) {
131
+ const count = e.node.workItems?.edges?.filter(w => w.node.key).length ?? 0;
132
+ workItemCounts.set(e.node.key, count);
133
+ }
134
+ return { success: true, goals, workItemCounts };
135
+ }
136
+ export async function getGoalByKey(client, goalKey) {
137
+ const result = await client.queryTenanted(GOAL_BY_KEY_QUERY, {
138
+ containerId: client.getSiteAri(),
139
+ goalKey,
140
+ });
141
+ if (!result.success || !result.data) {
142
+ return { success: false, error: result.error ?? 'No data returned' };
143
+ }
144
+ const node = result.data.goals_byKey;
145
+ if (!node) {
146
+ return { success: false, error: `Goal ${goalKey} not found` };
147
+ }
148
+ const goal = {
149
+ id: node.id,
150
+ name: node.name,
151
+ key: node.key,
152
+ url: node.url,
153
+ state: node.state,
154
+ owner: node.owner,
155
+ parentGoal: node.parentGoal,
156
+ description: extractAdfText(node.description),
157
+ subGoals: (node.subGoals?.edges ?? []).map(e => ({
158
+ id: e.node.id,
159
+ name: e.node.name,
160
+ key: e.node.key,
161
+ url: e.node.url,
162
+ state: e.node.state,
163
+ owner: e.node.owner,
164
+ parentGoal: e.node.parentGoal,
165
+ description: extractAdfText(e.node.description),
166
+ })),
167
+ projects: (node.projects?.edges ?? []).map(e => e.node),
168
+ workItems: (node.workItems?.edges ?? [])
169
+ .filter(e => e.node.key)
170
+ .map(e => ({
171
+ key: e.node.key,
172
+ summary: e.node.summary ?? '',
173
+ status: e.node.status ?? { name: 'Unknown' },
174
+ issueType: e.node.issueType ?? { name: 'Unknown' },
175
+ })),
176
+ };
177
+ return { success: true, goal };
178
+ }
179
+ /**
180
+ * Resolve all Jira issue keys linked to a goal and optionally its sub-goals.
181
+ * Returns keys suitable for a `key in (...)` JQL query.
182
+ */
183
+ export async function resolveGoalWorkItems(client, goalKey, includeSubGoals = true) {
184
+ const result = await getGoalByKey(client, goalKey);
185
+ if (!result.success || !result.goal) {
186
+ return { success: false, error: result.error };
187
+ }
188
+ const goal = result.goal;
189
+ const issueKeys = new Set(goal.workItems.map(w => w.key));
190
+ if (includeSubGoals && goal.subGoals.length > 0) {
191
+ // Fetch each sub-goal's work items
192
+ const subResults = await Promise.all(goal.subGoals.map(sg => getGoalByKey(client, sg.key)));
193
+ for (const sub of subResults) {
194
+ if (sub.success && sub.goal) {
195
+ for (const w of sub.goal.workItems) {
196
+ issueKeys.add(w.key);
197
+ }
198
+ }
199
+ }
200
+ }
201
+ return { success: true, issueKeys: [...issueKeys], goal };
202
+ }
203
+ // --- Mutations ---
204
+ const GOAL_TYPES_QUERY = `
205
+ query GoalTypes($containerId: ID!) {
206
+ goals_goalTypes(containerId: $containerId) {
207
+ edges { node { id } }
208
+ }
209
+ }
210
+ `;
211
+ // Session-level cache for goal type ARIs
212
+ let cachedGoalTypeIds = null;
213
+ async function resolveGoalTypes(client) {
214
+ if (cachedGoalTypeIds)
215
+ return cachedGoalTypeIds;
216
+ const result = await client.queryTenanted(GOAL_TYPES_QUERY, { containerId: client.getSiteAri() });
217
+ if (!result.success || !result.data)
218
+ return [];
219
+ cachedGoalTypeIds = result.data.goals_goalTypes.edges.map(e => e.node.id);
220
+ return cachedGoalTypeIds;
221
+ }
222
+ /** Resolve goal type: first type for top-level goals, last type for sub-goals */
223
+ async function resolveGoalType(client, hasParent) {
224
+ const types = await resolveGoalTypes(client);
225
+ if (types.length === 0)
226
+ return null;
227
+ return hasParent ? types[types.length - 1] : types[0];
228
+ }
229
+ const GOAL_CREATE_MUTATION = `
230
+ mutation CreateGoal($input: TownsquareGoalsCreateInput!) {
231
+ goals_create(input: $input) {
232
+ success
233
+ errors { message }
234
+ goal { id name key url state { value label } }
235
+ }
236
+ }
237
+ `;
238
+ const GOAL_EDIT_MUTATION = `
239
+ mutation EditGoal($input: TownsquareGoalsEditInput!) {
240
+ goals_edit(input: $input) {
241
+ goal { id name key url state { value label } isArchived }
242
+ }
243
+ }
244
+ `;
245
+ const GOAL_CREATE_UPDATE_MUTATION = `
246
+ mutation CreateGoalUpdate($input: TownsquareGoalsCreateUpdateInput!) {
247
+ goals_createUpdate(input: $input) {
248
+ success
249
+ errors { message }
250
+ update { id url creationDate }
251
+ }
252
+ }
253
+ `;
254
+ const GOAL_LINK_WORK_ITEM_MUTATION = `
255
+ mutation LinkWorkItem($input: TownsquareGoalsLinkWorkItemInput!) {
256
+ goals_linkWorkItem(input: $input) {
257
+ goal { id name key }
258
+ }
259
+ }
260
+ `;
261
+ const GOAL_UNLINK_WORK_ITEM_MUTATION = `
262
+ mutation UnlinkWorkItem($input: TownsquareGoalsUnlinkWorkItemInput!) {
263
+ goals_unlinkWorkItem(input: $input) {
264
+ goal { id name key }
265
+ }
266
+ }
267
+ `;
268
+ /** Resolve a goal key to its ARI (needed for mutations) */
269
+ async function resolveGoalId(client, goalKey) {
270
+ const result = await getGoalByKey(client, goalKey);
271
+ if (!result.success || !result.goal) {
272
+ return { success: false, error: result.error ?? `Goal ${goalKey} not found` };
273
+ }
274
+ return { success: true, goalId: result.goal.id };
275
+ }
276
+ export async function createGoal(client, opts) {
277
+ const hasParent = !!opts.parentGoalKey;
278
+ const goalTypeId = await resolveGoalType(client, hasParent);
279
+ if (!goalTypeId) {
280
+ return { success: false, error: 'Could not discover goal types for this instance. Goals may not be enabled.' };
281
+ }
282
+ const input = {
283
+ containerId: client.getSiteAri(),
284
+ name: opts.name,
285
+ goalTypeId,
286
+ };
287
+ if (opts.parentGoalKey) {
288
+ const parent = await resolveGoalId(client, opts.parentGoalKey);
289
+ if (!parent.success)
290
+ return { success: false, error: `Parent goal: ${parent.error}` };
291
+ input.parentGoalId = parent.goalId;
292
+ }
293
+ if (opts.targetDate) {
294
+ input.targetDate = { date: opts.targetDate, confidence: 'QUARTER' };
295
+ }
296
+ if (opts.description) {
297
+ input.description = toAdf(opts.description);
298
+ }
299
+ const result = await client.queryTenanted(GOAL_CREATE_MUTATION, { input });
300
+ if (!result.success || !result.data) {
301
+ return { success: false, error: result.error ?? 'Create failed' };
302
+ }
303
+ const mutation = result.data.goals_create;
304
+ if (!mutation.success || !mutation.goal) {
305
+ const msg = mutation.errors?.map(e => e.message).join('; ') ?? 'Unknown error';
306
+ return { success: false, error: msg };
307
+ }
308
+ return { success: true, goal: { name: mutation.goal.name, key: mutation.goal.key, url: mutation.goal.url } };
309
+ }
310
+ export async function editGoal(client, goalKey, opts) {
311
+ const resolved = await resolveGoalId(client, goalKey);
312
+ if (!resolved.success)
313
+ return { success: false, error: resolved.error };
314
+ const input = { goalId: resolved.goalId };
315
+ if (opts.name !== undefined)
316
+ input.name = opts.name;
317
+ if (opts.description !== undefined) {
318
+ input.description = toAdf(opts.description);
319
+ }
320
+ if (opts.targetDate !== undefined) {
321
+ input.targetDate = { date: opts.targetDate, confidence: 'QUARTER' };
322
+ }
323
+ if (opts.startDate !== undefined)
324
+ input.startDate = opts.startDate;
325
+ if (opts.archived !== undefined)
326
+ input.archived = opts.archived;
327
+ const result = await client.queryTenanted(GOAL_EDIT_MUTATION, { input });
328
+ if (!result.success)
329
+ return { success: false, error: result.error };
330
+ return { success: true };
331
+ }
332
+ export async function createGoalStatusUpdate(client, goalKey, status, summary) {
333
+ const resolved = await resolveGoalId(client, goalKey);
334
+ if (!resolved.success)
335
+ return { success: false, error: resolved.error };
336
+ const input = {
337
+ goalId: resolved.goalId,
338
+ status,
339
+ };
340
+ if (summary) {
341
+ input.summary = toAdf(summary);
342
+ }
343
+ const result = await client.queryTenanted(GOAL_CREATE_UPDATE_MUTATION, { input });
344
+ if (!result.success || !result.data) {
345
+ return { success: false, error: result.error ?? 'Status update failed' };
346
+ }
347
+ const mutation = result.data.goals_createUpdate;
348
+ if (!mutation.success) {
349
+ const msg = mutation.errors?.map(e => e.message).join('; ') ?? 'Status update failed (no error details)';
350
+ return { success: false, error: msg };
351
+ }
352
+ return { success: true };
353
+ }
354
+ const ISSUE_BY_KEY_QUERY = `
355
+ query IssueByKey($cloudId: ID!, $key: String!) {
356
+ jira { issueByKey(key: $key, cloudId: $cloudId) { id } }
357
+ }
358
+ `;
359
+ /** Resolve a Jira issue key to its ARI (needed for goal work item linking) */
360
+ async function resolveIssueAri(client, issueKey) {
361
+ const result = await client.queryTenanted(ISSUE_BY_KEY_QUERY, { key: issueKey });
362
+ if (!result.success || !result.data?.jira?.issueByKey) {
363
+ return { success: false, error: `Issue ${issueKey} not found` };
364
+ }
365
+ return { success: true, issueAri: result.data.jira.issueByKey.id };
366
+ }
367
+ export async function linkWorkItem(client, goalKey, issueKey) {
368
+ const [resolved, issue] = await Promise.all([
369
+ resolveGoalId(client, goalKey),
370
+ resolveIssueAri(client, issueKey),
371
+ ]);
372
+ if (!resolved.success)
373
+ return { success: false, error: resolved.error };
374
+ if (!issue.success)
375
+ return { success: false, error: issue.error };
376
+ const result = await client.queryTenanted(GOAL_LINK_WORK_ITEM_MUTATION, {
377
+ input: { goalId: resolved.goalId, workItemId: issue.issueAri },
378
+ });
379
+ if (!result.success)
380
+ return { success: false, error: result.error };
381
+ return { success: true };
382
+ }
383
+ export async function unlinkWorkItem(client, goalKey, issueKey) {
384
+ const [resolved, issue] = await Promise.all([
385
+ resolveGoalId(client, goalKey),
386
+ resolveIssueAri(client, issueKey),
387
+ ]);
388
+ if (!resolved.success)
389
+ return { success: false, error: resolved.error };
390
+ if (!issue.success)
391
+ return { success: false, error: issue.error };
392
+ const result = await client.queryTenanted(GOAL_UNLINK_WORK_ITEM_MUTATION, {
393
+ input: { goalId: resolved.goalId, workItemId: issue.issueAri },
394
+ });
395
+ if (!result.success)
396
+ return { success: false, error: result.error };
397
+ return { success: true };
398
+ }
@@ -742,7 +742,7 @@ async function renderHierarchy(issues, graphqlClient) {
742
742
  }
743
743
  }
744
744
  if (parentKeys.size > MAX_HIERARCHY_ROOTS) {
745
- lines.push('', `*Showing ${MAX_HIERARCHY_ROOTS} of ${parentKeys.size} root issues. Use analyze_jira_plan for a focused subtree.*`);
745
+ lines.push('', `*Showing ${MAX_HIERARCHY_ROOTS} of ${parentKeys.size} root issues. Use manage_jira_plan for a focused subtree.*`);
746
746
  }
747
747
  return lines.join('\n');
748
748
  }
@@ -858,11 +858,11 @@ export async function handleAnalysisRequest(jiraClient, request, graphqlClient,
858
858
  const dataRef = args.dataRef;
859
859
  if (dataRef && typeof dataRef === 'string' && dataRef.trim() !== '') {
860
860
  if (!cache) {
861
- throw new McpError(ErrorCode.InvalidParams, 'dataRef requires the graph object cache (start a walk with analyze_jira_plan first).');
861
+ throw new McpError(ErrorCode.InvalidParams, 'dataRef requires the graph object cache (start a walk with manage_jira_plan first).');
862
862
  }
863
863
  const cached = cache.get(dataRef);
864
864
  if (!cached) {
865
- throw new McpError(ErrorCode.InvalidParams, `No cached walk for "${dataRef}". Start one with analyze_jira_plan first.`);
865
+ throw new McpError(ErrorCode.InvalidParams, `No cached walk for "${dataRef}". Start one with manage_jira_plan first.`);
866
866
  }
867
867
  if (cached.state === 'walking') {
868
868
  return {
@@ -1,16 +1,60 @@
1
1
  import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
2
+ import { searchGoals, getGoalByKey, resolveGoalWorkItems, createGoal, editGoal, createGoalStatusUpdate, linkWorkItem, unlinkWorkItem } from '../client/graphql-goals.js';
2
3
  import { GraphQLHierarchyWalker, collectLeaves, computeDepth, walkTree } from '../client/graphql-hierarchy.js';
3
- import { planNextSteps } from '../utils/next-steps.js';
4
+ import { planNextSteps, goalNextSteps } from '../utils/next-steps.js';
4
5
  import { normalizeArgs } from '../utils/normalize-args.js';
5
6
  const ALL_ROLLUPS = ['dates', 'points', 'progress', 'assignees'];
6
7
  const MAX_CHILDREN_DISPLAY = 20;
7
8
  export async function handlePlanRequest(_jiraClient, graphqlClient, request, cache) {
8
9
  const args = normalizeArgs(request.params?.arguments ?? {});
10
+ const operation = args.operation ?? 'analyze';
11
+ const goalKey = args.goalKey;
12
+ // Goal operations
13
+ if (operation === 'list_goals') {
14
+ return handleListGoals(graphqlClient, args);
15
+ }
16
+ if (operation === 'get_goal') {
17
+ if (!goalKey)
18
+ throw new McpError(ErrorCode.InvalidParams, 'goalKey is required for get_goal');
19
+ return handleGetGoal(graphqlClient, goalKey);
20
+ }
21
+ if (goalKey && operation === 'analyze') {
22
+ return handleAnalyzeGoal(graphqlClient, goalKey, args, cache);
23
+ }
24
+ if (operation === 'create_goal') {
25
+ return handleCreateGoal(graphqlClient, args);
26
+ }
27
+ if (operation === 'update_goal') {
28
+ if (!goalKey)
29
+ throw new McpError(ErrorCode.InvalidParams, 'goalKey is required for update_goal');
30
+ return handleUpdateGoal(graphqlClient, goalKey, args);
31
+ }
32
+ if (operation === 'update_goal_status') {
33
+ if (!goalKey)
34
+ throw new McpError(ErrorCode.InvalidParams, 'goalKey is required for update_goal_status');
35
+ return handleUpdateGoalStatus(graphqlClient, goalKey, args);
36
+ }
37
+ if (operation === 'link_work_item') {
38
+ if (!goalKey)
39
+ throw new McpError(ErrorCode.InvalidParams, 'goalKey is required for link_work_item');
40
+ const issueKeyArg = args.issueKey;
41
+ if (!issueKeyArg)
42
+ throw new McpError(ErrorCode.InvalidParams, 'issueKey is required for link_work_item');
43
+ return handleLinkWorkItem(graphqlClient, goalKey, issueKeyArg);
44
+ }
45
+ if (operation === 'unlink_work_item') {
46
+ if (!goalKey)
47
+ throw new McpError(ErrorCode.InvalidParams, 'goalKey is required for unlink_work_item');
48
+ const issueKeyArg = args.issueKey;
49
+ if (!issueKeyArg)
50
+ throw new McpError(ErrorCode.InvalidParams, 'issueKey is required for unlink_work_item');
51
+ return handleUnlinkWorkItem(graphqlClient, goalKey, issueKeyArg);
52
+ }
53
+ // Issue operations require issueKey
9
54
  const issueKey = args.issueKey;
10
55
  if (!issueKey) {
11
- throw new McpError(ErrorCode.InvalidParams, 'issueKey is required for analyze_jira_plan');
56
+ throw new McpError(ErrorCode.InvalidParams, 'issueKey or goalKey is required for manage_jira_plan');
12
57
  }
13
- const operation = args.operation ?? 'analyze';
14
58
  // Handle release operation
15
59
  if (operation === 'release') {
16
60
  if (!cache) {
@@ -349,3 +393,264 @@ export function renderRollupTree(node, lines, rollups, prefix, isLast) {
349
393
  renderRollupTree(child, lines, rollups, childPrefix, i === node.children.length - 1);
350
394
  });
351
395
  }
396
+ // --- Goal operations ---
397
+ async function handleListGoals(graphqlClient, args) {
398
+ const searchString = args.searchString ?? '';
399
+ const sort = args.sort ?? 'HIERARCHY_ASC';
400
+ const result = await searchGoals(graphqlClient, searchString, sort);
401
+ if (!result.success || !result.goals) {
402
+ const error = result.error ?? 'Unknown error';
403
+ if (error.includes('not found') || error.includes('Cannot route')) {
404
+ return { content: [{ type: 'text', text: 'No goals found. Goals may not be enabled on this instance.' }] };
405
+ }
406
+ return { content: [{ type: 'text', text: `Goal search failed: ${error}` }], isError: true };
407
+ }
408
+ if (result.goals.length === 0) {
409
+ const hint = searchString.includes('status =')
410
+ ? '\n\n*Note: TQL status filtering may be incomplete for some values. Try without the status filter to see all goals.*'
411
+ : '';
412
+ return { content: [{ type: 'text', text: `No goals found for search: "${searchString}"${hint}` }] };
413
+ }
414
+ const lines = [];
415
+ lines.push(`# Goals (${result.goals.length})`);
416
+ lines.push('');
417
+ for (const goal of result.goals) {
418
+ const workCount = result.workItemCounts?.get(goal.key) ?? 0;
419
+ const indent = goal.parentGoal ? ' ' : '';
420
+ const stateIcon = goalStateIcon(goal.state.value);
421
+ const owner = goal.owner ? ` — ${goal.owner.name}` : '';
422
+ const workLabel = workCount > 0 ? ` | ${workCount} linked issues` : '';
423
+ lines.push(`${indent}${stateIcon} **${goal.key}**: ${goal.name} [${goal.state.label}]${owner}${workLabel}`);
424
+ }
425
+ lines.push('');
426
+ lines.push(goalNextSteps('list_goals'));
427
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
428
+ }
429
+ async function handleGetGoal(graphqlClient, goalKey) {
430
+ const result = await getGoalByKey(graphqlClient, goalKey);
431
+ if (!result.success || !result.goal) {
432
+ return { content: [{ type: 'text', text: `Goal ${goalKey} not found: ${result.error ?? 'unknown error'}` }], isError: true };
433
+ }
434
+ const goal = result.goal;
435
+ const lines = [];
436
+ lines.push(`# Goal: ${goal.key} — ${goal.name}`);
437
+ lines.push(`**State:** ${goal.state.label} | **Owner:** ${goal.owner?.name ?? 'Unassigned'}`);
438
+ if (goal.parentGoal) {
439
+ lines.push(`**Parent:** ${goal.parentGoal.key} — ${goal.parentGoal.name}`);
440
+ }
441
+ if (goal.description) {
442
+ lines.push(`**Description:** ${goal.description}`);
443
+ }
444
+ lines.push('');
445
+ if (goal.subGoals.length > 0) {
446
+ lines.push(`## Sub-Goals (${goal.subGoals.length})`);
447
+ lines.push('');
448
+ for (const sg of goal.subGoals) {
449
+ const stateIcon = goalStateIcon(sg.state.value);
450
+ lines.push(`${stateIcon} **${sg.key}**: ${sg.name} [${sg.state.label}]`);
451
+ }
452
+ lines.push('');
453
+ }
454
+ if (goal.projects.length > 0) {
455
+ lines.push(`## Projects (${goal.projects.length})`);
456
+ lines.push('');
457
+ for (const p of goal.projects) {
458
+ lines.push(`- ${p.name} [${p.state.value}]`);
459
+ }
460
+ lines.push('');
461
+ }
462
+ if (goal.workItems.length > 0) {
463
+ lines.push(`## Linked Issues (${goal.workItems.length})`);
464
+ lines.push('');
465
+ for (const w of goal.workItems) {
466
+ lines.push(`- **${w.key}** [${w.issueType.name}] ${w.summary} — ${w.status.name}`);
467
+ }
468
+ lines.push('');
469
+ }
470
+ else {
471
+ lines.push('*No linked Jira issues.*');
472
+ lines.push('');
473
+ }
474
+ lines.push(goalNextSteps('get_goal', goalKey, goal.workItems.length));
475
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
476
+ }
477
+ async function handleAnalyzeGoal(graphqlClient, goalKey, args, cache) {
478
+ const result = await resolveGoalWorkItems(graphqlClient, goalKey);
479
+ if (!result.success || !result.goal) {
480
+ return { content: [{ type: 'text', text: `Failed to resolve goal ${goalKey}: ${result.error ?? 'unknown error'}` }], isError: true };
481
+ }
482
+ const goal = result.goal;
483
+ const issueKeys = result.issueKeys ?? [];
484
+ if (issueKeys.length === 0) {
485
+ const lines = [];
486
+ lines.push(`# Goal: ${goal.key} — ${goal.name} [${goal.state.label}]`);
487
+ lines.push('');
488
+ lines.push('No linked Jira issues to analyze. Cannot resolve linked Jira issues — the workItems API may have changed, or no issues are linked to this goal.');
489
+ lines.push('');
490
+ lines.push(goalNextSteps('analyze', goalKey, 0));
491
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
492
+ }
493
+ // Goal context header
494
+ const header = [];
495
+ header.push(`# Goal: ${goal.key} — ${goal.name} [${goal.state.label}]`);
496
+ header.push(`**Owner:** ${goal.owner?.name ?? 'Unassigned'} | **Linked issues:** ${issueKeys.length}`);
497
+ if (goal.subGoals.length > 0) {
498
+ header.push(`**Sub-goals:** ${goal.subGoals.map(sg => `${sg.key} [${sg.state.value}]`).join(', ')}`);
499
+ }
500
+ header.push('');
501
+ // Walk each issue's hierarchy and compute rollups
502
+ const rollups = (Array.isArray(args.rollups) ? args.rollups : ALL_ROLLUPS);
503
+ // For goal analysis, walk each linked issue and merge results
504
+ const walker = new GraphQLHierarchyWalker(graphqlClient);
505
+ const allTrees = [];
506
+ const errors = [];
507
+ // Separate cached from uncached keys
508
+ const uncachedKeys = [];
509
+ for (const key of issueKeys) {
510
+ if (cache) {
511
+ const status = cache.getStatus(key);
512
+ if (status.state === 'complete' || status.state === 'stale') {
513
+ allTrees.push(cache.get(key).tree);
514
+ continue;
515
+ }
516
+ }
517
+ uncachedKeys.push(key);
518
+ }
519
+ // Walk uncached keys in parallel
520
+ const walkResults = await Promise.allSettled(uncachedKeys.map(key => walker.walkDown(key)));
521
+ for (let i = 0; i < walkResults.length; i++) {
522
+ const result = walkResults[i];
523
+ if (result.status === 'fulfilled') {
524
+ allTrees.push(result.value.tree);
525
+ }
526
+ else {
527
+ errors.push(uncachedKeys[i]);
528
+ }
529
+ }
530
+ if (allTrees.length === 0) {
531
+ header.push('All issue hierarchy walks failed. The linked issues may not be accessible.');
532
+ if (errors.length > 0)
533
+ header.push(`Failed keys: ${errors.join(', ')}`);
534
+ return { content: [{ type: 'text', text: header.join('\n') }] };
535
+ }
536
+ // Render each tree's rollup as a summary line
537
+ header.push(`## Issue Rollups (${allTrees.length} of ${issueKeys.length} resolved)`);
538
+ if (errors.length > 0) {
539
+ header.push(`*${errors.length} issues could not be walked: ${errors.join(', ')}*`);
540
+ }
541
+ header.push('');
542
+ let totalResolved = 0;
543
+ let totalItems = 0;
544
+ let totalPoints = 0;
545
+ let earnedPoints = 0;
546
+ for (const tree of allTrees) {
547
+ const rollup = GraphQLHierarchyWalker.computeRollups(tree);
548
+ totalResolved += rollup.resolvedItems;
549
+ totalItems += rollup.totalItems;
550
+ totalPoints += rollup.totalPoints;
551
+ earnedPoints += rollup.earnedPoints;
552
+ renderNodeLine(tree, header, rollups);
553
+ }
554
+ header.push('');
555
+ header.push('## Aggregate');
556
+ if (rollups.includes('progress')) {
557
+ const pct = totalItems > 0 ? Math.round(totalResolved / totalItems * 100) : 0;
558
+ header.push(`**Progress:** ${totalResolved}/${totalItems} resolved (${pct}%)`);
559
+ }
560
+ if (rollups.includes('points') && totalPoints > 0) {
561
+ header.push(`**Points:** ${earnedPoints}/${totalPoints} earned`);
562
+ }
563
+ header.push('');
564
+ header.push(goalNextSteps('analyze', goalKey, issueKeys.length));
565
+ return { content: [{ type: 'text', text: header.join('\n') }] };
566
+ }
567
+ function goalStateIcon(state) {
568
+ switch (state) {
569
+ case 'done': return '✓';
570
+ case 'on_track': return '●';
571
+ case 'at_risk': return '⚠';
572
+ case 'off_track': return '✗';
573
+ default: return '○';
574
+ }
575
+ }
576
+ // --- Goal mutations ---
577
+ async function handleCreateGoal(graphqlClient, args) {
578
+ const name = args.name;
579
+ if (!name)
580
+ throw new McpError(ErrorCode.InvalidParams, 'name is required for create_goal');
581
+ const result = await createGoal(graphqlClient, {
582
+ name,
583
+ description: args.description,
584
+ parentGoalKey: args.parentGoalKey,
585
+ targetDate: args.targetDate,
586
+ });
587
+ if (!result.success || !result.goal) {
588
+ return { content: [{ type: 'text', text: `Failed to create goal: ${result.error}` }], isError: true };
589
+ }
590
+ const goal = result.goal;
591
+ const lines = [
592
+ `Goal created: **${goal.key}** — ${goal.name}`,
593
+ `URL: ${goal.url}`,
594
+ '',
595
+ goalNextSteps('create_goal', goal.key),
596
+ ];
597
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
598
+ }
599
+ async function handleUpdateGoal(graphqlClient, goalKey, args) {
600
+ const result = await editGoal(graphqlClient, goalKey, {
601
+ name: args.name,
602
+ description: args.description,
603
+ targetDate: args.targetDate,
604
+ startDate: args.startDate,
605
+ archived: args.archived,
606
+ });
607
+ if (!result.success) {
608
+ return { content: [{ type: 'text', text: `Failed to update goal ${goalKey}: ${result.error}` }], isError: true };
609
+ }
610
+ const lines = [
611
+ `Goal ${goalKey} updated.`,
612
+ '',
613
+ goalNextSteps('update_goal', goalKey),
614
+ ];
615
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
616
+ }
617
+ async function handleUpdateGoalStatus(graphqlClient, goalKey, args) {
618
+ const status = args.status;
619
+ if (!status)
620
+ throw new McpError(ErrorCode.InvalidParams, 'status is required for update_goal_status');
621
+ const result = await createGoalStatusUpdate(graphqlClient, goalKey, status, args.summary);
622
+ if (!result.success) {
623
+ return { content: [{ type: 'text', text: `Failed to update status for ${goalKey}: ${result.error}` }], isError: true };
624
+ }
625
+ const lines = [
626
+ `Goal ${goalKey} status updated to **${status}**.`,
627
+ args.summary ? `Summary: ${args.summary}` : '',
628
+ '',
629
+ goalNextSteps('update_goal_status', goalKey),
630
+ ].filter(Boolean);
631
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
632
+ }
633
+ async function handleLinkWorkItem(graphqlClient, goalKey, issueKey) {
634
+ const result = await linkWorkItem(graphqlClient, goalKey, issueKey);
635
+ if (!result.success) {
636
+ return { content: [{ type: 'text', text: `Failed to link ${issueKey} to goal ${goalKey}: ${result.error}` }], isError: true };
637
+ }
638
+ const lines = [
639
+ `Linked **${issueKey}** to goal **${goalKey}**.`,
640
+ '',
641
+ goalNextSteps('link_work_item', goalKey),
642
+ ];
643
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
644
+ }
645
+ async function handleUnlinkWorkItem(graphqlClient, goalKey, issueKey) {
646
+ const result = await unlinkWorkItem(graphqlClient, goalKey, issueKey);
647
+ if (!result.success) {
648
+ return { content: [{ type: 'text', text: `Failed to unlink ${issueKey} from goal ${goalKey}: ${result.error}` }], isError: true };
649
+ }
650
+ const lines = [
651
+ `Unlinked **${issueKey}** from goal **${goalKey}**.`,
652
+ '',
653
+ goalNextSteps('unlink_work_item', goalKey),
654
+ ];
655
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
656
+ }
@@ -2,12 +2,14 @@ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
2
2
  import { setupToolResourceHandlers } from './tool-resource-handlers.js';
3
3
  import { fieldDiscovery } from '../client/field-discovery.js';
4
4
  import { categoryLabel } from '../client/field-type-map.js';
5
+ import { searchGoals } from '../client/graphql-goals.js';
5
6
  /**
6
7
  * Sets up resource handlers for the Jira MCP server
7
8
  * @param jiraClient The Jira client instance
9
+ * @param graphqlClient Optional GraphQL client for Townsquare goals
8
10
  * @returns Object containing resource handlers
9
11
  */
10
- export function setupResourceHandlers(jiraClient) {
12
+ export function setupResourceHandlers(jiraClient, graphqlClient) {
11
13
  const toolResourceHandler = setupToolResourceHandlers();
12
14
  return {
13
15
  /**
@@ -87,7 +89,7 @@ export function setupResourceHandlers(jiraClient) {
87
89
  try {
88
90
  // Handle static resources
89
91
  if (uri === 'jira://instance/summary') {
90
- return await getInstanceSummary(jiraClient);
92
+ return await getInstanceSummary(jiraClient, graphqlClient);
91
93
  }
92
94
  if (uri === 'jira://projects/distribution') {
93
95
  return await getProjectDistribution(jiraClient);
@@ -139,7 +141,7 @@ export function setupResourceHandlers(jiraClient) {
139
141
  /**
140
142
  * Gets a summary of the Jira instance
141
143
  */
142
- async function getInstanceSummary(jiraClient) {
144
+ async function getInstanceSummary(jiraClient, graphqlClient) {
143
145
  try {
144
146
  // Get projects
145
147
  const projects = await jiraClient.listProjects();
@@ -157,6 +159,29 @@ async function getInstanceSummary(jiraClient) {
157
159
  timestamp: new Date().toISOString()
158
160
  }
159
161
  };
162
+ // Fetch goal summary counts if GraphQL client is available
163
+ if (graphqlClient) {
164
+ try {
165
+ const result = await searchGoals(graphqlClient, '', 'HIERARCHY_ASC', 200);
166
+ if (result.success && result.goals && result.goals.length > 0) {
167
+ const goals = result.goals;
168
+ const stateCounts = {};
169
+ for (const g of goals) {
170
+ stateCounts[g.state.value] = (stateCounts[g.state.value] ?? 0) + 1;
171
+ }
172
+ const themes = goals.filter(g => !g.parentGoal).length;
173
+ summary.goals = {
174
+ total: goals.length,
175
+ themes,
176
+ ...stateCounts,
177
+ hint: 'Use manage_jira_plan with operation list_goals to explore the goal tree',
178
+ };
179
+ }
180
+ }
181
+ catch {
182
+ // Goals not available on this instance — skip silently
183
+ }
184
+ }
160
185
  return {
161
186
  contents: [
162
187
  {
package/build/index.js CHANGED
@@ -102,7 +102,7 @@ class JiraServer {
102
102
  }
103
103
  }).catch(() => { });
104
104
  // CloudId discovery happens in run() before server connects — must complete
105
- // before ListTools so analyze_jira_plan is registered if available.
105
+ // before ListTools so manage_jira_plan is registered if available.
106
106
  this.server.onerror = (error) => console.error('[MCP Error]', error);
107
107
  process.on('SIGINT', async () => {
108
108
  await this.server.close();
@@ -113,7 +113,7 @@ class JiraServer {
113
113
  // Set up required MCP protocol handlers
114
114
  this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
115
115
  tools: Object.entries(toolSchemas)
116
- .filter(([key]) => key !== 'analyze_jira_plan' || this.graphqlClient !== null)
116
+ .filter(([key]) => key !== 'manage_jira_plan' || this.graphqlClient !== null)
117
117
  .map(([key, schema]) => ({
118
118
  name: key,
119
119
  description: schema.description,
@@ -125,7 +125,7 @@ class JiraServer {
125
125
  })),
126
126
  }));
127
127
  // Set up resource handlers
128
- const resourceHandlers = setupResourceHandlers(this.jiraClient);
128
+ const resourceHandlers = setupResourceHandlers(this.jiraClient, this.graphqlClient);
129
129
  this.server.setRequestHandler(ListResourcesRequestSchema, resourceHandlers.listResources);
130
130
  this.server.setRequestHandler(ListResourceTemplatesRequestSchema, resourceHandlers.listResourceTemplates);
131
131
  this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
@@ -165,7 +165,7 @@ class JiraServer {
165
165
  ...toolHandlers,
166
166
  queue_jira_operations: createQueueHandler(toolHandlers, JIRA_HOST),
167
167
  ...(this.graphqlClient ? {
168
- analyze_jira_plan: (_client, req) => handlePlanRequest(this.jiraClient, this.graphqlClient, req, this.cache),
168
+ manage_jira_plan: (_client, req) => handlePlanRequest(this.jiraClient, this.graphqlClient, req, this.cache),
169
169
  } : {}),
170
170
  };
171
171
  const handler = handlers[name];
@@ -293,15 +293,15 @@ class JiraServer {
293
293
  try {
294
294
  const cloudId = await discoverCloudId(JIRA_HOST, JIRA_EMAIL, JIRA_API_TOKEN);
295
295
  if (cloudId) {
296
- this.graphqlClient = new GraphQLClient(JIRA_EMAIL, JIRA_API_TOKEN, cloudId);
296
+ this.graphqlClient = new GraphQLClient(JIRA_EMAIL, JIRA_API_TOKEN, cloudId, JIRA_HOST);
297
297
  console.error(`[jira-cloud] GraphQL client ready (cloudId: ${cloudId.slice(0, 8)}...)`);
298
298
  }
299
299
  else {
300
- console.error('[jira-cloud] GraphQL/Plans unavailable — analyze_jira_plan disabled');
300
+ console.error('[jira-cloud] GraphQL/Plans unavailable — manage_jira_plan disabled');
301
301
  }
302
302
  }
303
303
  catch {
304
- console.error('[jira-cloud] GraphQL discovery failed — analyze_jira_plan disabled');
304
+ console.error('[jira-cloud] GraphQL discovery failed — manage_jira_plan disabled');
305
305
  }
306
306
  const transport = new StdioServerTransport();
307
307
  await this.server.connect(transport);
@@ -373,7 +373,7 @@ export const toolSchemas = {
373
373
  },
374
374
  dataRef: {
375
375
  type: 'string',
376
- description: 'Root issue key of a cached hierarchy walk. Analyzes cached plan data without re-fetching from Jira. Start a walk with analyze_jira_plan first. Supports all metrics except flow. Takes precedence over jql/filterId.',
376
+ description: 'Root issue key of a cached hierarchy walk. Analyzes cached plan data without re-fetching from Jira. Start a walk with manage_jira_plan first. Supports all metrics except flow. Takes precedence over jql/filterId.',
377
377
  },
378
378
  metrics: {
379
379
  type: 'array',
@@ -408,20 +408,66 @@ export const toolSchemas = {
408
408
  required: [],
409
409
  },
410
410
  },
411
- analyze_jira_plan: {
412
- name: 'analyze_jira_plan',
413
- description: 'Analyze hierarchy rollups for any parent issue. Walks the issue tree via GraphQL, computes rolled-up dates, points, progress, assignees, and detects date conflicts. Results are cached server-side for fast re-analysis. Works on any Jira instance (no Plans/Premium required). For flat-set metrics use analyze_jira_issues (with dataRef to analyze cached plan data); for structure without rollups use manage_jira_issue hierarchy.',
411
+ manage_jira_plan: {
412
+ name: 'manage_jira_plan',
413
+ description: 'Navigate and manage the strategic-to-execution hierarchy. Walks issue trees and Atlassian Goals via GraphQL. Read: analyze rollups (dates, points, progress, conflicts), discover goals (list_goals), get goal detail (get_goal). Write: create/update goals, post status updates, link/unlink Jira issues to goals. Results cached server-side. For flat-set metrics use analyze_jira_issues; for structure without rollups use manage_jira_issue hierarchy.',
414
414
  inputSchema: {
415
415
  type: 'object',
416
416
  properties: {
417
417
  operation: {
418
418
  type: 'string',
419
- enum: ['analyze', 'release'],
420
- description: 'Operation to perform. analyze (default): walk hierarchy and compute rollups. release: free cached walk data for this issueKey.',
419
+ enum: ['analyze', 'release', 'list_goals', 'get_goal', 'create_goal', 'update_goal', 'update_goal_status', 'link_work_item', 'unlink_work_item'],
420
+ description: 'Operation to perform. analyze: walk hierarchy and compute rollups. release: free cached walk. list_goals: search goals. get_goal: goal detail. create_goal: create a goal. update_goal: edit goal name/description/dates/archive. update_goal_status: post a status update (on_track/off_track/done). link_work_item: link a Jira issue to a goal. unlink_work_item: unlink a Jira issue from a goal.',
421
421
  },
422
422
  issueKey: {
423
423
  type: 'string',
424
- description: 'Issue key at the root of the plan tree (e.g., PROJ-100). Required.',
424
+ description: 'Issue key at the root of the plan tree (e.g., PROJ-100). Required for analyze/release unless goalKey is provided. Also used with link_work_item/unlink_work_item to specify the Jira issue.',
425
+ },
426
+ goalKey: {
427
+ type: 'string',
428
+ description: 'Atlassian Goal key (e.g., PRAEC-25). Used for get_goal, analyze, update_goal, update_goal_status, link_work_item, unlink_work_item.',
429
+ },
430
+ name: {
431
+ type: 'string',
432
+ description: 'Goal name. Required for create_goal. Optional for update_goal (renames the goal).',
433
+ },
434
+ description: {
435
+ type: 'string',
436
+ description: 'Goal description text. For create_goal and update_goal.',
437
+ },
438
+ status: {
439
+ type: 'string',
440
+ enum: ['on_track', 'off_track', 'at_risk', 'done', 'pending', 'paused'],
441
+ description: 'Goal status for update_goal_status.',
442
+ },
443
+ summary: {
444
+ type: 'string',
445
+ description: 'Status update summary text for update_goal_status. Describes what changed and why.',
446
+ },
447
+ parentGoalKey: {
448
+ type: 'string',
449
+ description: 'Parent goal key for create_goal. Makes the new goal a sub-goal of this parent.',
450
+ },
451
+ targetDate: {
452
+ type: 'string',
453
+ description: 'Target date in ISO format (YYYY-MM-DD) for create_goal and update_goal.',
454
+ },
455
+ startDate: {
456
+ type: 'string',
457
+ description: 'Start date in ISO format (YYYY-MM-DD) for update_goal.',
458
+ },
459
+ archived: {
460
+ type: 'boolean',
461
+ description: 'Set to true to archive a goal, false to unarchive. For update_goal.',
462
+ },
463
+ searchString: {
464
+ type: 'string',
465
+ description: 'TQL search string for list_goals. Examples: \'name LIKE "Health"\', \'status = on_track\'. Empty string returns all goals.',
466
+ },
467
+ sort: {
468
+ type: 'string',
469
+ enum: ['HIERARCHY_ASC', 'HIERARCHY_DESC', 'NAME_ASC', 'NAME_DESC', 'TARGET_DATE_ASC', 'TARGET_DATE_DESC', 'LATEST_UPDATE_DATE_ASC', 'LATEST_UPDATE_DATE_DESC'],
470
+ description: 'Sort order for list_goals. Default: HIERARCHY_ASC (groups parents with children).',
425
471
  },
426
472
  rollups: {
427
473
  type: 'array',
@@ -433,7 +479,7 @@ export const toolSchemas = {
433
479
  },
434
480
  focus: {
435
481
  type: 'string',
436
- description: 'Issue key to focus on within the cached plan. Shows the node, its parent, siblings, and children — a windowed view for navigating large plans. Requires a completed walk.',
482
+ description: 'Issue key to focus on within the cached plan. Windowed view for navigating large plans.',
437
483
  },
438
484
  mode: {
439
485
  type: 'string',
@@ -441,7 +487,7 @@ export const toolSchemas = {
441
487
  description: 'Output mode. rollup (default): summary + entry points. gaps: conflicts and missing data only.',
442
488
  },
443
489
  },
444
- required: ['issueKey'],
490
+ required: [],
445
491
  },
446
492
  },
447
493
  queue_jira_operations: {
@@ -42,7 +42,7 @@ export function issueNextSteps(operation, issueKey) {
42
42
  steps.push({ description: 'View the updated issue', tool: 'manage_jira_issue', example: { operation: 'get', issueKey } }, { description: 'Log more time', tool: 'manage_jira_issue', example: { operation: 'worklog', issueKey, timeSpent: '<duration>' } }, { description: 'Adjust the remaining estimate', tool: 'manage_jira_issue', example: { operation: 'update', issueKey, remainingEstimate: '<duration>' } });
43
43
  break;
44
44
  case 'hierarchy':
45
- steps.push({ description: 'View a specific issue from the tree', tool: 'manage_jira_issue', example: { operation: 'get', issueKey } }, { description: 'Analyze plan rollups (requires Jira Plans)', tool: 'analyze_jira_plan', example: { issueKey } }, { description: 'Search for issues in this project', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql: `project = "${issueKey?.split('-')[0]}"` } });
45
+ steps.push({ description: 'View a specific issue from the tree', tool: 'manage_jira_issue', example: { operation: 'get', issueKey } }, { description: 'Analyze plan rollups (requires Jira Plans)', tool: 'manage_jira_plan', example: { issueKey } }, { description: 'Search for issues in this project', tool: 'manage_jira_filter', example: { operation: 'execute_jql', jql: `project = "${issueKey?.split('-')[0]}"` } });
46
46
  break;
47
47
  }
48
48
  return steps.length > 0 ? formatSteps(steps) : '';
@@ -132,10 +132,10 @@ export function planNextSteps(issueKey, mode, conflicts, rollup) {
132
132
  const steps = [];
133
133
  steps.push({ description: 'View the issue details', tool: 'manage_jira_issue', example: { operation: 'get', issueKey } }, { description: 'Explore the hierarchy tree', tool: 'manage_jira_issue', example: { operation: 'hierarchy', issueKey } });
134
134
  if (mode !== 'gaps') {
135
- steps.push({ description: 'Check for data gaps and conflicts', tool: 'analyze_jira_plan', example: { issueKey, mode: 'gaps' } });
135
+ steps.push({ description: 'Check for data gaps and conflicts', tool: 'manage_jira_plan', example: { issueKey, mode: 'gaps' } });
136
136
  }
137
137
  if (mode !== 'timeline') {
138
- steps.push({ description: 'View the timeline', tool: 'analyze_jira_plan', example: { issueKey, mode: 'timeline' } });
138
+ steps.push({ description: 'View the timeline', tool: 'manage_jira_plan', example: { issueKey, mode: 'timeline' } });
139
139
  }
140
140
  steps.push({ description: 'Run flat metrics on children', tool: 'analyze_jira_issues', example: { jql: `parent = ${issueKey}`, metrics: ['summary'], groupBy: 'assignee' } });
141
141
  let result = formatSteps(steps);
@@ -174,6 +174,39 @@ export function conflictFixSteps(conflicts, rollup) {
174
174
  }
175
175
  return lines.join('\n');
176
176
  }
177
+ export function goalNextSteps(operation, goalKey, workItemCount) {
178
+ const steps = [];
179
+ switch (operation) {
180
+ case 'list_goals':
181
+ steps.push({ description: 'Get detail on a specific goal', tool: 'manage_jira_plan', example: { operation: 'get_goal', goalKey: 'GOAL-KEY' } }, { description: 'Analyze a goal\'s linked issues', tool: 'manage_jira_plan', example: { operation: 'analyze', goalKey: 'GOAL-KEY' } });
182
+ break;
183
+ case 'get_goal':
184
+ if (goalKey && workItemCount && workItemCount > 0) {
185
+ steps.push({ description: 'Analyze this goal\'s linked issues', tool: 'manage_jira_plan', example: { operation: 'analyze', goalKey } });
186
+ }
187
+ steps.push({ description: 'Search for more goals', tool: 'manage_jira_plan', example: { operation: 'list_goals', searchString: '' } });
188
+ break;
189
+ case 'analyze':
190
+ if (goalKey) {
191
+ steps.push({ description: 'View goal detail', tool: 'manage_jira_plan', example: { operation: 'get_goal', goalKey } }, { description: 'Update goal status', tool: 'manage_jira_plan', example: { operation: 'update_goal_status', goalKey, status: 'on_track', summary: 'Progress update' } });
192
+ }
193
+ break;
194
+ case 'create_goal':
195
+ case 'update_goal':
196
+ case 'update_goal_status':
197
+ if (goalKey) {
198
+ steps.push({ description: 'View updated goal', tool: 'manage_jira_plan', example: { operation: 'get_goal', goalKey } });
199
+ }
200
+ break;
201
+ case 'link_work_item':
202
+ case 'unlink_work_item':
203
+ if (goalKey) {
204
+ steps.push({ description: 'View goal with updated links', tool: 'manage_jira_plan', example: { operation: 'get_goal', goalKey } }, { description: 'Analyze goal progress', tool: 'manage_jira_plan', example: { operation: 'analyze', goalKey } });
205
+ }
206
+ break;
207
+ }
208
+ return steps.length > 0 ? formatSteps(steps) : '';
209
+ }
177
210
  export function analysisNextSteps(jql, issueKeys, truncated = false, groupBy, filterSource) {
178
211
  const steps = [];
179
212
  if (issueKeys.length > 0) {
@@ -195,7 +228,7 @@ export function analysisNextSteps(jql, issueKeys, truncated = false, groupBy, fi
195
228
  }
196
229
  // Suggest plan analysis when issue keys suggest hierarchical structure
197
230
  if (issueKeys.length > 0) {
198
- steps.push({ description: 'Analyze plan rollups for a parent issue (requires Jira Plans)', tool: 'analyze_jira_plan', example: { issueKey: issueKeys[0] } });
231
+ steps.push({ description: 'Analyze plan rollups for a parent issue (requires Jira Plans)', tool: 'manage_jira_plan', example: { issueKey: issueKeys[0] } });
199
232
  }
200
233
  // Suggest saving as filter if not already using one
201
234
  if (!filterSource) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronsb/jira-cloud-mcp",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
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",