@rui.branco/jira-mcp 1.7.10 → 1.7.11
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/config-store.js +553 -0
- package/index.js +492 -149
- package/package.json +3 -2
- package/setup.js +129 -89
package/index.js
CHANGED
|
@@ -17,6 +17,7 @@ const {
|
|
|
17
17
|
const fs = require("fs");
|
|
18
18
|
const path = require("path");
|
|
19
19
|
const fetch = require("node-fetch");
|
|
20
|
+
const configStore = require("./config-store.js");
|
|
20
21
|
const { spawn, execSync } = require("child_process");
|
|
21
22
|
|
|
22
23
|
// Auto-update: check GitHub for new commits, install in background
|
|
@@ -47,11 +48,7 @@ try {
|
|
|
47
48
|
} catch {}
|
|
48
49
|
|
|
49
50
|
// Load Jira config (supports single-instance and multi-instance formats)
|
|
50
|
-
const
|
|
51
|
-
process.env.HOME,
|
|
52
|
-
".config/jira-mcp/config.json",
|
|
53
|
-
);
|
|
54
|
-
const rawConfig = JSON.parse(fs.readFileSync(jiraConfigPath, "utf8"));
|
|
51
|
+
const rawConfig = configStore.loadConfigStrict();
|
|
55
52
|
|
|
56
53
|
// Confluence tool visibility: 'on-demand' (default) hides the confluence_*
|
|
57
54
|
// tools until confluence_enable is called; 'always' exposes them from boot.
|
|
@@ -62,47 +59,72 @@ if (confluenceMode !== "always" && confluenceMode !== "on-demand") {
|
|
|
62
59
|
}
|
|
63
60
|
let confluenceActive = confluenceMode === "always";
|
|
64
61
|
|
|
62
|
+
function normalizeProjectPrefixes(projects) {
|
|
63
|
+
return [...new Set(
|
|
64
|
+
(Array.isArray(projects) ? projects : [])
|
|
65
|
+
.filter((project) => typeof project === "string")
|
|
66
|
+
.map((project) => project.trim().toUpperCase())
|
|
67
|
+
.filter(Boolean),
|
|
68
|
+
)];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildJiraInstance(inst) {
|
|
72
|
+
return {
|
|
73
|
+
...inst,
|
|
74
|
+
projects: normalizeProjectPrefixes(inst.projects),
|
|
75
|
+
auth: Buffer.from(`${inst.email}:${inst.token}`).toString("base64"),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
65
79
|
// Normalize config to multi-instance format
|
|
66
80
|
let instances;
|
|
67
|
-
if (rawConfig.instances) {
|
|
81
|
+
if (Array.isArray(rawConfig.instances)) {
|
|
68
82
|
// New multi-instance format
|
|
69
|
-
instances = rawConfig.instances.map(
|
|
70
|
-
...inst,
|
|
71
|
-
projects: (inst.projects || []).map((p) => p.toUpperCase()),
|
|
72
|
-
auth: Buffer.from(`${inst.email}:${inst.token}`).toString("base64"),
|
|
73
|
-
}));
|
|
83
|
+
instances = rawConfig.instances.map(buildJiraInstance);
|
|
74
84
|
} else {
|
|
75
85
|
// Old single-instance format — wrap as array
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
+
const legacyInstance = buildJiraInstance({
|
|
87
|
+
name: "default",
|
|
88
|
+
email: rawConfig.email,
|
|
89
|
+
token: rawConfig.token,
|
|
90
|
+
baseUrl: rawConfig.baseUrl,
|
|
91
|
+
projects: rawConfig.projects,
|
|
92
|
+
});
|
|
93
|
+
for (const field of ["scopes", "dryRun", "defaultTeam", "projectTeams", "confluenceBaseUrl"]) {
|
|
94
|
+
if (rawConfig[field] !== undefined) legacyInstance[field] = rawConfig[field];
|
|
95
|
+
}
|
|
96
|
+
instances = [legacyInstance];
|
|
86
97
|
}
|
|
87
98
|
|
|
88
99
|
// Resolve default instance
|
|
89
|
-
|
|
100
|
+
let defaultInstance =
|
|
90
101
|
(rawConfig.defaultInstance && instances.find((i) => i.name === rawConfig.defaultInstance)) ||
|
|
91
102
|
instances[0];
|
|
92
103
|
|
|
93
|
-
//
|
|
94
|
-
function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
104
|
+
// Instance resolution helpers
|
|
105
|
+
function getMappedInstancesForProject(projectPrefix) {
|
|
106
|
+
if (typeof projectPrefix !== "string") return [];
|
|
107
|
+
const upper = projectPrefix.trim().toUpperCase();
|
|
108
|
+
return instances.filter((i) => i.projects.includes(upper));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function getProjectAmbiguityError(projectPrefix, owners) {
|
|
112
|
+
const upper = projectPrefix.trim().toUpperCase();
|
|
113
|
+
const names = owners.map((owner) => `"${owner.name}"`).join(", ");
|
|
114
|
+
return new Error(
|
|
115
|
+
`Project prefix "${upper}" is mapped to multiple Jira instances (${names}). Pass an explicit instance.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function getMappedInstanceForProject(projectPrefix) {
|
|
120
|
+
const owners = getMappedInstancesForProject(projectPrefix);
|
|
121
|
+
return owners.length === 1 ? owners[0] : undefined;
|
|
100
122
|
}
|
|
101
123
|
|
|
102
|
-
// Instance resolution helpers
|
|
103
124
|
function getInstanceForProject(projectPrefix) {
|
|
104
|
-
const
|
|
105
|
-
|
|
125
|
+
const owners = getMappedInstancesForProject(projectPrefix);
|
|
126
|
+
if (owners.length > 1) throw getProjectAmbiguityError(projectPrefix, owners);
|
|
127
|
+
return owners[0] || defaultInstance;
|
|
106
128
|
}
|
|
107
129
|
|
|
108
130
|
function getInstanceForKey(issueKey) {
|
|
@@ -112,7 +134,9 @@ function getInstanceForKey(issueKey) {
|
|
|
112
134
|
|
|
113
135
|
function getInstanceByName(name) {
|
|
114
136
|
if (!name) return defaultInstance;
|
|
115
|
-
|
|
137
|
+
const instance = instances.find((i) => i.name === name);
|
|
138
|
+
if (!instance) throw new Error(`Instance "${name}" not found.`);
|
|
139
|
+
return instance;
|
|
116
140
|
}
|
|
117
141
|
|
|
118
142
|
// Load Figma config (optional)
|
|
@@ -433,24 +457,285 @@ function extractTarget(args) {
|
|
|
433
457
|
);
|
|
434
458
|
}
|
|
435
459
|
|
|
460
|
+
const ROUTING_ISSUE_FIELDS = [
|
|
461
|
+
"issueKey",
|
|
462
|
+
"parentKey",
|
|
463
|
+
"inwardIssueKey",
|
|
464
|
+
"outwardIssueKey",
|
|
465
|
+
"linkedIssueKey",
|
|
466
|
+
];
|
|
467
|
+
|
|
468
|
+
function isProjectPrefix(value) {
|
|
469
|
+
return typeof value === "string" && /^[A-Za-z][A-Za-z0-9_]*$/.test(value);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function issueKeyToProjectPrefix(value) {
|
|
473
|
+
if (typeof value !== "string") return null;
|
|
474
|
+
const match = value.trim().match(/^([A-Za-z][A-Za-z0-9_]*)-/);
|
|
475
|
+
return match ? match[1].toUpperCase() : null;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function projectValueToPrefix(value) {
|
|
479
|
+
if (typeof value !== "string") return null;
|
|
480
|
+
const trimmed = value.trim();
|
|
481
|
+
if (!trimmed || /^\d+$/.test(trimmed) || !isProjectPrefix(trimmed)) return null;
|
|
482
|
+
return trimmed.toUpperCase();
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function extractRoutingPrefixes(toolName, args) {
|
|
486
|
+
if (
|
|
487
|
+
!args ||
|
|
488
|
+
typeof args !== "object" ||
|
|
489
|
+
TOOL_METADATA[toolName]?.product !== "jira" ||
|
|
490
|
+
toolName === "jira_add_instance"
|
|
491
|
+
) {
|
|
492
|
+
return [];
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const prefixes = [];
|
|
496
|
+
const add = (prefix) => {
|
|
497
|
+
if (prefix && !prefixes.includes(prefix)) prefixes.push(prefix);
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
for (const field of ROUTING_ISSUE_FIELDS) {
|
|
501
|
+
add(issueKeyToProjectPrefix(args[field]));
|
|
502
|
+
}
|
|
503
|
+
if (Array.isArray(args.issueKeys)) {
|
|
504
|
+
for (const issueKey of args.issueKeys) {
|
|
505
|
+
add(issueKeyToProjectPrefix(issueKey));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
add(projectValueToPrefix(args.projectKey));
|
|
509
|
+
add(projectValueToPrefix(args.projectKeyOrId));
|
|
510
|
+
add(projectValueToPrefix(args.targetProjectKey));
|
|
511
|
+
return prefixes;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function probeJiraProject(prefix, instance) {
|
|
515
|
+
let response;
|
|
516
|
+
try {
|
|
517
|
+
response = await fetch(
|
|
518
|
+
`${instance.baseUrl}/rest/api/3/project/${encodeURIComponent(prefix)}`,
|
|
519
|
+
{
|
|
520
|
+
method: "GET",
|
|
521
|
+
headers: {
|
|
522
|
+
Authorization: `Basic ${instance.auth}`,
|
|
523
|
+
Accept: "application/json",
|
|
524
|
+
},
|
|
525
|
+
},
|
|
526
|
+
);
|
|
527
|
+
} catch (error) {
|
|
528
|
+
throw new Error(
|
|
529
|
+
`Project discovery failed for prefix "${prefix}" on instance "${instance.name}": ${error.message}`,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (!response || typeof response !== "object") {
|
|
534
|
+
throw new Error(
|
|
535
|
+
`Project discovery failed for prefix "${prefix}" on instance "${instance.name}": malformed response.`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
const status = response.status == null && response.ok ? 200 : response.status;
|
|
539
|
+
if (status === 200) {
|
|
540
|
+
if (typeof response.text !== "function") {
|
|
541
|
+
throw new Error(
|
|
542
|
+
`Project discovery failed for prefix "${prefix}" on instance "${instance.name}": malformed response.`,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
let text;
|
|
546
|
+
try {
|
|
547
|
+
text = await response.text();
|
|
548
|
+
} catch {
|
|
549
|
+
throw new Error(
|
|
550
|
+
`Project discovery failed for prefix "${prefix}" on instance "${instance.name}": malformed response.`,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
let project;
|
|
554
|
+
try {
|
|
555
|
+
project = JSON.parse(text);
|
|
556
|
+
} catch {
|
|
557
|
+
throw new Error(
|
|
558
|
+
`Project discovery failed for prefix "${prefix}" on instance "${instance.name}": malformed response.`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
if (!project || typeof project !== "object" || Array.isArray(project)) {
|
|
562
|
+
throw new Error(
|
|
563
|
+
`Project discovery failed for prefix "${prefix}" on instance "${instance.name}": malformed response.`,
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
return { kind: "match" };
|
|
567
|
+
}
|
|
568
|
+
if (status === 404) return { kind: "missing" };
|
|
569
|
+
if (status === 401 || status === 403) {
|
|
570
|
+
return { kind: "inaccessible", status };
|
|
571
|
+
}
|
|
572
|
+
if (typeof status === "number" && status >= 500) {
|
|
573
|
+
throw new Error(
|
|
574
|
+
`Project discovery failed for prefix "${prefix}" on instance "${instance.name}": Jira returned HTTP ${status}.`,
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
throw new Error(
|
|
578
|
+
`Project discovery failed for prefix "${prefix}" on instance "${instance.name}": unexpected HTTP status ${status}.`,
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function discoverProjectInstance(prefix) {
|
|
583
|
+
const results = await Promise.all(
|
|
584
|
+
instances.map(async (instance) => ({
|
|
585
|
+
instance,
|
|
586
|
+
result: await probeJiraProject(prefix, instance),
|
|
587
|
+
})),
|
|
588
|
+
);
|
|
589
|
+
const inaccessible = results.filter(({ result }) => result.kind === "inaccessible");
|
|
590
|
+
if (inaccessible.length > 0) {
|
|
591
|
+
const details = inaccessible
|
|
592
|
+
.map(({ instance, result }) => `"${instance.name}" (HTTP ${result.status})`)
|
|
593
|
+
.join(", ");
|
|
594
|
+
throw new Error(
|
|
595
|
+
`Project prefix "${prefix}" was not found on any accessible Jira instance. Permission denied on ${details}.`,
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const matches = results.filter(({ result }) => result.kind === "match");
|
|
600
|
+
if (matches.length === 1) return matches[0].instance;
|
|
601
|
+
if (matches.length > 1) {
|
|
602
|
+
const names = matches.map(({ instance }) => `"${instance.name}"`).join(", ");
|
|
603
|
+
throw new Error(
|
|
604
|
+
`Project prefix "${prefix}" is accessible on multiple Jira instances (${names}). Pass an explicit instance.`,
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
throw new Error(`Project prefix "${prefix}" was not found on any configured Jira instance.`);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function validateProjectOnInstance(prefix, instance) {
|
|
611
|
+
const result = await probeJiraProject(prefix, instance);
|
|
612
|
+
if (result.kind === "match") return;
|
|
613
|
+
if (result.kind === "inaccessible") {
|
|
614
|
+
throw new Error(
|
|
615
|
+
`Cannot access project prefix "${prefix}" on explicitly selected Jira instance "${instance.name}" (HTTP ${result.status}).`,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
throw new Error(
|
|
619
|
+
`Project prefix "${prefix}" was not found on explicitly selected Jira instance "${instance.name}".`,
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function addProjectsToConfig(config, instanceName, prefixes) {
|
|
624
|
+
const normalized = normalizeProjectPrefixes(prefixes);
|
|
625
|
+
if (Array.isArray(config.instances)) {
|
|
626
|
+
const savedInstance = config.instances.find((instance) => instance.name === instanceName);
|
|
627
|
+
if (!savedInstance) {
|
|
628
|
+
throw new Error(`Instance "${instanceName}" no longer exists in the Jira config.`);
|
|
629
|
+
}
|
|
630
|
+
for (const prefix of normalized) {
|
|
631
|
+
const otherOwners = config.instances.filter(
|
|
632
|
+
(instance) =>
|
|
633
|
+
instance.name !== instanceName &&
|
|
634
|
+
normalizeProjectPrefixes(instance.projects).includes(prefix),
|
|
635
|
+
);
|
|
636
|
+
if (otherOwners.length > 0) {
|
|
637
|
+
throw new Error(
|
|
638
|
+
`Project prefix "${prefix}" is already mapped to Jira instance(s) ${otherOwners.map((owner) => `"${owner.name}"`).join(", ")}.`,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
savedInstance.projects = normalizeProjectPrefixes([
|
|
643
|
+
...(savedInstance.projects || []),
|
|
644
|
+
...normalized,
|
|
645
|
+
]);
|
|
646
|
+
return savedInstance.projects;
|
|
647
|
+
}
|
|
648
|
+
if (config.instances !== undefined) {
|
|
649
|
+
throw new Error("Jira config has an invalid instances value.");
|
|
650
|
+
}
|
|
651
|
+
if (instanceName !== "default") {
|
|
652
|
+
throw new Error(`Instance "${instanceName}" no longer exists in the Jira config.`);
|
|
653
|
+
}
|
|
654
|
+
config.projects = normalizeProjectPrefixes([
|
|
655
|
+
...(config.projects || []),
|
|
656
|
+
...normalized,
|
|
657
|
+
]);
|
|
658
|
+
return config.projects;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
let discoveryWriteQueue = Promise.resolve();
|
|
662
|
+
|
|
663
|
+
function persistDiscoveredProjects(instance, prefixes) {
|
|
664
|
+
const normalized = normalizeProjectPrefixes(prefixes);
|
|
665
|
+
if (normalized.length === 0) return Promise.resolve(instance);
|
|
666
|
+
|
|
667
|
+
const operation = discoveryWriteQueue.then(() => {
|
|
668
|
+
const currentInstance = instances.find((candidate) => candidate.name === instance.name);
|
|
669
|
+
if (!currentInstance) {
|
|
670
|
+
throw new Error(`Instance "${instance.name}" no longer exists in memory.`);
|
|
671
|
+
}
|
|
672
|
+
const savedProjects = configStore.updateConfigSync((savedConfig) =>
|
|
673
|
+
addProjectsToConfig(savedConfig, instance.name, normalized),
|
|
674
|
+
);
|
|
675
|
+
// The in-memory route changes only after the atomic replacement succeeds.
|
|
676
|
+
currentInstance.projects = normalizeProjectPrefixes(savedProjects);
|
|
677
|
+
return currentInstance;
|
|
678
|
+
});
|
|
679
|
+
discoveryWriteQueue = operation.catch(() => {});
|
|
680
|
+
return operation;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function assertSameInstance(entries) {
|
|
684
|
+
const names = [...new Set(entries.map(({ instance }) => instance.name))];
|
|
685
|
+
if (names.length > 1) {
|
|
686
|
+
throw new Error(
|
|
687
|
+
`Jira targets resolve to different instances (${names.join(", ")}). Use an explicit instance and keep all keys on the same Jira instance.`,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
436
692
|
// Resolve instance for policy purposes (must run BEFORE the dispatch handler).
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
if (
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
693
|
+
// Discovery happens here so scope, dry-run, rate-limit, audit, and the handler
|
|
694
|
+
// all use the same target instance.
|
|
695
|
+
async function resolveInstanceForTool(toolName, args) {
|
|
696
|
+
const explicit = args && args.instance ? getInstanceByName(args.instance) : null;
|
|
697
|
+
const prefixes = extractRoutingPrefixes(toolName, args);
|
|
698
|
+
if (prefixes.length === 0) return explicit || defaultInstance;
|
|
699
|
+
|
|
700
|
+
if (explicit) {
|
|
701
|
+
const unknown = prefixes.filter(
|
|
702
|
+
(prefix) => getMappedInstancesForProject(prefix).length === 0,
|
|
703
|
+
);
|
|
704
|
+
if (unknown.length > 0) {
|
|
705
|
+
await Promise.all(unknown.map((prefix) => validateProjectOnInstance(prefix, explicit)));
|
|
706
|
+
await persistDiscoveredProjects(explicit, unknown);
|
|
707
|
+
}
|
|
708
|
+
return explicit;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const known = [];
|
|
712
|
+
const unknown = [];
|
|
713
|
+
for (const prefix of prefixes) {
|
|
714
|
+
const owners = getMappedInstancesForProject(prefix);
|
|
715
|
+
if (owners.length === 0) {
|
|
716
|
+
unknown.push(prefix);
|
|
717
|
+
} else if (owners.length > 1) {
|
|
718
|
+
throw getProjectAmbiguityError(prefix, owners);
|
|
719
|
+
} else {
|
|
720
|
+
known.push({ prefix, instance: owners[0] });
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
assertSameInstance(known);
|
|
724
|
+
if (unknown.length === 0) return known[0]?.instance || defaultInstance;
|
|
725
|
+
|
|
726
|
+
const discovered = await Promise.all(
|
|
727
|
+
unknown.map(async (prefix) => ({
|
|
728
|
+
prefix,
|
|
729
|
+
instance: await discoverProjectInstance(prefix),
|
|
730
|
+
})),
|
|
731
|
+
);
|
|
732
|
+
assertSameInstance([
|
|
733
|
+
...known,
|
|
734
|
+
...discovered,
|
|
735
|
+
]);
|
|
736
|
+
const selected = known[0]?.instance || discovered[0].instance;
|
|
737
|
+
await persistDiscoveredProjects(selected, unknown);
|
|
738
|
+
return selected;
|
|
454
739
|
}
|
|
455
740
|
|
|
456
741
|
// ============ JIRA FUNCTIONS ============
|
|
@@ -3379,7 +3664,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
3379
3664
|
|
|
3380
3665
|
async function runWithPolicy(name, args, handler) {
|
|
3381
3666
|
const meta = TOOL_METADATA[name];
|
|
3382
|
-
|
|
3667
|
+
let inst;
|
|
3668
|
+
try {
|
|
3669
|
+
inst = await resolveInstanceForTool(name, args);
|
|
3670
|
+
} catch (error) {
|
|
3671
|
+
return {
|
|
3672
|
+
content: [{ type: "text", text: `Error: ${error.message}` }],
|
|
3673
|
+
isError: true,
|
|
3674
|
+
};
|
|
3675
|
+
}
|
|
3383
3676
|
|
|
3384
3677
|
// 1. Scope check
|
|
3385
3678
|
const scopeResult = checkScope(name, inst);
|
|
@@ -3518,7 +3811,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3518
3811
|
],
|
|
3519
3812
|
};
|
|
3520
3813
|
} else if (name === "jira_search_users") {
|
|
3521
|
-
const inst = resolveInstanceForTool(name, args);
|
|
3814
|
+
const inst = await resolveInstanceForTool(name, args);
|
|
3522
3815
|
const maxResults = args.maxResults || 5;
|
|
3523
3816
|
const hasAssignmentContext = Boolean(args.issueKey || args.projectKey);
|
|
3524
3817
|
let users;
|
|
@@ -3985,7 +4278,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3985
4278
|
};
|
|
3986
4279
|
}
|
|
3987
4280
|
if (args.issueKey) {
|
|
3988
|
-
const
|
|
4281
|
+
const inst = args.instance
|
|
4282
|
+
? getInstanceByName(args.instance)
|
|
4283
|
+
: getInstanceForKey(args.issueKey);
|
|
4284
|
+
const result = await getChangelog(args.issueKey, inst);
|
|
3989
4285
|
return { content: [{ type: "text", text: result.formatted }] };
|
|
3990
4286
|
} else {
|
|
3991
4287
|
const inst = getInstanceByName(args.instance);
|
|
@@ -3995,6 +4291,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3995
4291
|
} else if (name === "jira_add_instance") {
|
|
3996
4292
|
const instName = args.name.trim();
|
|
3997
4293
|
const existingIdx = instances.findIndex((i) => i.name === instName);
|
|
4294
|
+
const existing = existingIdx >= 0 ? instances[existingIdx] : {};
|
|
3998
4295
|
const isUpdate = existingIdx >= 0;
|
|
3999
4296
|
|
|
4000
4297
|
// For new instances, email/token/baseUrl are required
|
|
@@ -4005,27 +4302,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4005
4302
|
};
|
|
4006
4303
|
}
|
|
4007
4304
|
|
|
4008
|
-
// Merge with existing or create new
|
|
4009
|
-
const existing = isUpdate ? instances[existingIdx] : {};
|
|
4010
4305
|
const email = args.email || existing.email;
|
|
4011
4306
|
const token = args.token || existing.token;
|
|
4012
4307
|
const baseUrl = args.baseUrl ? args.baseUrl.replace(/\/$/, "") : existing.baseUrl;
|
|
4013
|
-
const
|
|
4308
|
+
const requestedProjects = Array.isArray(args.projects)
|
|
4309
|
+
? normalizeProjectPrefixes(args.projects)
|
|
4310
|
+
: null;
|
|
4311
|
+
const currentProjects = normalizeProjectPrefixes(existing.projects);
|
|
4014
4312
|
const authStr = Buffer.from(`${email}:${token}`).toString("base64");
|
|
4015
4313
|
|
|
4016
4314
|
// Resolve defaultTeam if provided
|
|
4017
|
-
let
|
|
4315
|
+
let defaultTeamChange = null;
|
|
4018
4316
|
if (args.defaultTeam !== undefined) {
|
|
4019
4317
|
if (args.defaultTeam === "") {
|
|
4020
|
-
|
|
4318
|
+
defaultTeamChange = { value: undefined };
|
|
4021
4319
|
} else if (args.defaultTeam.toLowerCase() === "none") {
|
|
4022
|
-
|
|
4320
|
+
defaultTeamChange = { value: "none" };
|
|
4023
4321
|
} else {
|
|
4024
4322
|
// Validate team exists via Jira Teams API
|
|
4025
4323
|
const tempInst = { baseUrl, auth: authStr };
|
|
4026
4324
|
try {
|
|
4027
4325
|
const teamId = await resolveTeamId(args.defaultTeam, tempInst);
|
|
4028
|
-
|
|
4326
|
+
defaultTeamChange = { value: { name: args.defaultTeam, id: teamId } };
|
|
4029
4327
|
} catch (e) {
|
|
4030
4328
|
// Try to list available teams for a helpful error
|
|
4031
4329
|
try {
|
|
@@ -4046,18 +4344,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4046
4344
|
}
|
|
4047
4345
|
|
|
4048
4346
|
// Resolve projectTeam if provided
|
|
4049
|
-
let
|
|
4347
|
+
let projectTeamChange = null;
|
|
4050
4348
|
if (args.projectKey && args.projectTeam !== undefined) {
|
|
4051
4349
|
const pk = args.projectKey.toUpperCase();
|
|
4052
4350
|
if (args.projectTeam === "") {
|
|
4053
|
-
|
|
4351
|
+
projectTeamChange = { key: pk, value: undefined };
|
|
4054
4352
|
} else if (args.projectTeam.toLowerCase() === "none") {
|
|
4055
|
-
|
|
4353
|
+
projectTeamChange = { key: pk, value: "none" };
|
|
4056
4354
|
} else {
|
|
4057
4355
|
const tempInst = { baseUrl, auth: authStr };
|
|
4058
4356
|
try {
|
|
4059
4357
|
const teamId = await resolveTeamId(args.projectTeam, tempInst);
|
|
4060
|
-
|
|
4358
|
+
projectTeamChange = {
|
|
4359
|
+
key: pk,
|
|
4360
|
+
value: { name: args.projectTeam, id: teamId },
|
|
4361
|
+
};
|
|
4061
4362
|
} catch (e) {
|
|
4062
4363
|
return {
|
|
4063
4364
|
content: [{ type: "text", text: e.message }],
|
|
@@ -4067,76 +4368,106 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4067
4368
|
}
|
|
4068
4369
|
}
|
|
4069
4370
|
|
|
4070
|
-
const
|
|
4371
|
+
const persisted = configStore.updateConfigSync((savedConfig) => {
|
|
4372
|
+
configStore.migrateLegacyConfig(savedConfig);
|
|
4373
|
+
const savedIdx = savedConfig.instances.findIndex((i) => i.name === instName);
|
|
4374
|
+
const prevSaved = savedIdx >= 0 ? savedConfig.instances[savedIdx] : {};
|
|
4375
|
+
const savedEmail = args.email || prevSaved.email || email;
|
|
4376
|
+
const savedToken = args.token || prevSaved.token || token;
|
|
4377
|
+
const savedBaseUrl = args.baseUrl
|
|
4378
|
+
? args.baseUrl.replace(/\/$/, "")
|
|
4379
|
+
: (prevSaved.baseUrl || baseUrl);
|
|
4380
|
+
if (!savedEmail || !savedToken || !savedBaseUrl) {
|
|
4381
|
+
throw new Error("New instance requires email, token, and baseUrl.");
|
|
4382
|
+
}
|
|
4071
4383
|
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4384
|
+
const latestProjects = normalizeProjectPrefixes(prevSaved.projects);
|
|
4385
|
+
const projects = requestedProjects === null
|
|
4386
|
+
? latestProjects
|
|
4387
|
+
: normalizeProjectPrefixes([
|
|
4388
|
+
...requestedProjects,
|
|
4389
|
+
...latestProjects.filter((project) => !currentProjects.includes(project)),
|
|
4390
|
+
]);
|
|
4391
|
+
configStore.assertNoDuplicateProjectOwnership(savedConfig, instName, projects);
|
|
4392
|
+
|
|
4393
|
+
const toSave = {
|
|
4394
|
+
...prevSaved,
|
|
4395
|
+
name: instName,
|
|
4396
|
+
email: savedEmail,
|
|
4397
|
+
token: savedToken,
|
|
4398
|
+
baseUrl: savedBaseUrl,
|
|
4399
|
+
projects,
|
|
4400
|
+
};
|
|
4401
|
+
if (defaultTeamChange) {
|
|
4402
|
+
if (defaultTeamChange.value === undefined) {
|
|
4403
|
+
delete toSave.defaultTeam;
|
|
4404
|
+
} else {
|
|
4405
|
+
toSave.defaultTeam = defaultTeamChange.value;
|
|
4406
|
+
}
|
|
4407
|
+
}
|
|
4408
|
+
const projectTeams = { ...(prevSaved.projectTeams || {}) };
|
|
4409
|
+
if (projectTeamChange) {
|
|
4410
|
+
if (projectTeamChange.value === undefined) {
|
|
4411
|
+
delete projectTeams[projectTeamChange.key];
|
|
4412
|
+
} else {
|
|
4413
|
+
projectTeams[projectTeamChange.key] = projectTeamChange.value;
|
|
4414
|
+
}
|
|
4415
|
+
}
|
|
4416
|
+
if (Object.keys(projectTeams).length > 0) {
|
|
4417
|
+
toSave.projectTeams = projectTeams;
|
|
4095
4418
|
} else {
|
|
4096
|
-
|
|
4419
|
+
delete toSave.projectTeams;
|
|
4097
4420
|
}
|
|
4098
|
-
|
|
4421
|
+
if (savedIdx >= 0) {
|
|
4422
|
+
savedConfig.instances[savedIdx] = toSave;
|
|
4423
|
+
} else {
|
|
4424
|
+
savedConfig.instances.push(toSave);
|
|
4425
|
+
}
|
|
4426
|
+
if (args.setDefault || !savedConfig.defaultInstance) {
|
|
4427
|
+
savedConfig.defaultInstance = instName;
|
|
4428
|
+
}
|
|
4429
|
+
return {
|
|
4430
|
+
instance: toSave,
|
|
4431
|
+
isUpdate: savedIdx >= 0,
|
|
4432
|
+
defaultInstance: savedConfig.defaultInstance,
|
|
4433
|
+
};
|
|
4434
|
+
});
|
|
4099
4435
|
|
|
4100
|
-
//
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
email,
|
|
4109
|
-
token,
|
|
4110
|
-
baseUrl,
|
|
4111
|
-
projects,
|
|
4112
|
-
};
|
|
4113
|
-
if (defaultTeam) toSave.defaultTeam = defaultTeam;
|
|
4114
|
-
if (Object.keys(projectTeams).length > 0) toSave.projectTeams = projectTeams;
|
|
4115
|
-
if (savedIdx >= 0) {
|
|
4116
|
-
savedConfig.instances[savedIdx] = toSave;
|
|
4436
|
+
// Update in-memory state only after the atomic config replacement succeeds.
|
|
4437
|
+
const savedInstance = buildJiraInstance(persisted.instance);
|
|
4438
|
+
const currentIdx = instances.findIndex((i) => i.name === instName);
|
|
4439
|
+
let activeInstance;
|
|
4440
|
+
if (currentIdx >= 0) {
|
|
4441
|
+
activeInstance = instances[currentIdx];
|
|
4442
|
+
for (const key of Object.keys(activeInstance)) delete activeInstance[key];
|
|
4443
|
+
Object.assign(activeInstance, savedInstance);
|
|
4117
4444
|
} else {
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4445
|
+
instances.push(savedInstance);
|
|
4446
|
+
activeInstance = savedInstance;
|
|
4447
|
+
}
|
|
4448
|
+
defaultInstance =
|
|
4449
|
+
instances.find((instance) => instance.name === persisted.defaultInstance) ||
|
|
4450
|
+
instances[0];
|
|
4451
|
+
rawConfig.defaultInstance = persisted.defaultInstance;
|
|
4452
|
+
|
|
4453
|
+
const action = persisted.isUpdate ? "Updated" : "Added";
|
|
4454
|
+
const savedBaseUrl = activeInstance.baseUrl;
|
|
4455
|
+
const savedProjects = activeInstance.projects;
|
|
4456
|
+
const projectTeams = activeInstance.projectTeams || {};
|
|
4457
|
+
let text = `${action} instance "${instName}" (${savedBaseUrl}).`;
|
|
4458
|
+
if (savedProjects.length > 0) text += ` Projects: ${savedProjects.join(", ")}.`;
|
|
4128
4459
|
if (args.setDefault) text += " Set as default.";
|
|
4129
|
-
if (
|
|
4130
|
-
const
|
|
4131
|
-
const pt = projectTeams[pk];
|
|
4460
|
+
if (projectTeamChange) {
|
|
4461
|
+
const pt = projectTeams[projectTeamChange.key];
|
|
4132
4462
|
if (pt === "none") {
|
|
4133
|
-
text += ` Project ${
|
|
4463
|
+
text += ` Project ${projectTeamChange.key} team: None (disabled).`;
|
|
4134
4464
|
} else if (pt) {
|
|
4135
|
-
text += ` Project ${
|
|
4465
|
+
text += ` Project ${projectTeamChange.key} team: ${pt.name}.`;
|
|
4136
4466
|
} else {
|
|
4137
|
-
text += ` Project ${
|
|
4467
|
+
text += ` Project ${projectTeamChange.key} team override removed.`;
|
|
4138
4468
|
}
|
|
4139
4469
|
}
|
|
4470
|
+
const defaultTeam = activeInstance.defaultTeam;
|
|
4140
4471
|
if (defaultTeam === "none") {
|
|
4141
4472
|
text += " Default team: None (disabled).";
|
|
4142
4473
|
} else if (defaultTeam) {
|
|
@@ -4144,8 +4475,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4144
4475
|
} else {
|
|
4145
4476
|
// No default team — fetch available teams and prompt
|
|
4146
4477
|
try {
|
|
4147
|
-
const
|
|
4148
|
-
const teams = await listTeams(tempInst);
|
|
4478
|
+
const teams = await listTeams(activeInstance);
|
|
4149
4479
|
if (teams && teams.length > 0) {
|
|
4150
4480
|
const list = teams.map((t, i) => `${i + 1}. ${t.title}`).join("\n");
|
|
4151
4481
|
text += `\n\n⚠ No default team configured. Available teams:\n0. None\n${list}\n\nIMPORTANT: You MUST display this full numbered list in your chat response so the user can see and choose. NEVER auto-select a team. Ask the user to pick a number, type a team name to search, or choose None.\nIf they pick one, call jira_add_instance with name="${instName}" and defaultTeam="<team name>".\nIf the team is not listed, the user can type a name and you search with jira_add_instance defaultTeam="<name>" (it validates automatically).\nIf "None" is selected, call jira_add_instance with defaultTeam="none" to stop future prompts.`;
|
|
@@ -4159,33 +4489,39 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4159
4489
|
|
|
4160
4490
|
} else if (name === "jira_remove_instance") {
|
|
4161
4491
|
const instName = args.name.trim();
|
|
4162
|
-
|
|
4163
|
-
|
|
4492
|
+
let removal;
|
|
4493
|
+
try {
|
|
4494
|
+
removal = configStore.updateConfigSync((savedConfig) => {
|
|
4495
|
+
if (!Array.isArray(savedConfig.instances)) {
|
|
4496
|
+
throw new Error(`Instance "${instName}" not found.`);
|
|
4497
|
+
}
|
|
4498
|
+
if (savedConfig.instances.length <= 1) {
|
|
4499
|
+
throw new Error("Cannot remove the last remaining instance.");
|
|
4500
|
+
}
|
|
4501
|
+
const idx = savedConfig.instances.findIndex((i) => i.name === instName);
|
|
4502
|
+
if (idx < 0) {
|
|
4503
|
+
throw new Error(`Instance "${instName}" not found.`);
|
|
4504
|
+
}
|
|
4505
|
+
savedConfig.instances.splice(idx, 1);
|
|
4506
|
+
if (savedConfig.defaultInstance === instName) {
|
|
4507
|
+
savedConfig.defaultInstance = savedConfig.instances[0]?.name || null;
|
|
4508
|
+
}
|
|
4509
|
+
return { defaultInstance: savedConfig.defaultInstance };
|
|
4510
|
+
});
|
|
4511
|
+
} catch (error) {
|
|
4164
4512
|
return {
|
|
4165
|
-
content: [{ type: "text", text:
|
|
4513
|
+
content: [{ type: "text", text: error.message }],
|
|
4166
4514
|
isError: true,
|
|
4167
4515
|
};
|
|
4168
4516
|
}
|
|
4169
4517
|
|
|
4518
|
+
// Update in-memory state only after the atomic config replacement succeeds.
|
|
4170
4519
|
const idx = instances.findIndex((i) => i.name === instName);
|
|
4171
|
-
if (idx
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
}
|
|
4177
|
-
|
|
4178
|
-
instances.splice(idx, 1);
|
|
4179
|
-
|
|
4180
|
-
// Persist to config file
|
|
4181
|
-
const savedConfig = loadConfigFile();
|
|
4182
|
-
if (savedConfig.instances) {
|
|
4183
|
-
savedConfig.instances = savedConfig.instances.filter((i) => i.name !== instName);
|
|
4184
|
-
if (savedConfig.defaultInstance === instName) {
|
|
4185
|
-
savedConfig.defaultInstance = savedConfig.instances[0]?.name || null;
|
|
4186
|
-
}
|
|
4187
|
-
fs.writeFileSync(jiraConfigPath, JSON.stringify(savedConfig, null, 2));
|
|
4188
|
-
}
|
|
4520
|
+
if (idx >= 0) instances.splice(idx, 1);
|
|
4521
|
+
defaultInstance =
|
|
4522
|
+
instances.find((instance) => instance.name === removal.defaultInstance) ||
|
|
4523
|
+
instances[0];
|
|
4524
|
+
rawConfig.defaultInstance = removal.defaultInstance;
|
|
4189
4525
|
|
|
4190
4526
|
return { content: [{ type: "text", text: `Removed instance "${instName}".` }] };
|
|
4191
4527
|
|
|
@@ -4758,7 +5094,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4758
5094
|
};
|
|
4759
5095
|
|
|
4760
5096
|
} else if (name === "jira_move_to_sprint") {
|
|
4761
|
-
const inst =
|
|
5097
|
+
const inst = args.instance
|
|
5098
|
+
? getInstanceByName(args.instance)
|
|
5099
|
+
: (args.issueKeys?.length ? getInstanceForKey(args.issueKeys[0]) : defaultInstance);
|
|
4762
5100
|
await fetchJiraAgile(
|
|
4763
5101
|
`/sprint/${args.sprintId}/issue`,
|
|
4764
5102
|
{ method: "POST", body: { issues: args.issueKeys } },
|
|
@@ -4793,7 +5131,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4793
5131
|
return { content: [{ type: "text", text }] };
|
|
4794
5132
|
|
|
4795
5133
|
} else if (name === "jira_get_boards") {
|
|
4796
|
-
const inst =
|
|
5134
|
+
const inst = args.instance
|
|
5135
|
+
? getInstanceByName(args.instance)
|
|
5136
|
+
: (projectValueToPrefix(args.projectKeyOrId)
|
|
5137
|
+
? getInstanceForProject(args.projectKeyOrId)
|
|
5138
|
+
: defaultInstance);
|
|
4797
5139
|
let endpoint = "/board?maxResults=50";
|
|
4798
5140
|
if (args.projectKeyOrId) {
|
|
4799
5141
|
endpoint += `&projectKeyOrId=${encodeURIComponent(args.projectKeyOrId)}`;
|
|
@@ -5414,7 +5756,8 @@ if (typeof module !== "undefined") {
|
|
|
5414
5756
|
// Policy module exports
|
|
5415
5757
|
TOOL_METADATA, checkScope, resolveDryRun, scrubArgs, extractTarget,
|
|
5416
5758
|
checkRateLimit, _setClockForTests, _resetBucketsForTests,
|
|
5417
|
-
|
|
5759
|
+
extractRoutingPrefixes, probeJiraProject, discoverProjectInstance,
|
|
5760
|
+
persistDiscoveredProjects, resolveInstanceForTool, _injectDryRunSchema,
|
|
5418
5761
|
AUDIT_LOG_PATH,
|
|
5419
5762
|
};
|
|
5420
5763
|
}
|