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 CHANGED
@@ -23,12 +23,12 @@ bluntjira подключает Jira к AI-приложению через MCP.
23
23
  Открой терминал и выполни:
24
24
 
25
25
  ```bash
26
- npx -y bluntjira@0.1.0 login
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.1.0"]
42
+ "args": ["-y", "bluntjira@0.2.0"]
43
43
  }
44
44
  }
45
45
  }
46
46
  ```
47
47
 
48
- Расположение настроек зависит от приложения. Если оно предлагает отдельные поля вместо JSON, укажи `npx` в поле команды, а `-y` и `bluntjira@0.1.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
- Данные сессии хранятся в `~/.bluntjira/settings.json` и дают доступ к Jira. Не передавай этот файл другим и не добавляй его в Git.
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
- | Сессия истекла | Снова выполни `npx -y bluntjira@0.1.0 login`, затем переподключи сервер в AI-приложении |
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, loadJiraConfig } from "./jira-client.js";
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 config = loadJiraConfig(settings);
12
- const server = new McpServer({ name: "bluntjira", version: "0.1.0" });
13
- const jira = new JiraClient(config);
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`);
@@ -1,6 +1,6 @@
1
1
  import { setTimeout as delay } from "node:timers/promises";
2
2
  import { z } from "zod";
3
- import { sessionCookieSchema } from "./cookie.js";
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
- JIRA_COOKIE_NAME: sessionCookieSchema.shape.name,
23
- JIRA_COOKIE_VALUE: sessionCookieSchema.shape.value,
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
- this.#cookie = `${config.cookieName}=${config.cookieValue}`;
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.#config.cookieValue,
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;; attempt++) {
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 loadJiraConfig(settings) {
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
- cookieName: result.data.JIRA_COOKIE_NAME,
239
- cookieValue: result.data.JIRA_COOKIE_VALUE,
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 serializeSession(session) {
17
+ export function serializeCredentials(credentials) {
12
18
  return `${JSON.stringify({
13
- JIRA_URL: session.url,
14
- JIRA_COOKIE_NAME: session.cookieName,
15
- JIRA_COOKIE_VALUE: session.cookieValue,
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 saveSession(session, filePath = path.join(homedir(), ".bluntjira", "settings.json")) {
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, serializeSession(session), { mode: 0o600 });
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
- await saveSession(session);
128
- stdout.write(`Jira session saved to ${path.join(homedir(), ".bluntjira", "settings.json")} (permissions 600). Run login again after the session expires.\n`);
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.JIRA_COOKIE_NAME === "string" ? { JIRA_COOKIE_NAME: settings.JIRA_COOKIE_NAME } : {}),
35
- ...(typeof settings.JIRA_COOKIE_VALUE === "string" ? { JIRA_COOKIE_VALUE: settings.JIRA_COOKIE_VALUE } : {}),
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bluntjira",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Jira REST API v2 MCP server over stdio",
5
5
  "type": "module",
6
6
  "license": "MIT",