bluntjira 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Danil Shagimardanov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,99 @@
1
- # Temporary Holding Version
1
+ <h1 align="center">bluntjira</h1>
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ <p align="center">
4
+ <strong>Jira Server и Data Center в твоём AI-клиенте.</strong><br>
5
+ Ищи задачи, добавляй комментарии и меняй статусы прямо в чате.
6
+ </p>
7
+
8
+ <p align="center">
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="Лицензия MIT"></a>
10
+ <a href="https://nodejs.org/"><img src="https://img.shields.io/badge/Node.js-24%2B-339933?style=for-the-badge&amp;logo=node.js&amp;logoColor=white" alt="Node.js 24 или новее"></a>
11
+ </p>
12
+
13
+ bluntjira подключает Jira к AI-приложению через MCP. После подключения можно просить помощника найти задачу, прочитать обсуждение, создать новую задачу или перевести её в другой статус. Доступ к проектам и задачам остаётся таким же, как у твоей учётной записи Jira.
14
+
15
+ ## Подключение
16
+
17
+ Понадобятся [Node.js 24 или новее](https://nodejs.org/) с npm и AI-приложение, которое поддерживает локальные MCP-серверы (stdio).
18
+
19
+ Поддерживаются Jira Server и Data Center с входом по логину и паролю. Jira Cloud и вход через браузерный SSO не поддерживаются. Если Jira доступна только в корпоративной сети, сначала подключись к ней или включи VPN.
20
+
21
+ ### 1. Войди в Jira
22
+
23
+ Открой терминал и выполни:
24
+
25
+ ```bash
26
+ npx -y bluntjira@0.2.0 login
27
+ ```
28
+
29
+ Введи адрес Jira, логин и пароль. Используй полный HTTPS-адрес, например `https://jira.example.com` или `https://jira.example.com/jira`. При вводе пароля символы не отображаются.
30
+
31
+ Логин и пароль сохраняются на компьютере в открытом виде, сервер входит в Jira автоматически и сам обновляет сессию, когда она истекает.
32
+
33
+ ### 2. Добавь сервер в AI-приложение
34
+
35
+ Открой настройки MCP в своём приложении и добавь:
36
+
37
+ ```json
38
+ {
39
+ "mcpServers": {
40
+ "bluntjira": {
41
+ "command": "npx",
42
+ "args": ["-y", "bluntjira@0.2.0"]
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ Расположение настроек зависит от приложения. Если оно предлагает отдельные поля вместо JSON, укажи `npx` в поле команды, а `-y` и `bluntjira@0.2.0` в аргументах.
49
+
50
+ Перезапусти приложение или переподключи сервер в настройках MCP.
51
+
52
+ ### 3. Попробуй в чате
53
+
54
+ - «Покажи доступные проекты Jira».
55
+ - «Найди мои незакрытые задачи».
56
+ - «Покажи описание и комментарии к PROJ-123».
57
+ - «Создай задачу в проекте PROJ: исправить ошибку входа».
58
+ - «Добавь к PROJ-123 комментарий: исправление готово к проверке».
59
+ - «Какие статусы доступны для PROJ-123?»
60
+
61
+ Замени `PROJ` и `PROJ-123` на свой проект и задачу. Для создания задачи помощник может запросить её тип и другие обязательные поля.
62
+
63
+ ## Что важно знать
64
+
65
+ Помощник может изменять реальные задачи от твоего имени. Включи подтверждение действий в настройках AI-приложения, если оно это поддерживает. Если создание задачи или отправка комментария завершились ошибкой, проверь Jira перед повтором: действие могло успеть выполниться.
66
+
67
+ Логин и пароль хранятся в `~/.bluntjira/settings.json` (права 600) открытым текстом и дают доступ к Jira. Не передавай этот файл другим и не добавляй его в Git.
68
+
69
+ ## Если не работает
70
+
71
+ | Проблема | Что сделать |
72
+ |---|---|
73
+ | Команда `npx` не найдена | Установи Node.js 24+ с npm и перезапусти терминал и AI-приложение |
74
+ | Не удаётся войти | Проверь адрес, логин, пароль и подключение к корпоративной сети. При CAPTCHA сначала войди в Jira через браузер. Вход только через SSO не поддерживается |
75
+ | Ошибка входа (401) | Пароль изменился: снова выполни `npx -y bluntjira@0.2.0 login`, затем переподключи сервер в AI-приложении |
76
+ | Нет доступа к проекту или задаче | Проверь, доступны ли они твоей учётной записи в самой Jira |
77
+ | После ошибки доступа запросы приостановлены | Проверь вход, права и CAPTCHA. Пауза длится пять минут |
78
+
79
+ Если проблема остаётся, создай [issue](https://github.com/danillittle/bluntjira/issues). Укажи версию Node.js, AI-приложение и текст ошибки. Не прикладывай пароль или содержимое settings.json.
80
+
81
+ ## Для разработчиков
82
+
83
+ ### Установка из Git
84
+
85
+ ```bash
86
+ git clone https://github.com/danillittle/bluntjira.git
87
+ cd bluntjira
88
+ npm ci
89
+ npm run build
90
+ node dist/index.js login
91
+ ```
92
+
93
+ Для подключения локальной сборки используй команду `node` с аргументом `/absolute/path/to/bluntjira/dist/index.js`, указав свой абсолютный путь.
94
+
95
+ [Разработка и проверки](https://github.com/danillittle/bluntjira/blob/master/docs/development.md) · [Подготовка релиза](https://github.com/danillittle/bluntjira/blob/master/docs/releasing.md) · [История изменений](https://github.com/danillittle/bluntjira/blob/master/CHANGELOG.md)
96
+
97
+ ## Лицензия
98
+
99
+ [MIT](LICENSE) © 2026 Danil Shagimardanov.
package/dist/cookie.js ADDED
@@ -0,0 +1,6 @@
1
+ import { z } from "zod";
2
+ // The login response and server configuration must accept the same cookie format.
3
+ export const sessionCookieSchema = z.object({
4
+ name: z.string().regex(/^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/),
5
+ value: z.string().min(1).refine((value) => [...value].every((char) => char.charCodeAt(0) > 32 && char.charCodeAt(0) < 127 && char !== ";")),
6
+ });
package/dist/index.js ADDED
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { JiraClient, loadJiraCredentials } from "./jira-client.js";
5
+ import { registerTools } from "./tools.js";
6
+ import { authenticateSession, LoginError, runLogin } from "./login.js";
7
+ import { readSettings, SettingsError } from "./settings.js";
8
+ // Source: https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.x/docs/server.md#stdio
9
+ async function main() {
10
+ const settings = await readSettings();
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 });
15
+ registerTools(server, jira);
16
+ let stopping = false;
17
+ async function shutdown() {
18
+ if (stopping)
19
+ return;
20
+ stopping = true;
21
+ process.off("SIGINT", stop);
22
+ process.off("SIGTERM", stop);
23
+ process.stdin.off("end", stop);
24
+ process.stdin.off("close", stop);
25
+ jira.close();
26
+ await server.close();
27
+ }
28
+ function stop() {
29
+ void shutdown().catch(() => {
30
+ process.stderr.write("Unable to close bluntjira cleanly\n");
31
+ process.exitCode = 1;
32
+ });
33
+ }
34
+ process.once("SIGINT", stop);
35
+ process.once("SIGTERM", stop);
36
+ // SDK stdio handles data/errors, but EOF needs an explicit shutdown hook.
37
+ process.stdin.once("end", stop);
38
+ process.stdin.once("close", stop);
39
+ server.server.onclose = stop;
40
+ try {
41
+ await server.connect(new StdioServerTransport());
42
+ }
43
+ catch (error) {
44
+ await shutdown();
45
+ throw error;
46
+ }
47
+ }
48
+ if (process.argv[2] === "login") {
49
+ runLogin().catch((error) => {
50
+ const message = error instanceof LoginError ? error.message : "Unable to complete Jira login";
51
+ process.stderr.write(`${message}\n`);
52
+ process.exitCode = 1;
53
+ });
54
+ }
55
+ else {
56
+ main().catch((error) => {
57
+ const message = error instanceof SettingsError || error instanceof LoginError || (error instanceof Error && error.message.startsWith("Invalid Jira settings:"))
58
+ ? error.message
59
+ : "Unable to start bluntjira";
60
+ process.stderr.write(`${message}\n`);
61
+ process.exitCode = 1;
62
+ });
63
+ }
@@ -0,0 +1,290 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+ import { z } from "zod";
3
+ import { LoginError } from "./login.js";
4
+ const nonEmptyString = z.string().refine((value) => value.trim().length > 0);
5
+ // URL parsing normalizes the path; keep Jira's context path when appending REST paths.
6
+ // Source: https://zod.dev/api#refine
7
+ const jiraUrl = nonEmptyString.refine((value) => {
8
+ try {
9
+ const url = new URL(value);
10
+ return url.protocol === "https:"
11
+ && url.username === ""
12
+ && url.password === ""
13
+ && !value.includes("?")
14
+ && !value.includes("#");
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }).transform((value) => new URL(value).href.replace(/\/+$/, ""));
20
+ const settingsSchema = z.object({
21
+ JIRA_URL: jiraUrl,
22
+ JIRA_USERNAME: nonEmptyString,
23
+ JIRA_PASSWORD: z.string().min(1),
24
+ });
25
+ const retryableStatuses = new Set([429, 502, 503, 504]);
26
+ const authCooldownMs = 5 * 60_000;
27
+ export class JiraRequestError extends Error {
28
+ kind;
29
+ status;
30
+ constructor(message, kind, status) {
31
+ super(message);
32
+ this.kind = kind;
33
+ this.status = status;
34
+ this.name = "JiraRequestError";
35
+ }
36
+ }
37
+ const jiraErrorSchema = z.object({
38
+ errorMessages: z.array(z.string()).optional(),
39
+ errors: z.record(z.string(), z.string()).optional(),
40
+ });
41
+ export class JiraClient {
42
+ #config;
43
+ #fetch;
44
+ #timeoutMs;
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;
52
+ #activeRequests = new Set();
53
+ #closed = false;
54
+ #now;
55
+ #wait;
56
+ #authBlockedUntil = 0;
57
+ #authStatus;
58
+ constructor(config, options = {}) {
59
+ this.#config = config;
60
+ this.#fetch = options.fetch ?? globalThis.fetch;
61
+ this.#timeoutMs = options.timeoutMs ?? 30_000;
62
+ this.#now = options.now ?? Date.now;
63
+ this.#wait = options.wait ?? ((ms, signal) => delay(ms, undefined, { signal }));
64
+ if (config.cookieName && config.cookieValue) {
65
+ this.#cookie = `${config.cookieName}=${config.cookieValue}`;
66
+ this.#cookieValue = config.cookieValue;
67
+ }
68
+ this.#reauth = options.reauth;
69
+ }
70
+ close() {
71
+ this.#closed = true;
72
+ for (const controller of this.#activeRequests)
73
+ controller.abort();
74
+ }
75
+ getProjects() {
76
+ return this.#request("/project");
77
+ }
78
+ getIssue(issueKey, fields) {
79
+ const query = fields === undefined ? "" : `?${new URLSearchParams({ fields: fields.join(",") })}`;
80
+ return this.#request(`/issue/${encodeURIComponent(issueKey)}${query}`);
81
+ }
82
+ searchIssues(input) {
83
+ return this.#request("/search", { method: "POST", body: input });
84
+ }
85
+ createIssue(fields) {
86
+ return this.#request("/issue", { method: "POST", body: { fields }, write: true });
87
+ }
88
+ updateIssue(issueKey, fields) {
89
+ return this.#request(`/issue/${encodeURIComponent(issueKey)}`, {
90
+ method: "PUT", body: { fields }, write: true,
91
+ });
92
+ }
93
+ getComments(issueKey, startAt, maxResults) {
94
+ const query = new URLSearchParams({ startAt: String(startAt), maxResults: String(maxResults) });
95
+ return this.#request(`/issue/${encodeURIComponent(issueKey)}/comment?${query}`);
96
+ }
97
+ addComment(issueKey, body) {
98
+ return this.#request(`/issue/${encodeURIComponent(issueKey)}/comment`, {
99
+ method: "POST", body: { body }, write: true,
100
+ });
101
+ }
102
+ getTransitions(issueKey) {
103
+ return this.#request(`/issue/${encodeURIComponent(issueKey)}/transitions?expand=transitions.fields`);
104
+ }
105
+ transitionIssue(issueKey, transitionId, fields) {
106
+ return this.#request(`/issue/${encodeURIComponent(issueKey)}/transitions`, {
107
+ method: "POST",
108
+ body: { transition: { id: transitionId }, ...(fields === undefined ? {} : { fields }) },
109
+ write: true,
110
+ });
111
+ }
112
+ #safeText(value) {
113
+ // Redact before truncating: a partial credential must not survive the limit.
114
+ const secrets = [
115
+ this.#cookie,
116
+ this.#cookieValue,
117
+ ].filter(Boolean).sort((a, b) => b.length - a.length);
118
+ for (const secret of secrets) {
119
+ value = value.replaceAll(secret, "[REDACTED]");
120
+ }
121
+ // Replace ASCII control characters only; preserve non-ASCII error text.
122
+ return value.replace(/(?=\p{ASCII})\p{Cc}/gu, " ").slice(0, 4096);
123
+ }
124
+ #httpError(response, text) {
125
+ let message = `Jira HTTP ${response.status}`;
126
+ try {
127
+ const parsed = jiraErrorSchema.safeParse(JSON.parse(text));
128
+ if (parsed.success) {
129
+ const details = [
130
+ ...parsed.data.errorMessages ?? [],
131
+ ...Object.entries(parsed.data.errors ?? {}).map(([key, value]) => `${key}: ${value}`),
132
+ ];
133
+ if (details.length > 0)
134
+ message += `: ${details.join("; ")}`;
135
+ }
136
+ }
137
+ catch {
138
+ // HTML login pages and malformed JSON are never returned to the client.
139
+ }
140
+ const retryAfter = response.headers.get("Retry-After");
141
+ if (retryableStatuses.has(response.status) && retryAfter)
142
+ message += `; Retry-After: ${retryAfter}`;
143
+ if (response.status === 401 || response.status === 403) {
144
+ message += response.status === 401
145
+ ? "; Jira session may have expired. Run `bluntjira login` and restart the MCP server."
146
+ : "; Requests paused for 300 seconds. Check Jira permissions and CAPTCHA.";
147
+ }
148
+ return new JiraRequestError(this.#safeText(message), "http", response.status);
149
+ }
150
+ #assertAvailable() {
151
+ if (this.#closed)
152
+ throw new JiraRequestError("Jira client is closed", "cancelled");
153
+ const remaining = this.#authBlockedUntil - this.#now();
154
+ if (remaining > 0) {
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);
156
+ }
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
+ }
186
+ #retryDelay(attempt, response) {
187
+ const backoff = 1000 * 2 ** attempt + Math.floor(Math.random() * 251);
188
+ const value = response.headers.get("Retry-After");
189
+ if (value === null)
190
+ return backoff;
191
+ const retryAfter = /^\d+$/.test(value) ? Number(value) * 1000 : Date.parse(value) - this.#now();
192
+ return Number.isNaN(retryAfter) ? backoff : Math.max(backoff, retryAfter);
193
+ }
194
+ async #request(path, options = {}) {
195
+ this.#assertAvailable();
196
+ const controller = new AbortController();
197
+ this.#activeRequests.add(controller);
198
+ const deadline = this.#now() + this.#timeoutMs;
199
+ const timeout = setTimeout(() => controller.abort(), this.#timeoutMs);
200
+ let reauthenticated = false;
201
+ try {
202
+ for (let attempt = 0;;) {
203
+ this.#assertAvailable();
204
+ controller.signal.throwIfAborted();
205
+ if (!this.#cookie && this.#reauth)
206
+ await this.#login(controller.signal);
207
+ const response = await this.#fetch(`${this.#config.url}/rest/api/2${path}`, {
208
+ method: options.method ?? "GET",
209
+ headers: {
210
+ Accept: "application/json",
211
+ ...(this.#cookie ? { Cookie: this.#cookie } : {}),
212
+ ...(options.body === undefined ? {} : { "Content-Type": "application/json" }),
213
+ },
214
+ ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
215
+ redirect: "manual",
216
+ signal: controller.signal,
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
+ }
225
+ // Pause immediately on headers, even if reading the error body later fails.
226
+ if (response.status === 401 || response.status === 403) {
227
+ this.#authStatus = response.status;
228
+ this.#authBlockedUntil = this.#now() + authCooldownMs;
229
+ }
230
+ if (response.status >= 300 && response.status < 400) {
231
+ await response.body?.cancel();
232
+ throw new JiraRequestError("Jira redirect refused; check JIRA_URL", "redirect", response.status);
233
+ }
234
+ // One timeout covers all attempts, response bodies and backoff waits.
235
+ const text = await response.text();
236
+ if (!response.ok) {
237
+ const failure = this.#httpError(response, text);
238
+ if (options.write || !retryableStatuses.has(response.status) || attempt >= 2)
239
+ throw failure;
240
+ const waitMs = this.#retryDelay(attempt, response);
241
+ attempt++;
242
+ // Never retry earlier than Retry-After to fit our time budget.
243
+ if (waitMs >= deadline - this.#now())
244
+ throw failure;
245
+ await this.#wait(waitMs, controller.signal);
246
+ continue;
247
+ }
248
+ if (text.trim() === "")
249
+ return { success: true };
250
+ try {
251
+ return JSON.parse(text);
252
+ }
253
+ catch {
254
+ throw new JiraRequestError("Jira returned an invalid JSON response", "format");
255
+ }
256
+ }
257
+ }
258
+ catch (error) {
259
+ // Never attach a cause: transport errors may contain URLs or credentials.
260
+ const failure = error instanceof JiraRequestError ? error
261
+ : this.#closed ? new JiraRequestError("Jira request cancelled", "cancelled")
262
+ : controller.signal.aborted ? new JiraRequestError("Jira request timed out", "timeout")
263
+ : new JiraRequestError("Unable to reach Jira", "network");
264
+ // POST is not sufficient to infer writes: JQL search is a read-only POST.
265
+ if (options.write && (failure.kind === "network" || failure.kind === "timeout"
266
+ || failure.kind === "format" || failure.kind === "cancelled" || (failure.status ?? 0) >= 500)) {
267
+ throw new JiraRequestError(`${failure.message}. Write outcome is unknown; check Jira before retrying.`, failure.kind, failure.status);
268
+ }
269
+ throw failure;
270
+ }
271
+ finally {
272
+ this.#activeRequests.delete(controller);
273
+ clearTimeout(timeout);
274
+ }
275
+ }
276
+ }
277
+ /** Validate stored Jira settings; never include their values in errors. */
278
+ export function loadJiraCredentials(settings) {
279
+ const result = settingsSchema.safeParse(settings);
280
+ if (!result.success) {
281
+ // Raw Zod issues can include input values. Expose only known setting names.
282
+ const names = [...new Set(result.error.issues.map((issue) => String(issue.path[0])))];
283
+ throw new Error(`Invalid Jira settings: ${names.join(", ")}. Run the login command again.`);
284
+ }
285
+ return {
286
+ url: result.data.JIRA_URL,
287
+ username: result.data.JIRA_USERNAME,
288
+ password: result.data.JIRA_PASSWORD,
289
+ };
290
+ }
package/dist/login.js ADDED
@@ -0,0 +1,140 @@
1
+ import { chmod, mkdir, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { stdin, stdout } from "node:process";
6
+ import { StringDecoder } from "node:string_decoder";
7
+ import { sessionCookieSchema } from "./cookie.js";
8
+ // Only messages constructed by login itself are safe to print in the CLI.
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
+ }
16
+ }
17
+ export function serializeCredentials(credentials) {
18
+ return `${JSON.stringify({
19
+ JIRA_URL: credentials.url,
20
+ JIRA_USERNAME: credentials.username,
21
+ JIRA_PASSWORD: credentials.password,
22
+ }, null, 2)}\n`;
23
+ }
24
+ export async function authenticateSession(url, user, password, fetcher = globalThis.fetch) {
25
+ let parsedUrl;
26
+ try {
27
+ parsedUrl = new URL(url);
28
+ }
29
+ catch {
30
+ throw new LoginError("Jira URL must be a valid HTTPS URL");
31
+ }
32
+ if (parsedUrl.protocol !== "https:" || parsedUrl.username || parsedUrl.password || parsedUrl.search || parsedUrl.hash) {
33
+ throw new LoginError("Jira URL must be HTTPS and must not contain credentials, query, or fragment");
34
+ }
35
+ const baseUrl = parsedUrl.href.replace(/\/+$/, "");
36
+ const controller = new AbortController();
37
+ // One deadline includes both receiving headers and consuming the response body.
38
+ const timeout = setTimeout(() => controller.abort(), 30_000);
39
+ try {
40
+ const response = await fetcher(`${baseUrl}/rest/auth/1/session`, {
41
+ method: "POST",
42
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
43
+ body: JSON.stringify({ username: user, password }),
44
+ redirect: "manual",
45
+ signal: controller.signal,
46
+ });
47
+ if (!response.ok) {
48
+ void response.body?.cancel().catch(() => { });
49
+ throw new LoginError(`Jira login failed (HTTP ${response.status})`, response.status === 401 || response.status === 403);
50
+ }
51
+ let payload;
52
+ try {
53
+ payload = await response.json();
54
+ }
55
+ catch {
56
+ throw new LoginError("Unable to read a valid Jira login response");
57
+ }
58
+ if (!payload || typeof payload !== "object" || !("session" in payload)) {
59
+ throw new LoginError("Jira login response did not include a session cookie");
60
+ }
61
+ const session = sessionCookieSchema.safeParse(payload.session);
62
+ if (!session.success) {
63
+ throw new LoginError("Jira login response contained an invalid session cookie");
64
+ }
65
+ return { url: baseUrl, cookieName: session.data.name, cookieValue: session.data.value };
66
+ }
67
+ catch (error) {
68
+ if (controller.signal.aborted)
69
+ throw new LoginError("Jira login timed out");
70
+ if (error instanceof LoginError)
71
+ throw error;
72
+ // Original transport/parser exceptions may include credentials or response data.
73
+ throw new LoginError("Unable to reach Jira for login");
74
+ }
75
+ finally {
76
+ clearTimeout(timeout);
77
+ }
78
+ }
79
+ export async function saveCredentials(credentials, filePath = path.join(homedir(), ".bluntjira", "settings.json")) {
80
+ const directory = path.dirname(filePath);
81
+ await mkdir(directory, { recursive: true, mode: 0o700 });
82
+ await chmod(directory, 0o700);
83
+ await writeFile(filePath, serializeCredentials(credentials), { mode: 0o600 });
84
+ await chmod(filePath, 0o600);
85
+ }
86
+ export async function readHidden(prompt, input = stdin, output = stdout) {
87
+ if (!input.isTTY || typeof input.setRawMode !== "function")
88
+ throw new LoginError("Login requires an interactive terminal");
89
+ input.setRawMode(true);
90
+ return await new Promise((resolve, reject) => {
91
+ const characters = [];
92
+ // A UTF-8 character can span data events; iterate decoded code points, not bytes.
93
+ const decoder = new StringDecoder("utf8");
94
+ const onData = (chunk) => {
95
+ for (const character of decoder.write(chunk)) {
96
+ if (character === "\u0003") {
97
+ cleanup();
98
+ output.write("\n");
99
+ reject(new LoginError("Login cancelled"));
100
+ return;
101
+ }
102
+ if (character === "\r" || character === "\n") {
103
+ cleanup();
104
+ output.write("\n");
105
+ resolve(characters.join(""));
106
+ return;
107
+ }
108
+ if (character === "\u007f" || character === "\b")
109
+ characters.pop();
110
+ else if (character >= " ")
111
+ characters.push(character);
112
+ }
113
+ };
114
+ const cleanup = () => { input.off("data", onData); input.setRawMode?.(false); input.pause(); };
115
+ input.on("data", onData);
116
+ input.resume();
117
+ // Display the prompt only after echo is disabled and the input handler is ready.
118
+ output.write(prompt);
119
+ });
120
+ }
121
+ export async function runLogin() {
122
+ if (!stdin.isTTY || !stdout.isTTY)
123
+ throw new LoginError("Login requires an interactive terminal");
124
+ const terminal = createInterface({ input: stdin, output: stdout });
125
+ try {
126
+ const url = (await terminal.question("Jira URL: ")).trim();
127
+ const user = (await terminal.question("Jira username: ")).trim();
128
+ terminal.close();
129
+ const password = await readHidden("Jira password (input hidden): ");
130
+ if (!url || !user || !password)
131
+ throw new LoginError("Jira URL, username, and password are required");
132
+ const session = await authenticateSession(url, user, password);
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`);
136
+ }
137
+ finally {
138
+ terminal.close();
139
+ }
140
+ }
@@ -0,0 +1,37 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ // Only controlled messages from settings loading may be printed by the CLI.
5
+ export class SettingsError extends Error {
6
+ }
7
+ export function settingsPath() {
8
+ return path.join(homedir(), ".bluntjira", "settings.json");
9
+ }
10
+ export async function readSettings(filePath = settingsPath()) {
11
+ let contents;
12
+ try {
13
+ contents = await readFile(filePath, "utf8");
14
+ }
15
+ catch (error) {
16
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
17
+ throw new SettingsError("Jira settings file not found. Run the login command first.");
18
+ }
19
+ throw new SettingsError("Unable to read Jira settings file. Check file access and run the login command again.");
20
+ }
21
+ let parsed;
22
+ try {
23
+ parsed = JSON.parse(contents);
24
+ }
25
+ catch {
26
+ throw new SettingsError("Invalid Jira settings JSON. Run the login command again.");
27
+ }
28
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
29
+ throw new SettingsError("Invalid Jira settings JSON. Run the login command again.");
30
+ }
31
+ const settings = parsed;
32
+ return {
33
+ ...(typeof settings.JIRA_URL === "string" ? { JIRA_URL: settings.JIRA_URL } : {}),
34
+ ...(typeof settings.JIRA_USERNAME === "string" ? { JIRA_USERNAME: settings.JIRA_USERNAME } : {}),
35
+ ...(typeof settings.JIRA_PASSWORD === "string" ? { JIRA_PASSWORD: settings.JIRA_PASSWORD } : {}),
36
+ };
37
+ }
package/dist/tools.js ADDED
@@ -0,0 +1,82 @@
1
+ import { z } from "zod";
2
+ import { JiraRequestError } from "./jira-client.js";
3
+ const nonEmptyString = z.string().refine((value) => value.trim().length > 0);
4
+ // Dot segments are not Jira identifiers and must not normalize into a different REST path.
5
+ const identifier = nonEmptyString.refine((value) => value !== "." && value !== "..");
6
+ const fieldNames = z.array(nonEmptyString).optional();
7
+ const pagination = {
8
+ startAt: z.number().int().min(0).default(0),
9
+ maxResults: z.number().int().min(1).max(100).default(50),
10
+ };
11
+ const readAnnotations = { readOnlyHint: true, destructiveHint: false, openWorldHint: true };
12
+ const writeAnnotations = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true };
13
+ const jsonFields = z.record(z.string(), z.json());
14
+ const createFields = z.object({
15
+ project: z.object({ key: identifier.optional(), id: identifier.optional() }).catchall(z.json())
16
+ .refine((value) => value.key !== undefined || value.id !== undefined, "Project key or id is required"),
17
+ summary: nonEmptyString,
18
+ issuetype: z.object({ id: identifier.optional(), name: nonEmptyString.optional() }).catchall(z.json())
19
+ .refine((value) => value.id !== undefined || value.name !== undefined, "Issue type id or name is required"),
20
+ }).catchall(z.json());
21
+ const updateFields = jsonFields
22
+ .refine((value) => Object.keys(value).length > 0, "At least one field is required")
23
+ .refine((value) => !Object.hasOwn(value, "status"), "Use transition_issue to change status");
24
+ async function toolResult(operation) {
25
+ try {
26
+ const result = await operation();
27
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
28
+ }
29
+ catch (error) {
30
+ const message = error instanceof JiraRequestError ? error.message : "Jira request failed";
31
+ return { isError: true, content: [{ type: "text", text: message }] };
32
+ }
33
+ }
34
+ // Contracts: https://github.com/modelcontextprotocol/typescript-sdk/blob/v1.x/docs/server.md
35
+ // Jira REST v2: https://docs.atlassian.com/software/jira/docs/api/REST/9.12.0/
36
+ export function registerTools(server, jira) {
37
+ server.registerTool("get_projects", {
38
+ description: "List Jira projects visible to the configured user.",
39
+ inputSchema: z.strictObject({}),
40
+ annotations: readAnnotations,
41
+ }, () => toolResult(() => jira.getProjects()));
42
+ server.registerTool("get_issue", {
43
+ description: "Read a Jira issue by key or numeric ID, optionally selecting fields.",
44
+ inputSchema: z.strictObject({ issueKey: identifier, fields: fieldNames }),
45
+ annotations: readAnnotations,
46
+ }, ({ issueKey, fields }) => toolResult(() => jira.getIssue(issueKey, fields)));
47
+ server.registerTool("search_issues", {
48
+ description: "Search Jira with JQL. Returns one page and Jira pagination metadata; no automatic page traversal.",
49
+ inputSchema: z.strictObject({ jql: nonEmptyString, fields: fieldNames, ...pagination }),
50
+ annotations: readAnnotations,
51
+ }, (input) => toolResult(() => jira.searchIssues(input)));
52
+ server.registerTool("create_issue", {
53
+ description: "Create a Jira issue using fields: project (key or id), summary, issuetype (id or name), plus custom fields required by the project. Writes to Jira; do not blindly retry an unknown outcome.",
54
+ inputSchema: z.strictObject({ fields: createFields }),
55
+ annotations: writeAnnotations,
56
+ }, ({ fields }) => toolResult(() => jira.createIssue(fields)));
57
+ server.registerTool("update_issue", {
58
+ description: "Update issue fields, preserving custom fields and null values. Use transition_issue for status changes. Writes to Jira.",
59
+ inputSchema: z.strictObject({ issueKey: identifier, fields: updateFields }),
60
+ annotations: writeAnnotations,
61
+ }, ({ issueKey, fields }) => toolResult(() => jira.updateIssue(issueKey, fields)));
62
+ server.registerTool("get_comments", {
63
+ description: "Read one page of issue comments with Jira pagination metadata. Text is returned unchanged.",
64
+ inputSchema: z.strictObject({ issueKey: identifier, ...pagination }),
65
+ annotations: readAnnotations,
66
+ }, ({ issueKey, startAt, maxResults }) => toolResult(() => jira.getComments(issueKey, startAt, maxResults)));
67
+ server.registerTool("add_comment", {
68
+ description: "Add a comment as a REST API v2 text string (Jira wiki markup, not ADF). Writes to Jira.",
69
+ inputSchema: z.strictObject({ issueKey: identifier, body: nonEmptyString }),
70
+ annotations: writeAnnotations,
71
+ }, ({ issueKey, body }) => toolResult(() => jira.addComment(issueKey, body)));
72
+ server.registerTool("get_transitions", {
73
+ description: "List transitions available to the current user, including required transition field metadata.",
74
+ inputSchema: z.strictObject({ issueKey: identifier }),
75
+ annotations: readAnnotations,
76
+ }, ({ issueKey }) => toolResult(() => jira.getTransitions(issueKey)));
77
+ server.registerTool("transition_issue", {
78
+ description: "Change status using a transitionId from get_transitions. Include fields required by the transition screen. Writes to Jira; never directly updates status.",
79
+ inputSchema: z.strictObject({ issueKey: identifier, transitionId: identifier, fields: jsonFields.optional() }),
80
+ annotations: writeAnnotations,
81
+ }, ({ issueKey, transitionId, fields }) => toolResult(() => jira.transitionIssue(issueKey, transitionId, fields)));
82
+ }
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "bluntjira",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Jira REST API v2 MCP server over stdio",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Danil Shagimardanov",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/danillittle/bluntjira.git"
11
+ },
12
+ "homepage": "https://github.com/danillittle/bluntjira#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/danillittle/bluntjira/issues"
15
+ },
16
+ "keywords": ["jira", "mcp", "model-context-protocol", "stdio"],
17
+ "publishConfig": {
18
+ "access": "public",
19
+ "registry": "https://registry.npmjs.org/"
20
+ },
21
+ "engines": {
22
+ "node": ">=24"
23
+ },
24
+ "bin": {
25
+ "bluntjira": "dist/index.js"
26
+ },
27
+ "files": [
28
+ "dist/cookie.js",
29
+ "dist/index.js",
30
+ "dist/jira-client.js",
31
+ "dist/login.js",
32
+ "dist/settings.js",
33
+ "dist/tools.js",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.json",
39
+ "lint": "oxlint --max-warnings=0 src tests",
40
+ "typecheck": "tsc -p tsconfig.test.json --noEmit",
41
+ "test": "tsc -p tsconfig.test.json && node --test .test-dist/tests/*.test.js",
42
+ "start": "node dist/index.js",
43
+ "dev": "npm run build && node dist/index.js",
44
+ "check": "npm run lint && npm run typecheck && npm test && npm run build",
45
+ "prepublishOnly": "npm run check",
46
+ "prepack": "npm run build"
47
+ },
48
+ "dependencies": {
49
+ "@modelcontextprotocol/sdk": "1.32.1",
50
+ "zod": "4.6.7"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "24.19.2",
54
+ "oxlint": "1.88.0",
55
+ "typescript": "7.0.2"
56
+ }
57
+ }