@thammarongg/jira-mcp 0.1.0 → 0.2.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/README.md +34 -2
- package/dist/index.js +3 -1
- package/dist/install.js +372 -116
- package/package.json +1 -1
- package/skill/SKILL.md +12 -2
package/README.md
CHANGED
|
@@ -6,10 +6,42 @@ Works with both **Jira Cloud** (REST API v3) and **Jira Data Center** (REST API
|
|
|
6
6
|
|
|
7
7
|
## Install (one-click)
|
|
8
8
|
|
|
9
|
-
Once published to npm, any MCP client can run it via `npx` — no local build needed
|
|
9
|
+
Once published to npm, any MCP client can run it via `npx` — no local build needed.
|
|
10
|
+
|
|
11
|
+
### Interactive installer (all agents at once)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx -y @thammarongg/jira-mcp install
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Shows a menu — Select All or pick agents (Claude Code, OpenCode, Codex, Cursor,
|
|
18
|
+
Claude Desktop, Gemini CLI) — then asks for the Jira base URL and credentials
|
|
19
|
+
(token entry is hidden on a TTY). Existing config files are backed up to
|
|
20
|
+
`.bak` before any modification, and re-running updates the `jira` entry in
|
|
21
|
+
place instead of duplicating it.
|
|
22
|
+
|
|
23
|
+
Non-interactive (CI / scripting):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Jira Cloud
|
|
27
|
+
npx -y @thammarongg/jira-mcp install --agents all \
|
|
28
|
+
--base-url https://your-org.atlassian.net \
|
|
29
|
+
--email you@example.com --token xxx --yes
|
|
30
|
+
|
|
31
|
+
# Jira Data Center (subset of agents)
|
|
32
|
+
npx -y @thammarongg/jira-mcp install --agents claude-code,codex \
|
|
33
|
+
--base-url https://jira.yourcompany.com \
|
|
34
|
+
--username you --token xxx --yes
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Flags: `--agents all` or comma-separated ids/numbers (`claude-code`,
|
|
38
|
+
`opencode`, `codex`, `cursor`, `claude-desktop`, `gemini-cli`), `--base-url`,
|
|
39
|
+
`--email` (Cloud) / `--username` (Data Center), `--token` (API token or PAT),
|
|
40
|
+
`--password` (DC app password), `--yes` (skip the confirm prompt).
|
|
41
|
+
|
|
42
|
+
### Claude Code (manual one-liner)
|
|
10
43
|
|
|
11
44
|
```bash
|
|
12
|
-
# Claude Code (one-liner)
|
|
13
45
|
claude mcp add jira --env JIRA_BASE_URL=https://your-org.atlassian.net \
|
|
14
46
|
--env JIRA_EMAIL=you@example.com --env JIRA_API_TOKEN=xxx \
|
|
15
47
|
-- npx -y @thammarongg/jira-mcp
|
package/dist/index.js
CHANGED
|
@@ -33,6 +33,8 @@ async function main() {
|
|
|
33
33
|
console.error(`jira-mcp ready: ${config.baseUrl} (API v${config.apiVersion}, ${config.isCloud ? "Cloud" : "Data Center"})`);
|
|
34
34
|
}
|
|
35
35
|
main().catch((err) => {
|
|
36
|
-
|
|
36
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
37
|
+
const prefix = process.argv[2] === "install" ? "jira-mcp install failed" : "jira-mcp failed to start";
|
|
38
|
+
console.error(`${prefix}: ${msg}`);
|
|
37
39
|
process.exit(1);
|
|
38
40
|
});
|
package/dist/install.js
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir, platform } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import * as readline from "node:readline";
|
|
5
|
-
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
function readPackageName() {
|
|
7
|
+
try {
|
|
8
|
+
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
9
|
+
const parsed = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
10
|
+
if (typeof parsed.name === "string" && parsed.name.length > 0)
|
|
11
|
+
return parsed.name;
|
|
12
|
+
}
|
|
13
|
+
catch { }
|
|
14
|
+
return "@thammarongg/jira-mcp";
|
|
15
|
+
}
|
|
16
|
+
const PKG = readPackageName();
|
|
6
17
|
const claudeDesktopPath = platform() === "darwin"
|
|
7
18
|
? join(homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json")
|
|
8
19
|
: join(homedir(), ".config", "Claude", "claude_desktop_config.json");
|
|
@@ -48,6 +59,37 @@ function parseFlags(argv) {
|
|
|
48
59
|
}
|
|
49
60
|
return flags;
|
|
50
61
|
}
|
|
62
|
+
function validateCredentialFlags(flags) {
|
|
63
|
+
const anyPresent = [flags.baseUrl, flags.email, flags.username, flags.token, flags.password].some((v) => v !== undefined);
|
|
64
|
+
if (!anyPresent)
|
|
65
|
+
return;
|
|
66
|
+
if (flags.email !== undefined && flags.username !== undefined)
|
|
67
|
+
throw new Error("--email and --username are mutually exclusive (Cloud uses --email, Data Center uses --username)");
|
|
68
|
+
if (flags.email !== undefined) {
|
|
69
|
+
const missing = [];
|
|
70
|
+
if (flags.baseUrl === undefined)
|
|
71
|
+
missing.push("--base-url");
|
|
72
|
+
if (flags.token === undefined)
|
|
73
|
+
missing.push("--token");
|
|
74
|
+
if (missing.length > 0)
|
|
75
|
+
throw new Error(`--email requires ${missing.join(" and ")}`);
|
|
76
|
+
if (flags.password !== undefined)
|
|
77
|
+
throw new Error("--password is a Data Center option and cannot be combined with --email");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (flags.username !== undefined) {
|
|
81
|
+
if (flags.baseUrl === undefined)
|
|
82
|
+
throw new Error("--username requires --base-url");
|
|
83
|
+
if (flags.token === undefined && flags.password === undefined)
|
|
84
|
+
throw new Error("--username requires --token or --password");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (flags.token !== undefined)
|
|
88
|
+
throw new Error("--token requires --email (Cloud) or --username (Data Center)");
|
|
89
|
+
if (flags.password !== undefined)
|
|
90
|
+
throw new Error("--password requires --username (Data Center)");
|
|
91
|
+
throw new Error("--base-url requires --email or --username (plus --token / --password)");
|
|
92
|
+
}
|
|
51
93
|
function printUsage() {
|
|
52
94
|
console.log(`jira-mcp install — configure the jira MCP server in your agent(s)
|
|
53
95
|
|
|
@@ -66,40 +108,181 @@ Options:
|
|
|
66
108
|
-h, --help Show this help
|
|
67
109
|
`);
|
|
68
110
|
}
|
|
111
|
+
function makePrompter() {
|
|
112
|
+
const queue = [];
|
|
113
|
+
const waiters = [];
|
|
114
|
+
let closed = false;
|
|
115
|
+
let suspended = false;
|
|
116
|
+
let rl = null;
|
|
117
|
+
const attach = (r) => {
|
|
118
|
+
r.on("line", (line) => {
|
|
119
|
+
const waiter = waiters.shift();
|
|
120
|
+
if (waiter)
|
|
121
|
+
waiter(line);
|
|
122
|
+
else
|
|
123
|
+
queue.push(line);
|
|
124
|
+
});
|
|
125
|
+
r.on("close", () => {
|
|
126
|
+
if (suspended)
|
|
127
|
+
return;
|
|
128
|
+
closed = true;
|
|
129
|
+
while (waiters.length > 0)
|
|
130
|
+
waiters.shift()("");
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
const start = () => {
|
|
134
|
+
rl = readline.createInterface({
|
|
135
|
+
input: process.stdin,
|
|
136
|
+
output: process.stdout,
|
|
137
|
+
terminal: process.stdin.isTTY === true,
|
|
138
|
+
});
|
|
139
|
+
attach(rl);
|
|
140
|
+
};
|
|
141
|
+
start();
|
|
142
|
+
const prompter = {
|
|
143
|
+
get closed() {
|
|
144
|
+
return closed;
|
|
145
|
+
},
|
|
146
|
+
ask(question) {
|
|
147
|
+
process.stdout.write(question);
|
|
148
|
+
const queued = queue.shift();
|
|
149
|
+
if (queued !== undefined)
|
|
150
|
+
return Promise.resolve(queued);
|
|
151
|
+
if (closed)
|
|
152
|
+
return Promise.resolve("");
|
|
153
|
+
return new Promise((resolve) => waiters.push(resolve));
|
|
154
|
+
},
|
|
155
|
+
close() {
|
|
156
|
+
suspended = false;
|
|
157
|
+
closed = true;
|
|
158
|
+
while (waiters.length > 0)
|
|
159
|
+
waiters.shift()("");
|
|
160
|
+
rl?.close();
|
|
161
|
+
rl = null;
|
|
162
|
+
},
|
|
163
|
+
suspend() {
|
|
164
|
+
suspended = true;
|
|
165
|
+
rl?.close();
|
|
166
|
+
rl = null;
|
|
167
|
+
},
|
|
168
|
+
resume() {
|
|
169
|
+
suspended = false;
|
|
170
|
+
if (rl === null)
|
|
171
|
+
start();
|
|
172
|
+
},
|
|
173
|
+
tryQueued() {
|
|
174
|
+
return queue.shift();
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
return prompter;
|
|
178
|
+
}
|
|
179
|
+
function askPassword(p, promptText) {
|
|
180
|
+
const stdin = process.stdin;
|
|
181
|
+
if (typeof stdin.setRawMode !== "function") {
|
|
182
|
+
return p.ask(`${promptText} (no echo available) `).then((v) => v.trim());
|
|
183
|
+
}
|
|
184
|
+
const queued = p.tryQueued();
|
|
185
|
+
if (queued !== undefined) {
|
|
186
|
+
process.stdout.write(promptText);
|
|
187
|
+
return Promise.resolve(queued.trim());
|
|
188
|
+
}
|
|
189
|
+
return new Promise((resolve, reject) => {
|
|
190
|
+
let value = "";
|
|
191
|
+
let finished = false;
|
|
192
|
+
const cleanup = () => {
|
|
193
|
+
if (finished)
|
|
194
|
+
return;
|
|
195
|
+
finished = true;
|
|
196
|
+
stdin.removeListener("data", onData);
|
|
197
|
+
stdin.removeListener("end", onEnd);
|
|
198
|
+
stdin.removeListener("close", onClose);
|
|
199
|
+
stdin.setRawMode(false);
|
|
200
|
+
};
|
|
201
|
+
const finish = (v) => {
|
|
202
|
+
cleanup();
|
|
203
|
+
p.resume();
|
|
204
|
+
if (v === null)
|
|
205
|
+
reject(new Error("input closed while reading password"));
|
|
206
|
+
else {
|
|
207
|
+
process.stdout.write("\n");
|
|
208
|
+
resolve(v.trim());
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
const onData = (buf) => {
|
|
212
|
+
for (const ch of buf.toString("utf8")) {
|
|
213
|
+
if (ch === "\r" || ch === "\n") {
|
|
214
|
+
finish(value);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (ch === "\x03") {
|
|
218
|
+
cleanup();
|
|
219
|
+
p.resume();
|
|
220
|
+
process.exit(130);
|
|
221
|
+
}
|
|
222
|
+
if (ch === "\x04") {
|
|
223
|
+
finish("");
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (ch === "\u007f" || ch === "\b")
|
|
227
|
+
value = value.slice(0, -1);
|
|
228
|
+
else if (ch >= " ")
|
|
229
|
+
value += ch;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
const onEnd = () => finish(null);
|
|
233
|
+
const onClose = () => finish(null);
|
|
234
|
+
p.suspend();
|
|
235
|
+
process.stdout.write(promptText);
|
|
236
|
+
stdin.setRawMode(true);
|
|
237
|
+
stdin.resume();
|
|
238
|
+
stdin.on("data", onData);
|
|
239
|
+
stdin.on("end", onEnd);
|
|
240
|
+
stdin.on("close", onClose);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
69
243
|
export async function runInstaller(argv) {
|
|
70
244
|
const flags = parseFlags(argv);
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
for (const agent of selected) {
|
|
90
|
-
try {
|
|
91
|
-
const result = writeAgentConfig(agent, env);
|
|
92
|
-
console.log(` ok ${agent.label.padEnd(15)} ${agent.path} (${result})`);
|
|
245
|
+
validateCredentialFlags(flags);
|
|
246
|
+
const hasCredentials = (flags.baseUrl && flags.email && flags.token) ||
|
|
247
|
+
(flags.baseUrl && flags.username && (flags.token || flags.password));
|
|
248
|
+
const needsPrompts = !flags.agents || !hasCredentials || !flags.yes;
|
|
249
|
+
const p = needsPrompts ? makePrompter() : undefined;
|
|
250
|
+
try {
|
|
251
|
+
const selected = flags.agents ? resolveAgents(flags.agents) : await promptSelection(p);
|
|
252
|
+
const env = hasCredentials ? buildEnvFromFlags(flags) : await promptCredentials(p);
|
|
253
|
+
console.log("\nWill configure the jira MCP server in:");
|
|
254
|
+
for (const agent of selected)
|
|
255
|
+
console.log(` - ${agent.label.padEnd(15)} ${agent.path}`);
|
|
256
|
+
console.log(`Env vars: ${Object.keys(env).join(", ")} (values not shown)`);
|
|
257
|
+
if (!flags.yes) {
|
|
258
|
+
const answer = (await p.ask("\nProceed? [Y/n] ")).trim().toLowerCase();
|
|
259
|
+
if ((p.closed && answer === "") || (answer !== "" && answer !== "y" && answer !== "yes")) {
|
|
260
|
+
console.log("Aborted.");
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
93
263
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
264
|
+
let failed = false;
|
|
265
|
+
for (const agent of selected) {
|
|
266
|
+
try {
|
|
267
|
+
const result = writeAgentConfig(agent, env);
|
|
268
|
+
if (result === "skipped")
|
|
269
|
+
console.log(` skip ${agent.label.padEnd(15)} ${agent.path}`);
|
|
270
|
+
else
|
|
271
|
+
console.log(` ok ${agent.label.padEnd(15)} ${agent.path} (${result})`);
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
failed = true;
|
|
275
|
+
console.error(` ERR ${agent.label.padEnd(15)} ${agent.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
276
|
+
}
|
|
97
277
|
}
|
|
278
|
+
console.log("\nDone. Restart each agent to pick up the new MCP server.");
|
|
279
|
+
console.log("Verify by asking your agent to call the get_current_user tool.");
|
|
280
|
+
if (failed)
|
|
281
|
+
process.exitCode = 1;
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
p?.close();
|
|
98
285
|
}
|
|
99
|
-
console.log("\nDone. Restart each agent to pick up the new MCP server.");
|
|
100
|
-
console.log("Verify by asking your agent to call the get_current_user tool.");
|
|
101
|
-
if (failed)
|
|
102
|
-
process.exitCode = 1;
|
|
103
286
|
}
|
|
104
287
|
function resolveAgents(spec) {
|
|
105
288
|
const norm = spec.trim().toLowerCase();
|
|
@@ -107,9 +290,9 @@ function resolveAgents(spec) {
|
|
|
107
290
|
return [...AGENTS];
|
|
108
291
|
const ids = norm.split(/[,\s]+/).filter(Boolean);
|
|
109
292
|
const selected = ids.map((id) => {
|
|
110
|
-
const agent = AGENTS.find((a) => a.id === id);
|
|
293
|
+
const agent = /^\d+$/.test(id) ? AGENTS[Number(id) - 1] : AGENTS.find((a) => a.id === id);
|
|
111
294
|
if (!agent)
|
|
112
|
-
throw new Error(`Unknown agent '${id}'. Valid: ${AGENTS.map((a) => a.id).join(", ")} or all`);
|
|
295
|
+
throw new Error(`Unknown agent '${id}'. Valid: ${AGENTS.map((a, i) => `${i + 1}/${a.id}`).join(", ")} or all`);
|
|
113
296
|
return agent;
|
|
114
297
|
});
|
|
115
298
|
if (selected.length === 0)
|
|
@@ -119,14 +302,10 @@ function resolveAgents(spec) {
|
|
|
119
302
|
function buildEnvFromFlags(flags) {
|
|
120
303
|
const env = { JIRA_BASE_URL: flags.baseUrl };
|
|
121
304
|
if (flags.email) {
|
|
122
|
-
if (!flags.token)
|
|
123
|
-
throw new Error("--email requires --token");
|
|
124
305
|
env.JIRA_EMAIL = flags.email;
|
|
125
306
|
env.JIRA_API_TOKEN = flags.token;
|
|
126
307
|
}
|
|
127
308
|
else {
|
|
128
|
-
if (!flags.token && !flags.password)
|
|
129
|
-
throw new Error("--username requires --token or --password");
|
|
130
309
|
env.JIRA_USERNAME = flags.username;
|
|
131
310
|
if (flags.token)
|
|
132
311
|
env.JIRA_API_TOKEN = flags.token;
|
|
@@ -135,84 +314,37 @@ function buildEnvFromFlags(flags) {
|
|
|
135
314
|
}
|
|
136
315
|
return env;
|
|
137
316
|
}
|
|
138
|
-
function
|
|
139
|
-
return readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
140
|
-
}
|
|
141
|
-
function ask(rl, question) {
|
|
142
|
-
return new Promise((resolve) => rl.question(question, resolve));
|
|
143
|
-
}
|
|
144
|
-
function askPassword(promptText) {
|
|
145
|
-
const stdin = process.stdin;
|
|
146
|
-
if (typeof stdin.setRawMode !== "function") {
|
|
147
|
-
return new Promise((resolve) => {
|
|
148
|
-
process.stdout.write(`${promptText} (non-interactive: value visible)\n`);
|
|
149
|
-
const rl = makeRl();
|
|
150
|
-
rl.question("", (v) => {
|
|
151
|
-
rl.close();
|
|
152
|
-
resolve(v.trim());
|
|
153
|
-
});
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
return new Promise((resolve) => {
|
|
157
|
-
let value = "";
|
|
158
|
-
const done = () => {
|
|
159
|
-
stdin.removeListener("data", onData);
|
|
160
|
-
stdin.setRawMode(false);
|
|
161
|
-
process.stdout.write("\n");
|
|
162
|
-
resolve(value);
|
|
163
|
-
};
|
|
164
|
-
const onData = (buf) => {
|
|
165
|
-
for (const ch of buf.toString("utf8")) {
|
|
166
|
-
if (ch === "\r" || ch === "\n") {
|
|
167
|
-
done();
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
if (ch === "\u007f" || ch === "\b")
|
|
171
|
-
value = value.slice(0, -1);
|
|
172
|
-
else if (ch >= " ")
|
|
173
|
-
value += ch;
|
|
174
|
-
}
|
|
175
|
-
};
|
|
176
|
-
process.stdout.write(promptText);
|
|
177
|
-
stdin.setRawMode(true);
|
|
178
|
-
stdin.on("data", onData);
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
async function promptSelection() {
|
|
317
|
+
async function promptSelection(p) {
|
|
182
318
|
console.log(`\njira-mcp installer — select agents to configure:\n`);
|
|
183
319
|
console.log(" a) Select All");
|
|
184
320
|
AGENTS.forEach((agent, i) => {
|
|
185
321
|
console.log(` ${i + 1}) ${agent.label.padEnd(15)} ${agent.path}`);
|
|
186
322
|
});
|
|
187
|
-
const rl = makeRl();
|
|
188
323
|
for (;;) {
|
|
189
|
-
const answer = (await ask(
|
|
190
|
-
|
|
324
|
+
const answer = (await p.ask("\nChoice (e.g. 1,3 or a): ")).trim().toLowerCase();
|
|
325
|
+
if (answer === "" && p.closed)
|
|
326
|
+
throw new Error("No input received (stdin closed)");
|
|
191
327
|
try {
|
|
192
328
|
return answer === "a" || answer === "all" ? [...AGENTS] : resolveAgents(answer);
|
|
193
329
|
}
|
|
194
330
|
catch (err) {
|
|
331
|
+
if (p.closed)
|
|
332
|
+
throw err;
|
|
195
333
|
console.log(` ${err instanceof Error ? err.message : String(err)}`);
|
|
196
334
|
}
|
|
197
335
|
}
|
|
198
336
|
}
|
|
199
|
-
async function promptCredentials() {
|
|
200
|
-
const
|
|
201
|
-
const baseUrl = (await ask(rl, "Jira base URL (e.g. https://your-org.atlassian.net): ")).trim();
|
|
337
|
+
async function promptCredentials(p) {
|
|
338
|
+
const baseUrl = (await p.ask("Jira base URL (e.g. https://your-org.atlassian.net): ")).trim();
|
|
202
339
|
if (!/^https?:\/\/.+/i.test(baseUrl)) {
|
|
203
|
-
rl.close();
|
|
204
340
|
throw new Error("Jira base URL must start with http(s)://");
|
|
205
341
|
}
|
|
206
|
-
const mode = (await ask(
|
|
342
|
+
const mode = (await p.ask("Deployment: [1] Jira Cloud [2] Jira Data Center (default 1): ")).trim();
|
|
207
343
|
const isCloud = mode === "" || mode === "1";
|
|
208
|
-
const user = (await ask(
|
|
209
|
-
if (!user)
|
|
210
|
-
rl.close();
|
|
344
|
+
const user = (await p.ask(isCloud ? "Atlassian email: " : "Data Center username: ")).trim();
|
|
345
|
+
if (!user)
|
|
211
346
|
throw new Error(isCloud ? "Email is required" : "Username is required");
|
|
212
|
-
|
|
213
|
-
const secret = await askPassword(isCloud ? "API token (hidden, from id.atlassian.com): " : "API token / app password (hidden): ");
|
|
214
|
-
rl.resume();
|
|
215
|
-
rl.close();
|
|
347
|
+
const secret = await askPassword(p, isCloud ? "API token (hidden, from id.atlassian.com): " : "API token / app password (hidden): ");
|
|
216
348
|
if (!secret)
|
|
217
349
|
throw new Error("API token is required");
|
|
218
350
|
const env = { JIRA_BASE_URL: baseUrl };
|
|
@@ -230,8 +362,14 @@ function writeAgentConfig(agent, env) {
|
|
|
230
362
|
if (agent.kind === "codex")
|
|
231
363
|
return upsertCodex(agent.path, env);
|
|
232
364
|
if (agent.kind === "opencode") {
|
|
365
|
+
const jsonc = agent.path.replace(/\.json$/, ".jsonc");
|
|
366
|
+
if (existsSync(jsonc)) {
|
|
367
|
+
console.log(` ! ${jsonc} exists — skipping automatic opencode write (JSONC may contain comments); merge the jira entry manually`);
|
|
368
|
+
return "skipped";
|
|
369
|
+
}
|
|
233
370
|
return mergeJsonFile(agent.path, (obj) => {
|
|
234
371
|
const mcp = (obj.mcp ??= {});
|
|
372
|
+
reportEnvChanges(envOf(mcp.jira), env, agent.path);
|
|
235
373
|
mcp.jira = {
|
|
236
374
|
type: "local",
|
|
237
375
|
command: ["npx", "-y", PKG],
|
|
@@ -242,31 +380,122 @@ function writeAgentConfig(agent, env) {
|
|
|
242
380
|
}
|
|
243
381
|
return mergeJsonFile(agent.path, (obj) => {
|
|
244
382
|
const mcpServers = (obj.mcpServers ??= {});
|
|
383
|
+
reportEnvChanges(envOf(mcpServers.jira), env, agent.path);
|
|
245
384
|
mcpServers.jira = { command: "npx", args: ["-y", PKG], env };
|
|
246
385
|
});
|
|
247
386
|
}
|
|
387
|
+
function envOf(entry) {
|
|
388
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry))
|
|
389
|
+
return undefined;
|
|
390
|
+
const e = entry.env ?? entry.environment;
|
|
391
|
+
if (!e || typeof e !== "object" || Array.isArray(e))
|
|
392
|
+
return undefined;
|
|
393
|
+
return e;
|
|
394
|
+
}
|
|
395
|
+
function reportEnvChanges(oldEnv, newEnv, path) {
|
|
396
|
+
if (!oldEnv)
|
|
397
|
+
return;
|
|
398
|
+
const replaced = Object.keys(newEnv).filter((k) => oldEnv[k] !== newEnv[k]);
|
|
399
|
+
const removed = Object.keys(oldEnv).filter((k) => !(k in newEnv));
|
|
400
|
+
if (replaced.length > 0)
|
|
401
|
+
console.log(` ! ${path}: existing jira entry will be replaced — env vars changed: ${replaced.join(", ")}`);
|
|
402
|
+
if (removed.length > 0)
|
|
403
|
+
console.log(` ! ${path}: existing jira entry will be replaced — env vars removed: ${removed.join(", ")}`);
|
|
404
|
+
}
|
|
405
|
+
function fileStamp(path) {
|
|
406
|
+
const s = statSync(path);
|
|
407
|
+
return { mtimeMs: s.mtimeMs, size: s.size };
|
|
408
|
+
}
|
|
409
|
+
function assertUnchanged(path, before) {
|
|
410
|
+
const after = statSync(path);
|
|
411
|
+
if (after.mtimeMs !== before.mtimeMs || after.size !== before.size) {
|
|
412
|
+
throw new Error("changed while installing — close running agents and retry");
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
function atomicWrite(path, content) {
|
|
416
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
417
|
+
writeFileSync(tmp, content);
|
|
418
|
+
renameSync(tmp, path);
|
|
419
|
+
}
|
|
248
420
|
function mergeJsonFile(path, mutate) {
|
|
249
421
|
const existed = existsSync(path);
|
|
422
|
+
let stamp;
|
|
250
423
|
let obj = {};
|
|
251
424
|
if (existed) {
|
|
425
|
+
stamp = fileStamp(path);
|
|
252
426
|
const raw = readFileSync(path, "utf8");
|
|
427
|
+
let parsed;
|
|
253
428
|
try {
|
|
254
|
-
|
|
255
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
256
|
-
obj = parsed;
|
|
429
|
+
parsed = raw.trim() ? JSON.parse(raw) : {};
|
|
257
430
|
}
|
|
258
431
|
catch {
|
|
259
|
-
|
|
260
|
-
|
|
432
|
+
parsed = undefined;
|
|
433
|
+
}
|
|
434
|
+
if (parsed === undefined || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
435
|
+
console.log(` ! ${path} is not a JSON object — backing up to ${path}.bak and starting fresh`);
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
obj = parsed;
|
|
261
439
|
}
|
|
262
440
|
}
|
|
263
441
|
mutate(obj);
|
|
264
442
|
mkdirSync(dirname(path), { recursive: true });
|
|
265
|
-
|
|
443
|
+
if (existed) {
|
|
444
|
+
assertUnchanged(path, stamp);
|
|
445
|
+
copyFileSync(path, `${path}.bak`);
|
|
446
|
+
}
|
|
447
|
+
atomicWrite(path, JSON.stringify(obj, null, 2) + "\n");
|
|
266
448
|
return existed ? "updated" : "created";
|
|
267
449
|
}
|
|
268
450
|
function tomlStr(v) {
|
|
269
|
-
|
|
451
|
+
let out = "";
|
|
452
|
+
for (const ch of v) {
|
|
453
|
+
const code = ch.codePointAt(0);
|
|
454
|
+
if (ch === "\\")
|
|
455
|
+
out += "\\\\";
|
|
456
|
+
else if (ch === '"')
|
|
457
|
+
out += '\\"';
|
|
458
|
+
else if (ch === "\n")
|
|
459
|
+
out += "\\n";
|
|
460
|
+
else if (ch === "\t")
|
|
461
|
+
out += "\\t";
|
|
462
|
+
else if (ch === "\r")
|
|
463
|
+
out += "\\r";
|
|
464
|
+
else if (code < 0x20)
|
|
465
|
+
out += `\\u${code.toString(16).toUpperCase().padStart(4, "0")}`;
|
|
466
|
+
else
|
|
467
|
+
out += ch;
|
|
468
|
+
}
|
|
469
|
+
return `"${out}"`;
|
|
470
|
+
}
|
|
471
|
+
const JIRA_HEADER = /^\s*\[mcp_servers\.jira\](\s+#.*)?$/;
|
|
472
|
+
function parseTomlEnv(lines, start) {
|
|
473
|
+
let end = lines.length;
|
|
474
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
475
|
+
if (/^\s*\[/.test(lines[i])) {
|
|
476
|
+
end = i;
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
for (let i = start + 1; i < end; i++) {
|
|
481
|
+
const m = lines[i].match(/^\s*env\s*=\s*\{(.*)\}\s*$/);
|
|
482
|
+
if (!m)
|
|
483
|
+
continue;
|
|
484
|
+
const out = {};
|
|
485
|
+
for (const part of m[1].split(",")) {
|
|
486
|
+
const eq = part.indexOf("=");
|
|
487
|
+
if (eq < 0)
|
|
488
|
+
continue;
|
|
489
|
+
const k = part.slice(0, eq).trim();
|
|
490
|
+
let v = part.slice(eq + 1).trim();
|
|
491
|
+
if (v.length >= 2 && v.startsWith('"') && v.endsWith('"'))
|
|
492
|
+
v = v.slice(1, -1);
|
|
493
|
+
if (k)
|
|
494
|
+
out[k] = v;
|
|
495
|
+
}
|
|
496
|
+
return out;
|
|
497
|
+
}
|
|
498
|
+
return undefined;
|
|
270
499
|
}
|
|
271
500
|
function upsertCodex(path, env) {
|
|
272
501
|
const block = [
|
|
@@ -279,24 +508,51 @@ function upsertCodex(path, env) {
|
|
|
279
508
|
];
|
|
280
509
|
if (!existsSync(path)) {
|
|
281
510
|
mkdirSync(dirname(path), { recursive: true });
|
|
282
|
-
|
|
511
|
+
atomicWrite(path, block.join("\n") + "\n");
|
|
283
512
|
return "created";
|
|
284
513
|
}
|
|
514
|
+
const stamp = fileStamp(path);
|
|
285
515
|
const raw = readFileSync(path, "utf8");
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
516
|
+
const eol = raw.includes("\r\n") ? "\r\n" : "\n";
|
|
517
|
+
const lines = raw.split("\n").map((l) => (l.endsWith("\r") ? l.slice(0, -1) : l));
|
|
518
|
+
const starts = [];
|
|
519
|
+
for (let i = 0; i < lines.length; i++) {
|
|
520
|
+
if (JIRA_HEADER.test(lines[i]))
|
|
521
|
+
starts.push(i);
|
|
522
|
+
}
|
|
523
|
+
let content;
|
|
524
|
+
if (starts.length === 0) {
|
|
525
|
+
content = raw.replace(/\s*$/, "") + eol + eol + block.join(eol) + eol;
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
const remove = new Set();
|
|
529
|
+
for (const start of starts) {
|
|
530
|
+
let end = lines.length;
|
|
531
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
532
|
+
if (/^\s*\[/.test(lines[i])) {
|
|
533
|
+
end = i;
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
294
536
|
}
|
|
537
|
+
for (let i = start; i < end; i++)
|
|
538
|
+
remove.add(i);
|
|
295
539
|
}
|
|
296
|
-
lines.
|
|
297
|
-
|
|
298
|
-
|
|
540
|
+
const kept = lines.filter((_, i) => !remove.has(i));
|
|
541
|
+
let insertIdx = 0;
|
|
542
|
+
for (let i = 0; i < starts[0]; i++)
|
|
543
|
+
if (!remove.has(i))
|
|
544
|
+
insertIdx++;
|
|
545
|
+
kept.splice(insertIdx, 0, ...block);
|
|
546
|
+
const after = insertIdx + block.length;
|
|
547
|
+
if (after < kept.length && kept[after].trim() !== "")
|
|
548
|
+
kept.splice(after, 0, "");
|
|
549
|
+
content = kept.join(eol);
|
|
550
|
+
if (!content.endsWith(eol))
|
|
551
|
+
content += eol;
|
|
299
552
|
}
|
|
300
|
-
|
|
553
|
+
reportEnvChanges(starts.length > 0 ? parseTomlEnv(lines, starts[0]) : undefined, env, path);
|
|
554
|
+
assertUnchanged(path, stamp);
|
|
555
|
+
copyFileSync(path, `${path}.bak`);
|
|
556
|
+
atomicWrite(path, content);
|
|
301
557
|
return "updated";
|
|
302
558
|
}
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -13,8 +13,18 @@ server-side.
|
|
|
13
13
|
|
|
14
14
|
## If the jira MCP tools are not available
|
|
15
15
|
|
|
16
|
-
Tell the user the server isn't configured and offer to set it up.
|
|
17
|
-
|
|
16
|
+
Tell the user the server isn't configured and offer to set it up. Easiest is
|
|
17
|
+
the interactive installer (writes config for the selected agents — Claude
|
|
18
|
+
Code, OpenCode, Codex, Cursor, Claude Desktop, Gemini CLI; backs up existing
|
|
19
|
+
files to `.bak`):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx -y @thammarongg/jira-mcp install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or non-interactive: `npx -y @thammarongg/jira-mcp install --agents all
|
|
26
|
+
--base-url https://your-org.atlassian.net --email you@example.com --token xxx
|
|
27
|
+
--yes`. Required env vars:
|
|
18
28
|
|
|
19
29
|
- `JIRA_BASE_URL` — `https://your-org.atlassian.net` (Cloud) or
|
|
20
30
|
`https://jira.yourcompany.com` (DC)
|