@aaronsb/jira-cloud-mcp 0.8.1 → 0.10.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.
- package/build/client/graphql-client.js +23 -3
- package/build/client/graphql-goals.js +398 -0
- package/build/client/jira-client.js +41 -0
- package/build/handlers/analysis-handler.js +3 -3
- package/build/handlers/media-handler.js +130 -0
- package/build/handlers/plan-handler.js +308 -3
- package/build/handlers/resource-handlers.js +28 -3
- package/build/handlers/workspace-handler.js +214 -0
- package/build/index.js +12 -7
- package/build/schemas/tool-schemas.js +122 -10
- package/build/utils/next-steps.js +53 -5
- package/build/workspace/index.js +1 -0
- package/build/workspace/workspace.js +187 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
{
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handler for manage_workspace tool.
|
|
3
|
+
* See ADR-211: Attachment and Workspace Management.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'node:fs/promises';
|
|
6
|
+
import * as path from 'node:path';
|
|
7
|
+
import { ensureWorkspaceDir, formatSize, resolveWorkspacePath, ensureParentDir, verifyPathSafety, } from '../workspace/index.js';
|
|
8
|
+
const TEXT_INLINE_LIMIT = 100 * 1024; // 100KB
|
|
9
|
+
const IMAGE_INLINE_LIMIT = 5 * 1024 * 1024; // 5MB
|
|
10
|
+
const IMAGE_EXTENSIONS = {
|
|
11
|
+
'.png': 'image/png',
|
|
12
|
+
'.jpg': 'image/jpeg',
|
|
13
|
+
'.jpeg': 'image/jpeg',
|
|
14
|
+
'.gif': 'image/gif',
|
|
15
|
+
'.webp': 'image/webp',
|
|
16
|
+
'.svg': 'image/svg+xml',
|
|
17
|
+
'.bmp': 'image/bmp',
|
|
18
|
+
'.ico': 'image/x-icon',
|
|
19
|
+
};
|
|
20
|
+
const TEXT_EXTENSIONS = new Set([
|
|
21
|
+
'.txt', '.md', '.json', '.xml', '.csv', '.html', '.htm',
|
|
22
|
+
'.yaml', '.yml', '.toml', '.ini', '.cfg', '.conf', '.log',
|
|
23
|
+
'.js', '.ts', '.py', '.rb', '.sh', '.bash', '.zsh',
|
|
24
|
+
'.css', '.scss', '.less', '.svg',
|
|
25
|
+
]);
|
|
26
|
+
export async function handleWorkspaceRequest(args) {
|
|
27
|
+
switch (args.operation) {
|
|
28
|
+
case 'list':
|
|
29
|
+
return handleList();
|
|
30
|
+
case 'read':
|
|
31
|
+
return handleRead(args);
|
|
32
|
+
case 'write':
|
|
33
|
+
return handleWrite(args);
|
|
34
|
+
case 'delete':
|
|
35
|
+
return handleDelete(args);
|
|
36
|
+
case 'mkdir':
|
|
37
|
+
return handleMkdir(args);
|
|
38
|
+
case 'move':
|
|
39
|
+
return handleMove(args);
|
|
40
|
+
default:
|
|
41
|
+
return { content: [{ type: 'text', text: `Unknown workspace operation: ${args.operation}` }], isError: true };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function handleList() {
|
|
45
|
+
const status = await ensureWorkspaceDir();
|
|
46
|
+
if (!status.valid) {
|
|
47
|
+
return { content: [{ type: 'text', text: `Workspace invalid: ${status.warning}` }], isError: true };
|
|
48
|
+
}
|
|
49
|
+
const lines = [`Workspace: ${status.path}\n`];
|
|
50
|
+
await listRecursive(status.path, status.path, lines, 0);
|
|
51
|
+
if (lines.length === 1) {
|
|
52
|
+
return { content: [{ type: 'text', text: `Workspace: ${status.path}\n\n(empty — no files staged)` }] };
|
|
53
|
+
}
|
|
54
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
55
|
+
}
|
|
56
|
+
const MAX_LIST_DEPTH = 10;
|
|
57
|
+
async function listRecursive(rootDir, dir, lines, depth) {
|
|
58
|
+
if (depth >= MAX_LIST_DEPTH) {
|
|
59
|
+
lines.push(`${' '.repeat(depth + 1)}(truncated — max depth ${MAX_LIST_DEPTH})`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
let entries;
|
|
63
|
+
try {
|
|
64
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const indent = ' '.repeat(depth + 1);
|
|
70
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
71
|
+
if (entry.isSymbolicLink())
|
|
72
|
+
continue; // skip symlinks to prevent loops
|
|
73
|
+
try {
|
|
74
|
+
const fullPath = path.join(dir, entry.name);
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
lines.push(`${indent}${entry.name}/`);
|
|
77
|
+
await listRecursive(rootDir, fullPath, lines, depth + 1);
|
|
78
|
+
}
|
|
79
|
+
else if (entry.isFile()) {
|
|
80
|
+
const stat = await fs.stat(fullPath);
|
|
81
|
+
lines.push(`${indent}${entry.name} (${formatSize(stat.size)}, ${stat.mtime.toISOString().slice(0, 16)})`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Skip entries we can't stat
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function handleRead(args) {
|
|
90
|
+
if (!args.filename) {
|
|
91
|
+
return { content: [{ type: 'text', text: 'filename is required for read operation' }], isError: true };
|
|
92
|
+
}
|
|
93
|
+
const filePath = resolveWorkspacePath(args.filename);
|
|
94
|
+
await verifyPathSafety(filePath);
|
|
95
|
+
let stat;
|
|
96
|
+
try {
|
|
97
|
+
stat = await fs.stat(filePath);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { content: [{ type: 'text', text: `File not found in workspace: ${args.filename}` }], isError: true };
|
|
101
|
+
}
|
|
102
|
+
const ext = path.extname(args.filename).toLowerCase();
|
|
103
|
+
const isText = TEXT_EXTENSIONS.has(ext);
|
|
104
|
+
const imageMime = IMAGE_EXTENSIONS[ext];
|
|
105
|
+
// Inline text
|
|
106
|
+
if (isText && stat.size <= TEXT_INLINE_LIMIT) {
|
|
107
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
108
|
+
return { content: [{ type: 'text', text: `File: ${args.filename} (${formatSize(stat.size)})\nPath: ${filePath}\n\n${content}` }] };
|
|
109
|
+
}
|
|
110
|
+
// Inline image
|
|
111
|
+
if (imageMime && stat.size <= IMAGE_INLINE_LIMIT) {
|
|
112
|
+
const bytes = await fs.readFile(filePath);
|
|
113
|
+
return {
|
|
114
|
+
content: [
|
|
115
|
+
{ type: 'text', text: `File: ${args.filename} | ${formatSize(stat.size)}\nPath: ${filePath}` },
|
|
116
|
+
{ type: 'image', data: bytes.toString('base64'), mimeType: imageMime },
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// Too large or unsupported — path reference only
|
|
121
|
+
const label = imageMime ? 'image (too large to display inline)' : isText ? 'text' : 'binary';
|
|
122
|
+
return {
|
|
123
|
+
content: [{
|
|
124
|
+
type: 'text',
|
|
125
|
+
text: `File: ${args.filename} | ${formatSize(stat.size)} | ${label}\nPath: ${filePath}\n\nUse manage_jira_media upload with workspaceFile to upload, or manage_workspace delete to remove.`,
|
|
126
|
+
}],
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
async function handleWrite(args) {
|
|
130
|
+
if (!args.filename) {
|
|
131
|
+
return { content: [{ type: 'text', text: 'filename is required for write operation' }], isError: true };
|
|
132
|
+
}
|
|
133
|
+
if (!args.content) {
|
|
134
|
+
return { content: [{ type: 'text', text: 'content (base64-encoded) is required for write operation' }], isError: true };
|
|
135
|
+
}
|
|
136
|
+
const status = await ensureWorkspaceDir();
|
|
137
|
+
if (!status.valid) {
|
|
138
|
+
return { content: [{ type: 'text', text: `Workspace invalid: ${status.warning}` }], isError: true };
|
|
139
|
+
}
|
|
140
|
+
const filePath = resolveWorkspacePath(args.filename);
|
|
141
|
+
await verifyPathSafety(filePath);
|
|
142
|
+
await ensureParentDir(filePath);
|
|
143
|
+
const buffer = Buffer.from(args.content, 'base64');
|
|
144
|
+
await fs.writeFile(filePath, buffer);
|
|
145
|
+
return {
|
|
146
|
+
content: [{
|
|
147
|
+
type: 'text',
|
|
148
|
+
text: `Written: ${args.filename} (${formatSize(buffer.length)})\nPath: ${filePath}`,
|
|
149
|
+
}],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
async function handleDelete(args) {
|
|
153
|
+
if (!args.filename) {
|
|
154
|
+
return { content: [{ type: 'text', text: 'filename is required for delete operation' }], isError: true };
|
|
155
|
+
}
|
|
156
|
+
const filePath = resolveWorkspacePath(args.filename);
|
|
157
|
+
await verifyPathSafety(filePath);
|
|
158
|
+
try {
|
|
159
|
+
const stat = await fs.stat(filePath);
|
|
160
|
+
if (stat.isDirectory()) {
|
|
161
|
+
await fs.rm(filePath, { recursive: true });
|
|
162
|
+
return { content: [{ type: 'text', text: `Deleted local directory: ${args.filename} (Jira attachments unaffected)` }] };
|
|
163
|
+
}
|
|
164
|
+
await fs.unlink(filePath);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return { content: [{ type: 'text', text: `File not found in workspace: ${args.filename}` }], isError: true };
|
|
168
|
+
}
|
|
169
|
+
return { content: [{ type: 'text', text: `Deleted local file: ${args.filename} (Jira attachments unaffected)` }] };
|
|
170
|
+
}
|
|
171
|
+
async function handleMkdir(args) {
|
|
172
|
+
if (!args.filename) {
|
|
173
|
+
return { content: [{ type: 'text', text: 'filename (directory path) is required for mkdir operation' }], isError: true };
|
|
174
|
+
}
|
|
175
|
+
const status = await ensureWorkspaceDir();
|
|
176
|
+
if (!status.valid) {
|
|
177
|
+
return { content: [{ type: 'text', text: `Workspace invalid: ${status.warning}` }], isError: true };
|
|
178
|
+
}
|
|
179
|
+
const dirPath = resolveWorkspacePath(args.filename);
|
|
180
|
+
await verifyPathSafety(dirPath);
|
|
181
|
+
await fs.mkdir(dirPath, { recursive: true, mode: 0o755 });
|
|
182
|
+
return {
|
|
183
|
+
content: [{
|
|
184
|
+
type: 'text',
|
|
185
|
+
text: `Created: ${args.filename}/\nPath: ${dirPath}`,
|
|
186
|
+
}],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
async function handleMove(args) {
|
|
190
|
+
if (!args.filename) {
|
|
191
|
+
return { content: [{ type: 'text', text: 'filename (source path) is required for move operation' }], isError: true };
|
|
192
|
+
}
|
|
193
|
+
if (!args.destination) {
|
|
194
|
+
return { content: [{ type: 'text', text: 'destination path is required for move operation' }], isError: true };
|
|
195
|
+
}
|
|
196
|
+
const srcPath = resolveWorkspacePath(args.filename);
|
|
197
|
+
await verifyPathSafety(srcPath);
|
|
198
|
+
const destPath = resolveWorkspacePath(args.destination);
|
|
199
|
+
await verifyPathSafety(destPath);
|
|
200
|
+
try {
|
|
201
|
+
await fs.stat(srcPath);
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return { content: [{ type: 'text', text: `Source not found in workspace: ${args.filename}` }], isError: true };
|
|
205
|
+
}
|
|
206
|
+
await ensureParentDir(destPath);
|
|
207
|
+
await fs.rename(srcPath, destPath);
|
|
208
|
+
return {
|
|
209
|
+
content: [{
|
|
210
|
+
type: 'text',
|
|
211
|
+
text: `Moved: ${args.filename} -> ${args.destination}\nPath: ${destPath}`,
|
|
212
|
+
}],
|
|
213
|
+
};
|
|
214
|
+
}
|