@rui.branco/jira-mcp 1.7.9 → 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 +544 -156
- 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 ============
|
|
@@ -474,9 +759,12 @@ async function fetchJira(endpoint, options = {}, instance = defaultInstance) {
|
|
|
474
759
|
});
|
|
475
760
|
if (!response.ok) {
|
|
476
761
|
const errorBody = await response.text().catch(() => "");
|
|
477
|
-
|
|
762
|
+
const error = new Error(
|
|
478
763
|
`Jira API error: ${response.status} ${response.statusText}${errorBody ? ` - ${errorBody}` : ""}`,
|
|
479
764
|
);
|
|
765
|
+
error.status = response.status;
|
|
766
|
+
error.body = errorBody;
|
|
767
|
+
throw error;
|
|
480
768
|
}
|
|
481
769
|
const text = await response.text();
|
|
482
770
|
return text ? JSON.parse(text) : {};
|
|
@@ -2351,7 +2639,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
2351
2639
|
{
|
|
2352
2640
|
name: "jira_search_users",
|
|
2353
2641
|
description:
|
|
2354
|
-
"Search for Jira users by name or email. Returns account IDs and display names. Use this to find users for mentions or assignments. When multiple Jira instances are configured, use the 'instance' parameter to specify which one.",
|
|
2642
|
+
"Search for Jira users by name or email. Returns account IDs and display names. Use this to find users for mentions or assignments. For assignment-oriented searches, provide issueKey or projectKey to find users assignable to that issue or project when global user browsing is unavailable. When multiple Jira instances are configured, use the 'instance' parameter to specify which one.",
|
|
2355
2643
|
inputSchema: {
|
|
2356
2644
|
type: "object",
|
|
2357
2645
|
properties: {
|
|
@@ -2364,6 +2652,16 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
2364
2652
|
type: "number",
|
|
2365
2653
|
description: "Max results (default 5)",
|
|
2366
2654
|
},
|
|
2655
|
+
issueKey: {
|
|
2656
|
+
type: "string",
|
|
2657
|
+
description:
|
|
2658
|
+
"Optional issue key for assignment-oriented searches (e.g., 'MODS-123'). If global user search is empty or user browsing is forbidden, search users assignable to this issue. The instance is auto-detected from the issue key when omitted.",
|
|
2659
|
+
},
|
|
2660
|
+
projectKey: {
|
|
2661
|
+
type: "string",
|
|
2662
|
+
description:
|
|
2663
|
+
"Optional project key for assignment-oriented searches (e.g., 'MODS'). If global user search is empty or user browsing is forbidden, search users assignable to this project. Used when issueKey is not provided.",
|
|
2664
|
+
},
|
|
2367
2665
|
instance: {
|
|
2368
2666
|
type: "string",
|
|
2369
2667
|
description: "Instance name (optional).",
|
|
@@ -3366,7 +3664,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
3366
3664
|
|
|
3367
3665
|
async function runWithPolicy(name, args, handler) {
|
|
3368
3666
|
const meta = TOOL_METADATA[name];
|
|
3369
|
-
|
|
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
|
+
}
|
|
3370
3676
|
|
|
3371
3677
|
// 1. Scope check
|
|
3372
3678
|
const scopeResult = checkScope(name, inst);
|
|
@@ -3505,13 +3811,45 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3505
3811
|
],
|
|
3506
3812
|
};
|
|
3507
3813
|
} else if (name === "jira_search_users") {
|
|
3508
|
-
const inst =
|
|
3814
|
+
const inst = await resolveInstanceForTool(name, args);
|
|
3509
3815
|
const maxResults = args.maxResults || 5;
|
|
3510
|
-
const
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3816
|
+
const hasAssignmentContext = Boolean(args.issueKey || args.projectKey);
|
|
3817
|
+
let users;
|
|
3818
|
+
let useAssignableFallback = false;
|
|
3819
|
+
|
|
3820
|
+
try {
|
|
3821
|
+
users = await fetchJira(
|
|
3822
|
+
`/user/search?query=${encodeURIComponent(args.query)}&maxResults=${maxResults}`,
|
|
3823
|
+
{},
|
|
3824
|
+
inst,
|
|
3825
|
+
);
|
|
3826
|
+
} catch (error) {
|
|
3827
|
+
const errorDetail = String(error.body || error.message || "").toLowerCase();
|
|
3828
|
+
if (
|
|
3829
|
+
hasAssignmentContext &&
|
|
3830
|
+
error.status === 403 &&
|
|
3831
|
+
/(browse users|user browsing|users and groups)/.test(errorDetail)
|
|
3832
|
+
) {
|
|
3833
|
+
useAssignableFallback = true;
|
|
3834
|
+
} else {
|
|
3835
|
+
throw error;
|
|
3836
|
+
}
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
if (
|
|
3840
|
+
useAssignableFallback ||
|
|
3841
|
+
(hasAssignmentContext && (!users || users.length === 0))
|
|
3842
|
+
) {
|
|
3843
|
+
const scope = args.issueKey
|
|
3844
|
+
? `issueKey=${encodeURIComponent(args.issueKey)}`
|
|
3845
|
+
: `project=${encodeURIComponent(args.projectKey)}`;
|
|
3846
|
+
users = await fetchJira(
|
|
3847
|
+
`/user/assignable/search?${scope}&query=${encodeURIComponent(args.query)}&maxResults=${maxResults}`,
|
|
3848
|
+
{},
|
|
3849
|
+
inst,
|
|
3850
|
+
);
|
|
3851
|
+
}
|
|
3852
|
+
|
|
3515
3853
|
if (!users || users.length === 0) {
|
|
3516
3854
|
return {
|
|
3517
3855
|
content: [
|
|
@@ -3940,7 +4278,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3940
4278
|
};
|
|
3941
4279
|
}
|
|
3942
4280
|
if (args.issueKey) {
|
|
3943
|
-
const
|
|
4281
|
+
const inst = args.instance
|
|
4282
|
+
? getInstanceByName(args.instance)
|
|
4283
|
+
: getInstanceForKey(args.issueKey);
|
|
4284
|
+
const result = await getChangelog(args.issueKey, inst);
|
|
3944
4285
|
return { content: [{ type: "text", text: result.formatted }] };
|
|
3945
4286
|
} else {
|
|
3946
4287
|
const inst = getInstanceByName(args.instance);
|
|
@@ -3950,6 +4291,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3950
4291
|
} else if (name === "jira_add_instance") {
|
|
3951
4292
|
const instName = args.name.trim();
|
|
3952
4293
|
const existingIdx = instances.findIndex((i) => i.name === instName);
|
|
4294
|
+
const existing = existingIdx >= 0 ? instances[existingIdx] : {};
|
|
3953
4295
|
const isUpdate = existingIdx >= 0;
|
|
3954
4296
|
|
|
3955
4297
|
// For new instances, email/token/baseUrl are required
|
|
@@ -3960,27 +4302,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3960
4302
|
};
|
|
3961
4303
|
}
|
|
3962
4304
|
|
|
3963
|
-
// Merge with existing or create new
|
|
3964
|
-
const existing = isUpdate ? instances[existingIdx] : {};
|
|
3965
4305
|
const email = args.email || existing.email;
|
|
3966
4306
|
const token = args.token || existing.token;
|
|
3967
4307
|
const baseUrl = args.baseUrl ? args.baseUrl.replace(/\/$/, "") : existing.baseUrl;
|
|
3968
|
-
const
|
|
4308
|
+
const requestedProjects = Array.isArray(args.projects)
|
|
4309
|
+
? normalizeProjectPrefixes(args.projects)
|
|
4310
|
+
: null;
|
|
4311
|
+
const currentProjects = normalizeProjectPrefixes(existing.projects);
|
|
3969
4312
|
const authStr = Buffer.from(`${email}:${token}`).toString("base64");
|
|
3970
4313
|
|
|
3971
4314
|
// Resolve defaultTeam if provided
|
|
3972
|
-
let
|
|
4315
|
+
let defaultTeamChange = null;
|
|
3973
4316
|
if (args.defaultTeam !== undefined) {
|
|
3974
4317
|
if (args.defaultTeam === "") {
|
|
3975
|
-
|
|
4318
|
+
defaultTeamChange = { value: undefined };
|
|
3976
4319
|
} else if (args.defaultTeam.toLowerCase() === "none") {
|
|
3977
|
-
|
|
4320
|
+
defaultTeamChange = { value: "none" };
|
|
3978
4321
|
} else {
|
|
3979
4322
|
// Validate team exists via Jira Teams API
|
|
3980
4323
|
const tempInst = { baseUrl, auth: authStr };
|
|
3981
4324
|
try {
|
|
3982
4325
|
const teamId = await resolveTeamId(args.defaultTeam, tempInst);
|
|
3983
|
-
|
|
4326
|
+
defaultTeamChange = { value: { name: args.defaultTeam, id: teamId } };
|
|
3984
4327
|
} catch (e) {
|
|
3985
4328
|
// Try to list available teams for a helpful error
|
|
3986
4329
|
try {
|
|
@@ -4001,18 +4344,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4001
4344
|
}
|
|
4002
4345
|
|
|
4003
4346
|
// Resolve projectTeam if provided
|
|
4004
|
-
let
|
|
4347
|
+
let projectTeamChange = null;
|
|
4005
4348
|
if (args.projectKey && args.projectTeam !== undefined) {
|
|
4006
4349
|
const pk = args.projectKey.toUpperCase();
|
|
4007
4350
|
if (args.projectTeam === "") {
|
|
4008
|
-
|
|
4351
|
+
projectTeamChange = { key: pk, value: undefined };
|
|
4009
4352
|
} else if (args.projectTeam.toLowerCase() === "none") {
|
|
4010
|
-
|
|
4353
|
+
projectTeamChange = { key: pk, value: "none" };
|
|
4011
4354
|
} else {
|
|
4012
4355
|
const tempInst = { baseUrl, auth: authStr };
|
|
4013
4356
|
try {
|
|
4014
4357
|
const teamId = await resolveTeamId(args.projectTeam, tempInst);
|
|
4015
|
-
|
|
4358
|
+
projectTeamChange = {
|
|
4359
|
+
key: pk,
|
|
4360
|
+
value: { name: args.projectTeam, id: teamId },
|
|
4361
|
+
};
|
|
4016
4362
|
} catch (e) {
|
|
4017
4363
|
return {
|
|
4018
4364
|
content: [{ type: "text", text: e.message }],
|
|
@@ -4022,76 +4368,106 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4022
4368
|
}
|
|
4023
4369
|
}
|
|
4024
4370
|
|
|
4025
|
-
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
|
+
}
|
|
4026
4383
|
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
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;
|
|
4050
4418
|
} else {
|
|
4051
|
-
|
|
4419
|
+
delete toSave.projectTeams;
|
|
4052
4420
|
}
|
|
4053
|
-
|
|
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
|
+
});
|
|
4054
4435
|
|
|
4055
|
-
//
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
email,
|
|
4064
|
-
token,
|
|
4065
|
-
baseUrl,
|
|
4066
|
-
projects,
|
|
4067
|
-
};
|
|
4068
|
-
if (defaultTeam) toSave.defaultTeam = defaultTeam;
|
|
4069
|
-
if (Object.keys(projectTeams).length > 0) toSave.projectTeams = projectTeams;
|
|
4070
|
-
if (savedIdx >= 0) {
|
|
4071
|
-
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);
|
|
4072
4444
|
} else {
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
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(", ")}.`;
|
|
4083
4459
|
if (args.setDefault) text += " Set as default.";
|
|
4084
|
-
if (
|
|
4085
|
-
const
|
|
4086
|
-
const pt = projectTeams[pk];
|
|
4460
|
+
if (projectTeamChange) {
|
|
4461
|
+
const pt = projectTeams[projectTeamChange.key];
|
|
4087
4462
|
if (pt === "none") {
|
|
4088
|
-
text += ` Project ${
|
|
4463
|
+
text += ` Project ${projectTeamChange.key} team: None (disabled).`;
|
|
4089
4464
|
} else if (pt) {
|
|
4090
|
-
text += ` Project ${
|
|
4465
|
+
text += ` Project ${projectTeamChange.key} team: ${pt.name}.`;
|
|
4091
4466
|
} else {
|
|
4092
|
-
text += ` Project ${
|
|
4467
|
+
text += ` Project ${projectTeamChange.key} team override removed.`;
|
|
4093
4468
|
}
|
|
4094
4469
|
}
|
|
4470
|
+
const defaultTeam = activeInstance.defaultTeam;
|
|
4095
4471
|
if (defaultTeam === "none") {
|
|
4096
4472
|
text += " Default team: None (disabled).";
|
|
4097
4473
|
} else if (defaultTeam) {
|
|
@@ -4099,8 +4475,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4099
4475
|
} else {
|
|
4100
4476
|
// No default team — fetch available teams and prompt
|
|
4101
4477
|
try {
|
|
4102
|
-
const
|
|
4103
|
-
const teams = await listTeams(tempInst);
|
|
4478
|
+
const teams = await listTeams(activeInstance);
|
|
4104
4479
|
if (teams && teams.length > 0) {
|
|
4105
4480
|
const list = teams.map((t, i) => `${i + 1}. ${t.title}`).join("\n");
|
|
4106
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.`;
|
|
@@ -4114,33 +4489,39 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4114
4489
|
|
|
4115
4490
|
} else if (name === "jira_remove_instance") {
|
|
4116
4491
|
const instName = args.name.trim();
|
|
4117
|
-
|
|
4118
|
-
|
|
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) {
|
|
4119
4512
|
return {
|
|
4120
|
-
content: [{ type: "text", text:
|
|
4513
|
+
content: [{ type: "text", text: error.message }],
|
|
4121
4514
|
isError: true,
|
|
4122
4515
|
};
|
|
4123
4516
|
}
|
|
4124
4517
|
|
|
4518
|
+
// Update in-memory state only after the atomic config replacement succeeds.
|
|
4125
4519
|
const idx = instances.findIndex((i) => i.name === instName);
|
|
4126
|
-
if (idx
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
}
|
|
4132
|
-
|
|
4133
|
-
instances.splice(idx, 1);
|
|
4134
|
-
|
|
4135
|
-
// Persist to config file
|
|
4136
|
-
const savedConfig = loadConfigFile();
|
|
4137
|
-
if (savedConfig.instances) {
|
|
4138
|
-
savedConfig.instances = savedConfig.instances.filter((i) => i.name !== instName);
|
|
4139
|
-
if (savedConfig.defaultInstance === instName) {
|
|
4140
|
-
savedConfig.defaultInstance = savedConfig.instances[0]?.name || null;
|
|
4141
|
-
}
|
|
4142
|
-
fs.writeFileSync(jiraConfigPath, JSON.stringify(savedConfig, null, 2));
|
|
4143
|
-
}
|
|
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;
|
|
4144
4525
|
|
|
4145
4526
|
return { content: [{ type: "text", text: `Removed instance "${instName}".` }] };
|
|
4146
4527
|
|
|
@@ -4713,7 +5094,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4713
5094
|
};
|
|
4714
5095
|
|
|
4715
5096
|
} else if (name === "jira_move_to_sprint") {
|
|
4716
|
-
const inst =
|
|
5097
|
+
const inst = args.instance
|
|
5098
|
+
? getInstanceByName(args.instance)
|
|
5099
|
+
: (args.issueKeys?.length ? getInstanceForKey(args.issueKeys[0]) : defaultInstance);
|
|
4717
5100
|
await fetchJiraAgile(
|
|
4718
5101
|
`/sprint/${args.sprintId}/issue`,
|
|
4719
5102
|
{ method: "POST", body: { issues: args.issueKeys } },
|
|
@@ -4748,7 +5131,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
4748
5131
|
return { content: [{ type: "text", text }] };
|
|
4749
5132
|
|
|
4750
5133
|
} else if (name === "jira_get_boards") {
|
|
4751
|
-
const inst =
|
|
5134
|
+
const inst = args.instance
|
|
5135
|
+
? getInstanceByName(args.instance)
|
|
5136
|
+
: (projectValueToPrefix(args.projectKeyOrId)
|
|
5137
|
+
? getInstanceForProject(args.projectKeyOrId)
|
|
5138
|
+
: defaultInstance);
|
|
4752
5139
|
let endpoint = "/board?maxResults=50";
|
|
4753
5140
|
if (args.projectKeyOrId) {
|
|
4754
5141
|
endpoint += `&projectKeyOrId=${encodeURIComponent(args.projectKeyOrId)}`;
|
|
@@ -5369,7 +5756,8 @@ if (typeof module !== "undefined") {
|
|
|
5369
5756
|
// Policy module exports
|
|
5370
5757
|
TOOL_METADATA, checkScope, resolveDryRun, scrubArgs, extractTarget,
|
|
5371
5758
|
checkRateLimit, _setClockForTests, _resetBucketsForTests,
|
|
5372
|
-
|
|
5759
|
+
extractRoutingPrefixes, probeJiraProject, discoverProjectInstance,
|
|
5760
|
+
persistDiscoveredProjects, resolveInstanceForTool, _injectDryRunSchema,
|
|
5373
5761
|
AUDIT_LOG_PATH,
|
|
5374
5762
|
};
|
|
5375
5763
|
}
|