bluntjira 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 +7 -7
- package/dist/index.js +7 -6
- package/dist/jira-client.js +60 -11
- package/dist/login.js +16 -9
- package/dist/settings.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,12 +23,12 @@ bluntjira подключает Jira к AI-приложению через MCP.
|
|
|
23
23
|
Открой терминал и выполни:
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
npx -y bluntjira@0.
|
|
26
|
+
npx -y bluntjira@0.2.0 login
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
Введи адрес Jira, логин и пароль. Используй полный HTTPS-адрес, например `https://jira.example.com` или `https://jira.example.com/jira`. При вводе пароля символы не отображаются.
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
Логин и пароль сохраняются на компьютере в открытом виде, сервер входит в Jira автоматически и сам обновляет сессию, когда она истекает.
|
|
32
32
|
|
|
33
33
|
### 2. Добавь сервер в AI-приложение
|
|
34
34
|
|
|
@@ -39,13 +39,13 @@ npx -y bluntjira@0.1.0 login
|
|
|
39
39
|
"mcpServers": {
|
|
40
40
|
"bluntjira": {
|
|
41
41
|
"command": "npx",
|
|
42
|
-
"args": ["-y", "bluntjira@0.
|
|
42
|
+
"args": ["-y", "bluntjira@0.2.0"]
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
Расположение настроек зависит от приложения. Если оно предлагает отдельные поля вместо JSON, укажи `npx` в поле команды, а `-y` и `bluntjira@0.
|
|
48
|
+
Расположение настроек зависит от приложения. Если оно предлагает отдельные поля вместо JSON, укажи `npx` в поле команды, а `-y` и `bluntjira@0.2.0` в аргументах.
|
|
49
49
|
|
|
50
50
|
Перезапусти приложение или переподключи сервер в настройках MCP.
|
|
51
51
|
|
|
@@ -64,7 +64,7 @@ npx -y bluntjira@0.1.0 login
|
|
|
64
64
|
|
|
65
65
|
Помощник может изменять реальные задачи от твоего имени. Включи подтверждение действий в настройках AI-приложения, если оно это поддерживает. Если создание задачи или отправка комментария завершились ошибкой, проверь Jira перед повтором: действие могло успеть выполниться.
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
Логин и пароль хранятся в `~/.bluntjira/settings.json` (права 600) открытым текстом и дают доступ к Jira. Не передавай этот файл другим и не добавляй его в Git.
|
|
68
68
|
|
|
69
69
|
## Если не работает
|
|
70
70
|
|
|
@@ -72,11 +72,11 @@ npx -y bluntjira@0.1.0 login
|
|
|
72
72
|
|---|---|
|
|
73
73
|
| Команда `npx` не найдена | Установи Node.js 24+ с npm и перезапусти терминал и AI-приложение |
|
|
74
74
|
| Не удаётся войти | Проверь адрес, логин, пароль и подключение к корпоративной сети. При CAPTCHA сначала войди в Jira через браузер. Вход только через SSO не поддерживается |
|
|
75
|
-
|
|
|
75
|
+
| Ошибка входа (401) | Пароль изменился: снова выполни `npx -y bluntjira@0.2.0 login`, затем переподключи сервер в AI-приложении |
|
|
76
76
|
| Нет доступа к проекту или задаче | Проверь, доступны ли они твоей учётной записи в самой Jira |
|
|
77
77
|
| После ошибки доступа запросы приостановлены | Проверь вход, права и CAPTCHA. Пауза длится пять минут |
|
|
78
78
|
|
|
79
|
-
Если проблема остаётся, создай [issue](https://github.com/danillittle/bluntjira/issues). Укажи версию Node.js, AI-приложение и текст ошибки. Не прикладывай пароль или
|
|
79
|
+
Если проблема остаётся, создай [issue](https://github.com/danillittle/bluntjira/issues). Укажи версию Node.js, AI-приложение и текст ошибки. Не прикладывай пароль или содержимое settings.json.
|
|
80
80
|
|
|
81
81
|
## Для разработчиков
|
|
82
82
|
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
-
import { JiraClient,
|
|
4
|
+
import { JiraClient, loadJiraCredentials } from "./jira-client.js";
|
|
5
5
|
import { registerTools } from "./tools.js";
|
|
6
|
-
import { LoginError, runLogin } from "./login.js";
|
|
6
|
+
import { authenticateSession, LoginError, runLogin } from "./login.js";
|
|
7
7
|
import { readSettings, SettingsError } from "./settings.js";
|
|
8
8
|
// Source: https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.x/docs/server.md#stdio
|
|
9
9
|
async function main() {
|
|
10
10
|
const settings = await readSettings();
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
11
|
+
const credentials = loadJiraCredentials(settings);
|
|
12
|
+
const authenticate = () => authenticateSession(credentials.url, credentials.username, credentials.password);
|
|
13
|
+
const server = new McpServer({ name: "bluntjira", version: "0.2.0" });
|
|
14
|
+
const jira = new JiraClient({ url: credentials.url }, { reauth: authenticate });
|
|
14
15
|
registerTools(server, jira);
|
|
15
16
|
let stopping = false;
|
|
16
17
|
async function shutdown() {
|
|
@@ -53,7 +54,7 @@ if (process.argv[2] === "login") {
|
|
|
53
54
|
}
|
|
54
55
|
else {
|
|
55
56
|
main().catch((error) => {
|
|
56
|
-
const message = error instanceof SettingsError || (error instanceof Error && error.message.startsWith("Invalid Jira settings:"))
|
|
57
|
+
const message = error instanceof SettingsError || error instanceof LoginError || (error instanceof Error && error.message.startsWith("Invalid Jira settings:"))
|
|
57
58
|
? error.message
|
|
58
59
|
: "Unable to start bluntjira";
|
|
59
60
|
process.stderr.write(`${message}\n`);
|
package/dist/jira-client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { setTimeout as delay } from "node:timers/promises";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { LoginError } from "./login.js";
|
|
4
4
|
const nonEmptyString = z.string().refine((value) => value.trim().length > 0);
|
|
5
5
|
// URL parsing normalizes the path; keep Jira's context path when appending REST paths.
|
|
6
6
|
// Source: https://zod.dev/api#refine
|
|
@@ -19,8 +19,8 @@ const jiraUrl = nonEmptyString.refine((value) => {
|
|
|
19
19
|
}).transform((value) => new URL(value).href.replace(/\/+$/, ""));
|
|
20
20
|
const settingsSchema = z.object({
|
|
21
21
|
JIRA_URL: jiraUrl,
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
JIRA_USERNAME: nonEmptyString,
|
|
23
|
+
JIRA_PASSWORD: z.string().min(1),
|
|
24
24
|
});
|
|
25
25
|
const retryableStatuses = new Set([429, 502, 503, 504]);
|
|
26
26
|
const authCooldownMs = 5 * 60_000;
|
|
@@ -42,7 +42,13 @@ export class JiraClient {
|
|
|
42
42
|
#config;
|
|
43
43
|
#fetch;
|
|
44
44
|
#timeoutMs;
|
|
45
|
-
#cookie;
|
|
45
|
+
#cookie = "";
|
|
46
|
+
#cookieValue = "";
|
|
47
|
+
// Shared so concurrent expired requests trigger a single login.
|
|
48
|
+
#loginInFlight;
|
|
49
|
+
// Jira refused the credentials: stop retrying until restart to avoid CAPTCHA lockouts.
|
|
50
|
+
#loginRejected = false;
|
|
51
|
+
#reauth;
|
|
46
52
|
#activeRequests = new Set();
|
|
47
53
|
#closed = false;
|
|
48
54
|
#now;
|
|
@@ -55,7 +61,11 @@ export class JiraClient {
|
|
|
55
61
|
this.#timeoutMs = options.timeoutMs ?? 30_000;
|
|
56
62
|
this.#now = options.now ?? Date.now;
|
|
57
63
|
this.#wait = options.wait ?? ((ms, signal) => delay(ms, undefined, { signal }));
|
|
58
|
-
|
|
64
|
+
if (config.cookieName && config.cookieValue) {
|
|
65
|
+
this.#cookie = `${config.cookieName}=${config.cookieValue}`;
|
|
66
|
+
this.#cookieValue = config.cookieValue;
|
|
67
|
+
}
|
|
68
|
+
this.#reauth = options.reauth;
|
|
59
69
|
}
|
|
60
70
|
close() {
|
|
61
71
|
this.#closed = true;
|
|
@@ -103,7 +113,7 @@ export class JiraClient {
|
|
|
103
113
|
// Redact before truncating: a partial credential must not survive the limit.
|
|
104
114
|
const secrets = [
|
|
105
115
|
this.#cookie,
|
|
106
|
-
this.#
|
|
116
|
+
this.#cookieValue,
|
|
107
117
|
].filter(Boolean).sort((a, b) => b.length - a.length);
|
|
108
118
|
for (const secret of secrets) {
|
|
109
119
|
value = value.replaceAll(secret, "[REDACTED]");
|
|
@@ -145,6 +155,34 @@ export class JiraClient {
|
|
|
145
155
|
throw new JiraRequestError(`Jira requests paused for ${Math.ceil(remaining / 1000)} seconds after HTTP ${this.#authStatus}. Check credentials, permissions and CAPTCHA; no HTTP request was sent.`, "cooldown", this.#authStatus);
|
|
146
156
|
}
|
|
147
157
|
}
|
|
158
|
+
async #login(signal) {
|
|
159
|
+
signal.throwIfAborted();
|
|
160
|
+
if (this.#loginRejected || !this.#reauth) {
|
|
161
|
+
throw new JiraRequestError("Jira rejected the stored credentials. Run `bluntjira login` and restart the MCP server.", "http", 401);
|
|
162
|
+
}
|
|
163
|
+
const reauth = this.#reauth;
|
|
164
|
+
this.#loginInFlight ??= reauth().then((session) => {
|
|
165
|
+
this.#cookie = `${session.cookieName}=${session.cookieValue}`;
|
|
166
|
+
this.#cookieValue = session.cookieValue;
|
|
167
|
+
}).catch((error) => {
|
|
168
|
+
if (error instanceof LoginError && error.rejected)
|
|
169
|
+
this.#loginRejected = true;
|
|
170
|
+
throw error;
|
|
171
|
+
}).finally(() => { this.#loginInFlight = undefined; });
|
|
172
|
+
// shortcut: an aborted request stops waiting but the shared login runs on (<=30s), upgrade if that matters.
|
|
173
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
174
|
+
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
175
|
+
});
|
|
176
|
+
try {
|
|
177
|
+
await Promise.race([this.#loginInFlight, aborted]);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (signal.aborted)
|
|
181
|
+
throw error;
|
|
182
|
+
// "cooldown" kind: the request was never sent, so it must not be reported as an unknown write outcome.
|
|
183
|
+
throw new JiraRequestError(`Jira login failed: ${error instanceof LoginError ? error.message : "unknown error"}. Run \`bluntjira login\` if the credentials changed.`, "cooldown", error instanceof LoginError && error.rejected ? 401 : undefined);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
148
186
|
#retryDelay(attempt, response) {
|
|
149
187
|
const backoff = 1000 * 2 ** attempt + Math.floor(Math.random() * 251);
|
|
150
188
|
const value = response.headers.get("Retry-After");
|
|
@@ -159,21 +197,31 @@ export class JiraClient {
|
|
|
159
197
|
this.#activeRequests.add(controller);
|
|
160
198
|
const deadline = this.#now() + this.#timeoutMs;
|
|
161
199
|
const timeout = setTimeout(() => controller.abort(), this.#timeoutMs);
|
|
200
|
+
let reauthenticated = false;
|
|
162
201
|
try {
|
|
163
|
-
for (let attempt = 0;;
|
|
202
|
+
for (let attempt = 0;;) {
|
|
164
203
|
this.#assertAvailable();
|
|
165
204
|
controller.signal.throwIfAborted();
|
|
205
|
+
if (!this.#cookie && this.#reauth)
|
|
206
|
+
await this.#login(controller.signal);
|
|
166
207
|
const response = await this.#fetch(`${this.#config.url}/rest/api/2${path}`, {
|
|
167
208
|
method: options.method ?? "GET",
|
|
168
209
|
headers: {
|
|
169
210
|
Accept: "application/json",
|
|
170
|
-
Cookie: this.#cookie,
|
|
211
|
+
...(this.#cookie ? { Cookie: this.#cookie } : {}),
|
|
171
212
|
...(options.body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
172
213
|
},
|
|
173
214
|
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
|
174
215
|
redirect: "manual",
|
|
175
216
|
signal: controller.signal,
|
|
176
217
|
});
|
|
218
|
+
// Renew an expired session once per request; 401 means nothing was executed, so retrying is safe even for writes.
|
|
219
|
+
if (response.status === 401 && this.#reauth && !reauthenticated && !this.#loginRejected) {
|
|
220
|
+
reauthenticated = true;
|
|
221
|
+
void response.body?.cancel().catch(() => { });
|
|
222
|
+
await this.#login(controller.signal);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
177
225
|
// Pause immediately on headers, even if reading the error body later fails.
|
|
178
226
|
if (response.status === 401 || response.status === 403) {
|
|
179
227
|
this.#authStatus = response.status;
|
|
@@ -190,6 +238,7 @@ export class JiraClient {
|
|
|
190
238
|
if (options.write || !retryableStatuses.has(response.status) || attempt >= 2)
|
|
191
239
|
throw failure;
|
|
192
240
|
const waitMs = this.#retryDelay(attempt, response);
|
|
241
|
+
attempt++;
|
|
193
242
|
// Never retry earlier than Retry-After to fit our time budget.
|
|
194
243
|
if (waitMs >= deadline - this.#now())
|
|
195
244
|
throw failure;
|
|
@@ -226,7 +275,7 @@ export class JiraClient {
|
|
|
226
275
|
}
|
|
227
276
|
}
|
|
228
277
|
/** Validate stored Jira settings; never include their values in errors. */
|
|
229
|
-
export function
|
|
278
|
+
export function loadJiraCredentials(settings) {
|
|
230
279
|
const result = settingsSchema.safeParse(settings);
|
|
231
280
|
if (!result.success) {
|
|
232
281
|
// Raw Zod issues can include input values. Expose only known setting names.
|
|
@@ -235,7 +284,7 @@ export function loadJiraConfig(settings) {
|
|
|
235
284
|
}
|
|
236
285
|
return {
|
|
237
286
|
url: result.data.JIRA_URL,
|
|
238
|
-
|
|
239
|
-
|
|
287
|
+
username: result.data.JIRA_USERNAME,
|
|
288
|
+
password: result.data.JIRA_PASSWORD,
|
|
240
289
|
};
|
|
241
290
|
}
|
package/dist/login.js
CHANGED
|
@@ -7,12 +7,18 @@ import { StringDecoder } from "node:string_decoder";
|
|
|
7
7
|
import { sessionCookieSchema } from "./cookie.js";
|
|
8
8
|
// Only messages constructed by login itself are safe to print in the CLI.
|
|
9
9
|
export class LoginError extends Error {
|
|
10
|
+
rejected;
|
|
11
|
+
// True when Jira refused the credentials themselves (not a network or format problem).
|
|
12
|
+
constructor(message, rejected = false) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.rejected = rejected;
|
|
15
|
+
}
|
|
10
16
|
}
|
|
11
|
-
export function
|
|
17
|
+
export function serializeCredentials(credentials) {
|
|
12
18
|
return `${JSON.stringify({
|
|
13
|
-
JIRA_URL:
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
JIRA_URL: credentials.url,
|
|
20
|
+
JIRA_USERNAME: credentials.username,
|
|
21
|
+
JIRA_PASSWORD: credentials.password,
|
|
16
22
|
}, null, 2)}\n`;
|
|
17
23
|
}
|
|
18
24
|
export async function authenticateSession(url, user, password, fetcher = globalThis.fetch) {
|
|
@@ -40,7 +46,7 @@ export async function authenticateSession(url, user, password, fetcher = globalT
|
|
|
40
46
|
});
|
|
41
47
|
if (!response.ok) {
|
|
42
48
|
void response.body?.cancel().catch(() => { });
|
|
43
|
-
throw new LoginError(`Jira login failed (HTTP ${response.status})
|
|
49
|
+
throw new LoginError(`Jira login failed (HTTP ${response.status})`, response.status === 401 || response.status === 403);
|
|
44
50
|
}
|
|
45
51
|
let payload;
|
|
46
52
|
try {
|
|
@@ -70,11 +76,11 @@ export async function authenticateSession(url, user, password, fetcher = globalT
|
|
|
70
76
|
clearTimeout(timeout);
|
|
71
77
|
}
|
|
72
78
|
}
|
|
73
|
-
export async function
|
|
79
|
+
export async function saveCredentials(credentials, filePath = path.join(homedir(), ".bluntjira", "settings.json")) {
|
|
74
80
|
const directory = path.dirname(filePath);
|
|
75
81
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
76
82
|
await chmod(directory, 0o700);
|
|
77
|
-
await writeFile(filePath,
|
|
83
|
+
await writeFile(filePath, serializeCredentials(credentials), { mode: 0o600 });
|
|
78
84
|
await chmod(filePath, 0o600);
|
|
79
85
|
}
|
|
80
86
|
export async function readHidden(prompt, input = stdin, output = stdout) {
|
|
@@ -124,8 +130,9 @@ export async function runLogin() {
|
|
|
124
130
|
if (!url || !user || !password)
|
|
125
131
|
throw new LoginError("Jira URL, username, and password are required");
|
|
126
132
|
const session = await authenticateSession(url, user, password);
|
|
127
|
-
|
|
128
|
-
|
|
133
|
+
// Login is verified first so a typo never overwrites working settings.
|
|
134
|
+
await saveCredentials({ url: session.url, username: user, password });
|
|
135
|
+
stdout.write(`Jira credentials saved to ${path.join(homedir(), ".bluntjira", "settings.json")} (permissions 600). The server logs in automatically.\n`);
|
|
129
136
|
}
|
|
130
137
|
finally {
|
|
131
138
|
terminal.close();
|
package/dist/settings.js
CHANGED
|
@@ -31,7 +31,7 @@ export async function readSettings(filePath = settingsPath()) {
|
|
|
31
31
|
const settings = parsed;
|
|
32
32
|
return {
|
|
33
33
|
...(typeof settings.JIRA_URL === "string" ? { JIRA_URL: settings.JIRA_URL } : {}),
|
|
34
|
-
...(typeof settings.
|
|
35
|
-
...(typeof settings.
|
|
34
|
+
...(typeof settings.JIRA_USERNAME === "string" ? { JIRA_USERNAME: settings.JIRA_USERNAME } : {}),
|
|
35
|
+
...(typeof settings.JIRA_PASSWORD === "string" ? { JIRA_PASSWORD: settings.JIRA_PASSWORD } : {}),
|
|
36
36
|
};
|
|
37
37
|
}
|