@levinariy.fedorov/youtrack-mcp 0.1.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 +21 -0
- package/README.md +94 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +100 -0
- package/dist/client.d.ts +26 -0
- package/dist/client.js +91 -0
- package/dist/config.d.ts +61 -0
- package/dist/config.js +122 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +10 -0
- package/dist/issues.d.ts +63 -0
- package/dist/issues.js +144 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +101 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Levinariy Fedorov
|
|
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
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# @levinariy.fedorov/youtrack-mcp
|
|
2
|
+
|
|
3
|
+
MCP-сервер для YouTrack — и та же самая библиотека для тех, кому протокол не нужен.
|
|
4
|
+
|
|
5
|
+
Модели инструменты нужны через MCP: другого способа их звать у неё нет. Приложению
|
|
6
|
+
на том же языке протокол только мешает — оно импортирует функции напрямую. Поэтому
|
|
7
|
+
здесь одно ядро и две обёртки над ним, а не сервер, к которому все ходят по stdio.
|
|
8
|
+
|
|
9
|
+
Написан под YouTrack без встроенного MCP: в 2025.3 и новее JetBrains даёт удалённый
|
|
10
|
+
MCP-сервер прямо в продукте, и если он у вас есть — начните с него.
|
|
11
|
+
|
|
12
|
+
## Подключение
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"mcpServers": {
|
|
17
|
+
"youtrack": {
|
|
18
|
+
"command": "npx",
|
|
19
|
+
"args": ["-y", "@levinariy.fedorov/youtrack-mcp@0.1.0"]
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Версию лучше указывать: сервер запускается при каждом старте сессии, и «свежайшая»
|
|
26
|
+
здесь означает «неизвестно какая».
|
|
27
|
+
|
|
28
|
+
## Вход
|
|
29
|
+
|
|
30
|
+
Токен — постоянный токен YouTrack из профиля пользователя. Принимается только
|
|
31
|
+
потоком: в аргументе он осел бы в истории оболочки и был бы виден в списке процессов.
|
|
32
|
+
|
|
33
|
+
```powershell
|
|
34
|
+
"<токен>" | npx @levinariy.fedorov/youtrack-mcp login --base-url https://youtrack.example.com --token-stdin
|
|
35
|
+
npx @levinariy.fedorov/youtrack-mcp status
|
|
36
|
+
npx @levinariy.fedorov/youtrack-mcp profiles
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Профилей может быть несколько — например, своя учётка и сервисная. Профиль
|
|
40
|
+
выбирается **параметром вызова**, а не переключателем: запись нового профиля не
|
|
41
|
+
делает его основным, поэтому завести бота, не превратившись в него, безопасно.
|
|
42
|
+
|
|
43
|
+
Учётки лежат в `%APPDATA%\youtrack-mcp\config.json` (на прочих системах — XDG).
|
|
44
|
+
Путь переопределяется переменной `YOUTRACK_MCP_CONFIG`. Переменные `YOUTRACK_URL`
|
|
45
|
+
и `YOUTRACK_TOKEN` перебивают файл — для CI и разовых запусков.
|
|
46
|
+
|
|
47
|
+
## Инструменты
|
|
48
|
+
|
|
49
|
+
| Инструмент | Что делает |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `read_issue` | Задача целиком: поля, комментарии, вложения, связи — одним вызовом |
|
|
52
|
+
| `search_issues` | Поиск запросом YouTrack; только идентификатор, тема и поля |
|
|
53
|
+
| `whoami` | Учётная запись, от имени которой идут вызовы |
|
|
54
|
+
|
|
55
|
+
Ответы структурированные: у каждого инструмента объявлен `outputSchema`, и
|
|
56
|
+
вызывающий получает объект, а не JSON внутри текста.
|
|
57
|
+
|
|
58
|
+
`read_issue` отдаёт поля плоской картой (`fields["Type"]`), у комментария —
|
|
59
|
+
булево `public` вместо разбора видимости, у вложения — комментарий, к которому оно
|
|
60
|
+
приложено. Даты приходят датами: у YouTrack они лежат миллисекундами эпохи и от
|
|
61
|
+
обычного целого неотличимы, поэтому тип поля запрашивается вместе со значением.
|
|
62
|
+
С `attachments: true` файлы скачиваются на диск и получают `path`: содержимое в
|
|
63
|
+
ответ не попадает, читать файл — отдельный шаг.
|
|
64
|
+
|
|
65
|
+
Пустые поля не отдаются: «поля нет» и «поле не заполнено» для читающего одно и то
|
|
66
|
+
же, а два десятка пустых строк в каждом ответе — потраченный впустую контекст.
|
|
67
|
+
|
|
68
|
+
Инструментов чтения конфигурации нет и не будет: токен не должен попадать в
|
|
69
|
+
контекст модели.
|
|
70
|
+
|
|
71
|
+
## Библиотека
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
import { readIssue, resolveCredentials, YouTrackClient } from "@levinariy.fedorov/youtrack-mcp"
|
|
75
|
+
|
|
76
|
+
const { credentials } = await resolveCredentials()
|
|
77
|
+
const issue = await readIssue(new YouTrackClient(credentials), "PROJ-1234")
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Разработка
|
|
81
|
+
|
|
82
|
+
```powershell
|
|
83
|
+
npm install
|
|
84
|
+
npm run build
|
|
85
|
+
npm test
|
|
86
|
+
npm run typecheck
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Собранный `dist` в репозитории не хранится: его делает `prepare` — при публикации
|
|
90
|
+
и при установке пакета из git.
|
|
91
|
+
|
|
92
|
+
## Лицензия
|
|
93
|
+
|
|
94
|
+
MIT.
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { YouTrackClient } from "./client.js";
|
|
5
|
+
import { AuthError, configPath, listProfiles, resolveCredentials, saveProfile, setDefaultProfile } from "./config.js";
|
|
6
|
+
import { me } from "./issues.js";
|
|
7
|
+
import { createServer } from "./server.js";
|
|
8
|
+
const USAGE = `youtrack-mcp — MCP-сервер для YouTrack.
|
|
9
|
+
|
|
10
|
+
youtrack-mcp запустить сервер (stdio)
|
|
11
|
+
youtrack-mcp login сохранить учётку
|
|
12
|
+
youtrack-mcp status проверить, кто мы в YouTrack
|
|
13
|
+
youtrack-mcp profiles перечислить профили
|
|
14
|
+
|
|
15
|
+
Ключи login:
|
|
16
|
+
--base-url <URL> адрес YouTrack
|
|
17
|
+
--token-stdin прочитать токен из stdin
|
|
18
|
+
--profile <имя> профиль (по умолчанию default)
|
|
19
|
+
--set-default сделать профиль основным
|
|
20
|
+
`;
|
|
21
|
+
async function readStdin() {
|
|
22
|
+
const chunks = [];
|
|
23
|
+
for await (const chunk of process.stdin)
|
|
24
|
+
chunks.push(chunk);
|
|
25
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
26
|
+
}
|
|
27
|
+
async function login(argv) {
|
|
28
|
+
const { values } = parseArgs({
|
|
29
|
+
args: argv,
|
|
30
|
+
options: {
|
|
31
|
+
"base-url": { type: "string" },
|
|
32
|
+
"token-stdin": { type: "boolean", default: false },
|
|
33
|
+
profile: { type: "string", default: "default" },
|
|
34
|
+
"set-default": { type: "boolean", default: false }
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
const baseUrl = values["base-url"]?.trim();
|
|
38
|
+
if (!baseUrl)
|
|
39
|
+
throw new AuthError("Нужен --base-url: адрес YouTrack.");
|
|
40
|
+
// Токен принимаем только потоком: в аргументе он осядет в истории оболочки и
|
|
41
|
+
// будет виден в списке процессов.
|
|
42
|
+
if (!values["token-stdin"])
|
|
43
|
+
throw new AuthError("Токен передаётся через --token-stdin, аргументом — никогда.");
|
|
44
|
+
const token = await readStdin();
|
|
45
|
+
if (token === "")
|
|
46
|
+
throw new AuthError("Пустой токен.");
|
|
47
|
+
const profile = values.profile ?? "default";
|
|
48
|
+
await saveProfile(profile, { baseUrl, token });
|
|
49
|
+
if (values["set-default"])
|
|
50
|
+
await setDefaultProfile(profile);
|
|
51
|
+
const { credentials } = await resolveCredentials(profile);
|
|
52
|
+
const user = await me(new YouTrackClient(credentials));
|
|
53
|
+
process.stdout.write(`Профиль «${profile}» сохранён: ${user.name} (${user.login}) на ${baseUrl}\n`);
|
|
54
|
+
}
|
|
55
|
+
async function status() {
|
|
56
|
+
const { profile, credentials } = await resolveCredentials();
|
|
57
|
+
const user = await me(new YouTrackClient(credentials));
|
|
58
|
+
process.stdout.write(`${user.name} (${user.login})\nПрофиль: ${profile}\nURL: ${credentials.baseUrl}\n`);
|
|
59
|
+
}
|
|
60
|
+
async function profiles() {
|
|
61
|
+
const list = await listProfiles();
|
|
62
|
+
if (list.length === 0) {
|
|
63
|
+
process.stdout.write(`Профилей нет. Конфигурация: ${configPath()}\n`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const item of list) {
|
|
67
|
+
process.stdout.write(`${item.isDefault ? "*" : " "} ${item.name}\t${item.baseUrl}\n`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function main() {
|
|
71
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
72
|
+
switch (command) {
|
|
73
|
+
case undefined:
|
|
74
|
+
// Ни одного аргумента — значит нас запустил MCP-клиент: говорим по stdio
|
|
75
|
+
// и ничего не печатаем в stdout, там протокол.
|
|
76
|
+
await createServer().connect(new StdioServerTransport());
|
|
77
|
+
break;
|
|
78
|
+
case "login":
|
|
79
|
+
await login(rest);
|
|
80
|
+
break;
|
|
81
|
+
case "status":
|
|
82
|
+
await status();
|
|
83
|
+
break;
|
|
84
|
+
case "profiles":
|
|
85
|
+
await profiles();
|
|
86
|
+
break;
|
|
87
|
+
case "--help":
|
|
88
|
+
case "-h":
|
|
89
|
+
case "help":
|
|
90
|
+
process.stdout.write(USAGE);
|
|
91
|
+
break;
|
|
92
|
+
default:
|
|
93
|
+
process.stderr.write(`Неизвестная команда ${command}.\n\n${USAGE}`);
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
main().catch((error) => {
|
|
98
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
});
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Credentials } from "./config.js";
|
|
2
|
+
/** Ошибка со стороны YouTrack: несёт код ответа, чтобы вызывающий не разбирал текст. */
|
|
3
|
+
export declare class YouTrackError extends Error {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
constructor(message: string, status: number);
|
|
6
|
+
}
|
|
7
|
+
type Query = Record<string, string | number | undefined>;
|
|
8
|
+
export declare class YouTrackClient {
|
|
9
|
+
private readonly credentials;
|
|
10
|
+
constructor(credentials: Credentials);
|
|
11
|
+
get baseUrl(): string;
|
|
12
|
+
request<T>(method: string, apiPath: string, options?: {
|
|
13
|
+
query?: Query;
|
|
14
|
+
body?: unknown;
|
|
15
|
+
}): Promise<T>;
|
|
16
|
+
get<T>(apiPath: string, query?: Query): Promise<T>;
|
|
17
|
+
post<T>(apiPath: string, body: unknown, query?: Query): Promise<T>;
|
|
18
|
+
/**
|
|
19
|
+
* Скачивает вложение по ссылке из поля `url`.
|
|
20
|
+
*
|
|
21
|
+
* Ссылка приходит относительной и уже подписанной: собрать её самостоятельно
|
|
22
|
+
* нельзя, поэтому путь и параметры уходят в запрос как есть.
|
|
23
|
+
*/
|
|
24
|
+
download(relativeUrl: string, directory: string, fileName: string): Promise<string>;
|
|
25
|
+
}
|
|
26
|
+
export {};
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { pipeline } from "node:stream/promises";
|
|
6
|
+
import { AuthError } from "./config.js";
|
|
7
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
8
|
+
/** Вложение может весить десятки мегабайт и в общий лимит не укладывается. */
|
|
9
|
+
const DOWNLOAD_TIMEOUT_MS = 5 * 60_000;
|
|
10
|
+
/** Ошибка со стороны YouTrack: несёт код ответа, чтобы вызывающий не разбирал текст. */
|
|
11
|
+
export class YouTrackError extends Error {
|
|
12
|
+
status;
|
|
13
|
+
constructor(message, status) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.name = "YouTrackError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function describe(status, body) {
|
|
20
|
+
try {
|
|
21
|
+
const parsed = JSON.parse(body);
|
|
22
|
+
const text = parsed.error_description ?? parsed.error;
|
|
23
|
+
if (text)
|
|
24
|
+
return text;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// YouTrack отвечает и просто текстом — тогда берём его как есть.
|
|
28
|
+
}
|
|
29
|
+
return body.trim().slice(0, 500) || `HTTP ${status}`;
|
|
30
|
+
}
|
|
31
|
+
export class YouTrackClient {
|
|
32
|
+
credentials;
|
|
33
|
+
constructor(credentials) {
|
|
34
|
+
this.credentials = credentials;
|
|
35
|
+
}
|
|
36
|
+
get baseUrl() {
|
|
37
|
+
return this.credentials.baseUrl;
|
|
38
|
+
}
|
|
39
|
+
async request(method, apiPath, options = {}) {
|
|
40
|
+
const url = new URL(apiPath.replace(/^\//, ""), `${this.credentials.baseUrl}/`);
|
|
41
|
+
for (const [key, value] of Object.entries(options.query ?? {})) {
|
|
42
|
+
if (value !== undefined)
|
|
43
|
+
url.searchParams.set(key, String(value));
|
|
44
|
+
}
|
|
45
|
+
const response = await fetch(url, {
|
|
46
|
+
method,
|
|
47
|
+
headers: {
|
|
48
|
+
Authorization: `Bearer ${this.credentials.token}`,
|
|
49
|
+
Accept: "application/json",
|
|
50
|
+
...(options.body === undefined ? {} : { "Content-Type": "application/json" })
|
|
51
|
+
},
|
|
52
|
+
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
|
53
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
54
|
+
});
|
|
55
|
+
if (response.status === 401 || response.status === 403) {
|
|
56
|
+
throw new AuthError(`YouTrack не принял токен (${response.status}). Он отозван или истёк — заведите новый и выполните: youtrack-mcp login --base-url ${this.credentials.baseUrl} --token-stdin`);
|
|
57
|
+
}
|
|
58
|
+
const text = await response.text();
|
|
59
|
+
if (!response.ok)
|
|
60
|
+
throw new YouTrackError(describe(response.status, text), response.status);
|
|
61
|
+
if (text.trim() === "")
|
|
62
|
+
return undefined;
|
|
63
|
+
return JSON.parse(text);
|
|
64
|
+
}
|
|
65
|
+
get(apiPath, query) {
|
|
66
|
+
return this.request("GET", apiPath, query === undefined ? {} : { query });
|
|
67
|
+
}
|
|
68
|
+
post(apiPath, body, query) {
|
|
69
|
+
return this.request("POST", apiPath, query === undefined ? { body } : { body, query });
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Скачивает вложение по ссылке из поля `url`.
|
|
73
|
+
*
|
|
74
|
+
* Ссылка приходит относительной и уже подписанной: собрать её самостоятельно
|
|
75
|
+
* нельзя, поэтому путь и параметры уходят в запрос как есть.
|
|
76
|
+
*/
|
|
77
|
+
async download(relativeUrl, directory, fileName) {
|
|
78
|
+
const url = new URL(relativeUrl, `${this.credentials.baseUrl}/`);
|
|
79
|
+
const response = await fetch(url, {
|
|
80
|
+
headers: { Authorization: `Bearer ${this.credentials.token}` },
|
|
81
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)
|
|
82
|
+
});
|
|
83
|
+
if (!response.ok || !response.body) {
|
|
84
|
+
throw new YouTrackError(`Не удалось скачать вложение ${fileName} (HTTP ${response.status}).`, response.status);
|
|
85
|
+
}
|
|
86
|
+
await mkdir(directory, { recursive: true });
|
|
87
|
+
const target = path.join(directory, fileName);
|
|
88
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(target));
|
|
89
|
+
return target;
|
|
90
|
+
}
|
|
91
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Учётные данные одного профиля.
|
|
4
|
+
*
|
|
5
|
+
* Профилей несколько не ради «мультиаккаунта», а потому что писать в YouTrack
|
|
6
|
+
* приходится от двух лиц: от себя и от сервисной учётки. Выбор — всегда
|
|
7
|
+
* параметр вызова, поэтому активный профиль здесь означает лишь «кого взять,
|
|
8
|
+
* если не сказано иное», и записью нового профиля не меняется.
|
|
9
|
+
*/
|
|
10
|
+
export interface Credentials {
|
|
11
|
+
baseUrl: string;
|
|
12
|
+
token: string;
|
|
13
|
+
}
|
|
14
|
+
declare const ConfigSchema: z.ZodObject<{
|
|
15
|
+
defaultProfile: z.ZodDefault<z.ZodString>;
|
|
16
|
+
profiles: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
17
|
+
baseUrl: z.ZodString;
|
|
18
|
+
token: z.ZodString;
|
|
19
|
+
}, z.core.$strip>>>;
|
|
20
|
+
}, z.core.$strip>;
|
|
21
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
22
|
+
/** Профиль без секрета: всё, что можно показывать наружу. */
|
|
23
|
+
export interface ProfileInfo {
|
|
24
|
+
name: string;
|
|
25
|
+
baseUrl: string;
|
|
26
|
+
isDefault: boolean;
|
|
27
|
+
}
|
|
28
|
+
export declare class AuthError extends Error {
|
|
29
|
+
constructor(message: string);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Путь к конфигурации.
|
|
33
|
+
*
|
|
34
|
+
* На Windows — `%APPDATA%`, потому что там место пользовательских настроек;
|
|
35
|
+
* на остальных системах — XDG. `YOUTRACK_MCP_CONFIG` перебивает обе ветки:
|
|
36
|
+
* это нужно тестам и запуску нескольких окружений рядом.
|
|
37
|
+
*/
|
|
38
|
+
export declare function configPath(): string;
|
|
39
|
+
export declare function readConfig(): Promise<Config>;
|
|
40
|
+
/**
|
|
41
|
+
* Сохраняет профиль.
|
|
42
|
+
*
|
|
43
|
+
* Активный профиль намеренно не трогает: заведение сервисной учётки не должно
|
|
44
|
+
* даже на секунду превращать в неё все остальные сессии.
|
|
45
|
+
*/
|
|
46
|
+
export declare function saveProfile(name: string, credentials: Credentials): Promise<void>;
|
|
47
|
+
export declare function setDefaultProfile(name: string): Promise<void>;
|
|
48
|
+
export declare function removeProfile(name: string): Promise<void>;
|
|
49
|
+
export declare function listProfiles(): Promise<ProfileInfo[]>;
|
|
50
|
+
/**
|
|
51
|
+
* Находит учётку для вызова: переменные среды, затем файл.
|
|
52
|
+
*
|
|
53
|
+
* Переменные перебивают файл целиком и только для профиля по умолчанию: они
|
|
54
|
+
* задают одно окружение на процесс, и смешивать их с именованным профилем
|
|
55
|
+
* значило бы писать от неизвестно чьего имени.
|
|
56
|
+
*/
|
|
57
|
+
export declare function resolveCredentials(profile?: string): Promise<{
|
|
58
|
+
profile: string;
|
|
59
|
+
credentials: Credentials;
|
|
60
|
+
}>;
|
|
61
|
+
export {};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
const CredentialsSchema = z.object({
|
|
6
|
+
baseUrl: z.string().min(1),
|
|
7
|
+
token: z.string().min(1)
|
|
8
|
+
});
|
|
9
|
+
const ConfigSchema = z.object({
|
|
10
|
+
defaultProfile: z.string().default("default"),
|
|
11
|
+
profiles: z.record(z.string(), CredentialsSchema).default({})
|
|
12
|
+
});
|
|
13
|
+
export class AuthError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "AuthError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Путь к конфигурации.
|
|
21
|
+
*
|
|
22
|
+
* На Windows — `%APPDATA%`, потому что там место пользовательских настроек;
|
|
23
|
+
* на остальных системах — XDG. `YOUTRACK_MCP_CONFIG` перебивает обе ветки:
|
|
24
|
+
* это нужно тестам и запуску нескольких окружений рядом.
|
|
25
|
+
*/
|
|
26
|
+
export function configPath() {
|
|
27
|
+
const override = process.env.YOUTRACK_MCP_CONFIG?.trim();
|
|
28
|
+
if (override)
|
|
29
|
+
return override;
|
|
30
|
+
if (process.platform === "win32" && process.env.APPDATA) {
|
|
31
|
+
return path.join(process.env.APPDATA, "youtrack-mcp", "config.json");
|
|
32
|
+
}
|
|
33
|
+
const xdg = process.env.XDG_CONFIG_HOME?.trim();
|
|
34
|
+
const base = xdg && xdg !== "" ? xdg : path.join(os.homedir(), ".config");
|
|
35
|
+
return path.join(base, "youtrack-mcp", "config.json");
|
|
36
|
+
}
|
|
37
|
+
const EMPTY = { defaultProfile: "default", profiles: {} };
|
|
38
|
+
export async function readConfig() {
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
raw = await readFile(configPath(), "utf8");
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code === "ENOENT")
|
|
45
|
+
return { ...EMPTY };
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
if (raw.trim() === "")
|
|
49
|
+
return { ...EMPTY };
|
|
50
|
+
const parsed = ConfigSchema.safeParse(JSON.parse(raw));
|
|
51
|
+
if (!parsed.success) {
|
|
52
|
+
throw new AuthError(`Конфигурация ${configPath()} испорчена: ${parsed.error.issues[0]?.message ?? "неизвестная ошибка"}.`);
|
|
53
|
+
}
|
|
54
|
+
return parsed.data;
|
|
55
|
+
}
|
|
56
|
+
async function writeConfig(config) {
|
|
57
|
+
const file = configPath();
|
|
58
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
59
|
+
// mode 0600 — осмысленно на POSIX; на Windows файл всё равно доступен любому
|
|
60
|
+
// процессу пользователя, и притворяться, что это защита, не стоит.
|
|
61
|
+
await writeFile(file, `${JSON.stringify(config, null, "\t")}\n`, { encoding: "utf8", mode: 0o600 });
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Сохраняет профиль.
|
|
65
|
+
*
|
|
66
|
+
* Активный профиль намеренно не трогает: заведение сервисной учётки не должно
|
|
67
|
+
* даже на секунду превращать в неё все остальные сессии.
|
|
68
|
+
*/
|
|
69
|
+
export async function saveProfile(name, credentials) {
|
|
70
|
+
const config = await readConfig();
|
|
71
|
+
const parsed = CredentialsSchema.parse({
|
|
72
|
+
baseUrl: credentials.baseUrl.trim().replace(/\/+$/, ""),
|
|
73
|
+
token: credentials.token.trim()
|
|
74
|
+
});
|
|
75
|
+
config.profiles[name] = parsed;
|
|
76
|
+
await writeConfig(config);
|
|
77
|
+
}
|
|
78
|
+
export async function setDefaultProfile(name) {
|
|
79
|
+
const config = await readConfig();
|
|
80
|
+
if (!config.profiles[name])
|
|
81
|
+
throw new AuthError(`Профиля «${name}» нет в конфигурации.`);
|
|
82
|
+
config.defaultProfile = name;
|
|
83
|
+
await writeConfig(config);
|
|
84
|
+
}
|
|
85
|
+
export async function removeProfile(name) {
|
|
86
|
+
const config = await readConfig();
|
|
87
|
+
delete config.profiles[name];
|
|
88
|
+
await writeConfig(config);
|
|
89
|
+
}
|
|
90
|
+
export async function listProfiles() {
|
|
91
|
+
const config = await readConfig();
|
|
92
|
+
return Object.entries(config.profiles).map(([name, credentials]) => ({
|
|
93
|
+
name,
|
|
94
|
+
baseUrl: credentials.baseUrl,
|
|
95
|
+
isDefault: name === config.defaultProfile
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Находит учётку для вызова: переменные среды, затем файл.
|
|
100
|
+
*
|
|
101
|
+
* Переменные перебивают файл целиком и только для профиля по умолчанию: они
|
|
102
|
+
* задают одно окружение на процесс, и смешивать их с именованным профилем
|
|
103
|
+
* значило бы писать от неизвестно чьего имени.
|
|
104
|
+
*/
|
|
105
|
+
export async function resolveCredentials(profile) {
|
|
106
|
+
const envUrl = process.env.YOUTRACK_URL?.trim();
|
|
107
|
+
const envToken = process.env.YOUTRACK_TOKEN?.trim();
|
|
108
|
+
if (!profile && envUrl && envToken) {
|
|
109
|
+
return { profile: "env", credentials: { baseUrl: envUrl.replace(/\/+$/, ""), token: envToken } };
|
|
110
|
+
}
|
|
111
|
+
const config = await readConfig();
|
|
112
|
+
const name = profile ?? config.defaultProfile;
|
|
113
|
+
const credentials = config.profiles[name];
|
|
114
|
+
if (!credentials) {
|
|
115
|
+
const known = Object.keys(config.profiles);
|
|
116
|
+
const hint = known.length > 0
|
|
117
|
+
? `Известные профили: ${known.join(", ")}.`
|
|
118
|
+
: `Ни одного профиля нет. Войдите: youtrack-mcp login --base-url <URL> --token-stdin`;
|
|
119
|
+
throw new AuthError(`Профиля «${name}» нет в ${configPath()}. ${hint}`);
|
|
120
|
+
}
|
|
121
|
+
return { profile: name, credentials };
|
|
122
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Библиотека YouTrack: то же самое, что делает MCP-сервер, но вызовом функции.
|
|
3
|
+
*
|
|
4
|
+
* Приложению протокол не нужен — оно на том же языке, поэтому ходит сюда
|
|
5
|
+
* напрямую, а MCP остаётся тем, чем и был: способом дать инструменты модели.
|
|
6
|
+
*/
|
|
7
|
+
export { YouTrackClient, YouTrackError } from "./client.js";
|
|
8
|
+
export { AuthError, configPath, listProfiles, readConfig, removeProfile, resolveCredentials, saveProfile, setDefaultProfile } from "./config.js";
|
|
9
|
+
export type { Config, Credentials, ProfileInfo } from "./config.js";
|
|
10
|
+
export { me, readIssue, searchIssues } from "./issues.js";
|
|
11
|
+
export type { Attachment, Comment, Found, Issue, Link, Person, ReadOptions } from "./issues.js";
|
|
12
|
+
export { createServer } from "./server.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Библиотека YouTrack: то же самое, что делает MCP-сервер, но вызовом функции.
|
|
3
|
+
*
|
|
4
|
+
* Приложению протокол не нужен — оно на том же языке, поэтому ходит сюда
|
|
5
|
+
* напрямую, а MCP остаётся тем, чем и был: способом дать инструменты модели.
|
|
6
|
+
*/
|
|
7
|
+
export { YouTrackClient, YouTrackError } from "./client.js";
|
|
8
|
+
export { AuthError, configPath, listProfiles, readConfig, removeProfile, resolveCredentials, saveProfile, setDefaultProfile } from "./config.js";
|
|
9
|
+
export { me, readIssue, searchIssues } from "./issues.js";
|
|
10
|
+
export { createServer } from "./server.js";
|
package/dist/issues.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { YouTrackClient } from "./client.js";
|
|
2
|
+
export type Person = {
|
|
3
|
+
login: string;
|
|
4
|
+
name: string;
|
|
5
|
+
};
|
|
6
|
+
export type Comment = {
|
|
7
|
+
id: string;
|
|
8
|
+
author: Person;
|
|
9
|
+
created: string;
|
|
10
|
+
text: string;
|
|
11
|
+
/** Виден заказчику. Внутренняя заметка ответом заказчику не считается. */
|
|
12
|
+
public: boolean;
|
|
13
|
+
};
|
|
14
|
+
export type Attachment = {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
mimeType: string;
|
|
18
|
+
size: number;
|
|
19
|
+
/** Комментарий, к которому приложен файл, либо null — если к самой задаче. */
|
|
20
|
+
commentId: string | null;
|
|
21
|
+
/** Путь на диске. Появляется, только если файлы просили скачать. */
|
|
22
|
+
path?: string;
|
|
23
|
+
};
|
|
24
|
+
export type Link = {
|
|
25
|
+
/** Как связь читается со стороны этой задачи: «duplicates», «relates to». */
|
|
26
|
+
type: string;
|
|
27
|
+
issues: {
|
|
28
|
+
id: string;
|
|
29
|
+
summary: string;
|
|
30
|
+
}[];
|
|
31
|
+
};
|
|
32
|
+
export type Issue = {
|
|
33
|
+
id: string;
|
|
34
|
+
summary: string;
|
|
35
|
+
description: string;
|
|
36
|
+
url: string;
|
|
37
|
+
project: string;
|
|
38
|
+
reporter: Person;
|
|
39
|
+
created: string;
|
|
40
|
+
updated: string;
|
|
41
|
+
resolved: string | null;
|
|
42
|
+
/** Значения полей задачи по именам: Type, State, Business solution, Priority. */
|
|
43
|
+
fields: Record<string, string>;
|
|
44
|
+
comments: Comment[];
|
|
45
|
+
attachments: Attachment[];
|
|
46
|
+
links: Link[];
|
|
47
|
+
};
|
|
48
|
+
export type ReadOptions = {
|
|
49
|
+
/** Скачать вложения на диск и проставить им `path`. */
|
|
50
|
+
downloadAttachments?: boolean;
|
|
51
|
+
/** Куда складывать файлы. По умолчанию — подкаталог задачи во временном каталоге. */
|
|
52
|
+
directory?: string;
|
|
53
|
+
};
|
|
54
|
+
export declare function readIssue(client: YouTrackClient, id: string, options?: ReadOptions): Promise<Issue>;
|
|
55
|
+
export type Found = {
|
|
56
|
+
id: string;
|
|
57
|
+
summary: string;
|
|
58
|
+
fields: Record<string, string>;
|
|
59
|
+
};
|
|
60
|
+
export declare function searchIssues(client: YouTrackClient, query: string, limit?: number): Promise<Found[]>;
|
|
61
|
+
export declare function me(client: YouTrackClient): Promise<Person & {
|
|
62
|
+
email: string;
|
|
63
|
+
}>;
|
package/dist/issues.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Поля, которые запрашиваются у YouTrack.
|
|
5
|
+
*
|
|
6
|
+
* Держатся здесь, а не в промпте скилла: забытое поле не ломает вызов, а тихо
|
|
7
|
+
* отдаёт пустоту, и заметно это становится на живой задаче.
|
|
8
|
+
*/
|
|
9
|
+
const ISSUE_FIELDS = [
|
|
10
|
+
"idReadable",
|
|
11
|
+
"summary",
|
|
12
|
+
"description",
|
|
13
|
+
"created",
|
|
14
|
+
"updated",
|
|
15
|
+
"resolved",
|
|
16
|
+
"project(shortName,name)",
|
|
17
|
+
"reporter(login,fullName)",
|
|
18
|
+
// Тип поля спрашивается вместе со значением: у даты значение приезжает голым
|
|
19
|
+
// числом, и без типа её не отличить от обычного целого.
|
|
20
|
+
"customFields(name,projectCustomField(field(fieldType(id))),value(name,login,fullName,presentation,text,minutes))",
|
|
21
|
+
"comments(id,created,text,author(login,fullName),visibility($type))",
|
|
22
|
+
"attachments(id,name,size,mimeType,url,comment(id))",
|
|
23
|
+
"links(direction,linkType(name,sourceToTarget,targetToSource),issues(idReadable,summary))"
|
|
24
|
+
].join(",");
|
|
25
|
+
const SEARCH_FIELDS = "idReadable,summary,customFields(name,projectCustomField(field(fieldType(id))),value(name,login,fullName,presentation,text,minutes))";
|
|
26
|
+
function person(raw) {
|
|
27
|
+
return { login: raw?.login ?? "", name: raw?.fullName ?? raw?.login ?? "" };
|
|
28
|
+
}
|
|
29
|
+
function moment(value) {
|
|
30
|
+
return value ? new Date(value).toISOString() : null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Приводит значение поля к строке.
|
|
34
|
+
*
|
|
35
|
+
* У YouTrack `value` бывает объектом, массивом, строкой и числом в зависимости
|
|
36
|
+
* от типа поля — разбирать это на каждом вызове не должен никто, кроме этого
|
|
37
|
+
* места.
|
|
38
|
+
*/
|
|
39
|
+
function fieldValue(value, fieldType) {
|
|
40
|
+
if (value === null || value === undefined)
|
|
41
|
+
return "";
|
|
42
|
+
if (typeof value === "string")
|
|
43
|
+
return value;
|
|
44
|
+
if (typeof value === "number") {
|
|
45
|
+
// Дата приезжает миллисекундами эпохи. Отдавать её так наружу — значит
|
|
46
|
+
// заставить читающего гадать, срок это или количество.
|
|
47
|
+
if (fieldType === "date")
|
|
48
|
+
return new Date(value).toISOString().slice(0, 10);
|
|
49
|
+
if (fieldType === "date and time")
|
|
50
|
+
return new Date(value).toISOString();
|
|
51
|
+
return String(value);
|
|
52
|
+
}
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
return value
|
|
55
|
+
.map((item) => fieldValue(item, fieldType))
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.join(", ");
|
|
58
|
+
}
|
|
59
|
+
const raw = value;
|
|
60
|
+
return raw.name ?? raw.fullName ?? raw.login ?? raw.presentation ?? raw.text ?? (raw.minutes === undefined ? "" : String(raw.minutes));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Собирает поля задачи в плоскую карту.
|
|
64
|
+
*
|
|
65
|
+
* Пустые не кладёт: «поля нет» и «поле не заполнено» для читающего одно и то же,
|
|
66
|
+
* а два десятка пустых строк в каждом ответе — это чужой контекст, потраченный
|
|
67
|
+
* впустую.
|
|
68
|
+
*/
|
|
69
|
+
function collectFields(raw) {
|
|
70
|
+
const fields = {};
|
|
71
|
+
for (const field of raw.customFields ?? []) {
|
|
72
|
+
if (!field.name)
|
|
73
|
+
continue;
|
|
74
|
+
const value = fieldValue(field.value, field.projectCustomField?.field?.fieldType?.id);
|
|
75
|
+
if (value !== "")
|
|
76
|
+
fields[field.name] = value;
|
|
77
|
+
}
|
|
78
|
+
return fields;
|
|
79
|
+
}
|
|
80
|
+
function toIssue(raw, baseUrl) {
|
|
81
|
+
const id = raw.idReadable ?? "";
|
|
82
|
+
return {
|
|
83
|
+
id,
|
|
84
|
+
summary: raw.summary ?? "",
|
|
85
|
+
description: raw.description ?? "",
|
|
86
|
+
url: `${baseUrl}/issue/${id}`,
|
|
87
|
+
project: raw.project?.name ?? raw.project?.shortName ?? "",
|
|
88
|
+
reporter: person(raw.reporter),
|
|
89
|
+
created: moment(raw.created) ?? "",
|
|
90
|
+
updated: moment(raw.updated) ?? "",
|
|
91
|
+
resolved: moment(raw.resolved),
|
|
92
|
+
fields: collectFields(raw),
|
|
93
|
+
comments: (raw.comments ?? []).map((comment) => ({
|
|
94
|
+
id: comment.id ?? "",
|
|
95
|
+
author: person(comment.author),
|
|
96
|
+
created: moment(comment.created) ?? "",
|
|
97
|
+
text: comment.text ?? "",
|
|
98
|
+
// Ограниченная видимость — внутренняя заметка. Отсутствие поля значит,
|
|
99
|
+
// что комментарий видят все, включая заказчика.
|
|
100
|
+
public: comment.visibility?.$type !== "IssueCommentVisibility" && comment.visibility?.$type !== "LimitedVisibility"
|
|
101
|
+
})),
|
|
102
|
+
attachments: (raw.attachments ?? []).map((attachment) => ({
|
|
103
|
+
id: attachment.id ?? "",
|
|
104
|
+
name: attachment.name ?? "",
|
|
105
|
+
mimeType: attachment.mimeType ?? "",
|
|
106
|
+
size: attachment.size ?? 0,
|
|
107
|
+
commentId: attachment.comment?.id ?? null
|
|
108
|
+
})),
|
|
109
|
+
links: (raw.links ?? [])
|
|
110
|
+
.filter((link) => (link.issues ?? []).length > 0)
|
|
111
|
+
.map((link) => ({
|
|
112
|
+
type: (link.direction === "INWARD" ? link.linkType?.targetToSource : link.linkType?.sourceToTarget) ??
|
|
113
|
+
link.linkType?.name ??
|
|
114
|
+
"",
|
|
115
|
+
issues: (link.issues ?? []).map((issue) => ({ id: issue.idReadable ?? "", summary: issue.summary ?? "" }))
|
|
116
|
+
}))
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export async function readIssue(client, id, options = {}) {
|
|
120
|
+
const raw = await client.get(`/api/issues/${encodeURIComponent(id)}`, { fields: ISSUE_FIELDS });
|
|
121
|
+
const issue = toIssue(raw, client.baseUrl);
|
|
122
|
+
if (!options.downloadAttachments || issue.attachments.length === 0)
|
|
123
|
+
return issue;
|
|
124
|
+
const directory = options.directory ?? path.join(os.tmpdir(), "youtrack", issue.id);
|
|
125
|
+
const urls = new Map((raw.attachments ?? []).map((attachment) => [attachment.id ?? "", attachment.url ?? ""]));
|
|
126
|
+
// Последовательно: вложения бывают крупными, и параллельная загрузка десятка
|
|
127
|
+
// файлов упирается не в нас, а в YouTrack.
|
|
128
|
+
for (const attachment of issue.attachments) {
|
|
129
|
+
const url = urls.get(attachment.id);
|
|
130
|
+
if (url)
|
|
131
|
+
attachment.path = await client.download(url, directory, attachment.name);
|
|
132
|
+
}
|
|
133
|
+
return issue;
|
|
134
|
+
}
|
|
135
|
+
export async function searchIssues(client, query, limit = 50) {
|
|
136
|
+
const raw = await client.get("/api/issues", { query, $top: limit, fields: SEARCH_FIELDS });
|
|
137
|
+
return raw.map((issue) => {
|
|
138
|
+
return { id: issue.idReadable ?? "", summary: issue.summary ?? "", fields: collectFields(issue) };
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
export async function me(client) {
|
|
142
|
+
const raw = await client.get("/api/users/me", { fields: "login,fullName,email" });
|
|
143
|
+
return { ...person(raw), email: raw.email ?? "" };
|
|
144
|
+
}
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { YouTrackClient } from "./client.js";
|
|
4
|
+
import { resolveCredentials } from "./config.js";
|
|
5
|
+
import { me, readIssue, searchIssues } from "./issues.js";
|
|
6
|
+
/**
|
|
7
|
+
* Клиенты живут по имени профиля: конфигурацию незачем перечитывать на каждый
|
|
8
|
+
* вызов, а профилей всё равно единицы.
|
|
9
|
+
*/
|
|
10
|
+
const clients = new Map();
|
|
11
|
+
async function clientFor(profile) {
|
|
12
|
+
const resolved = await resolveCredentials(profile);
|
|
13
|
+
const cached = clients.get(resolved.profile);
|
|
14
|
+
if (cached)
|
|
15
|
+
return cached;
|
|
16
|
+
const client = new YouTrackClient(resolved.credentials);
|
|
17
|
+
clients.set(resolved.profile, client);
|
|
18
|
+
return client;
|
|
19
|
+
}
|
|
20
|
+
const profileArg = z
|
|
21
|
+
.string()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("Профиль учётной записи. Не указан — работа идёт от вашего имени; сервисная учётка задаётся здесь и только на время одного вызова.");
|
|
24
|
+
const PersonSchema = z.object({ login: z.string(), name: z.string() });
|
|
25
|
+
const IssueSchema = z.object({
|
|
26
|
+
id: z.string(),
|
|
27
|
+
summary: z.string(),
|
|
28
|
+
description: z.string(),
|
|
29
|
+
url: z.string(),
|
|
30
|
+
project: z.string(),
|
|
31
|
+
reporter: PersonSchema,
|
|
32
|
+
created: z.string(),
|
|
33
|
+
updated: z.string(),
|
|
34
|
+
resolved: z.string().nullable(),
|
|
35
|
+
fields: z.record(z.string(), z.string()),
|
|
36
|
+
comments: z.array(z.object({
|
|
37
|
+
id: z.string(),
|
|
38
|
+
author: PersonSchema,
|
|
39
|
+
created: z.string(),
|
|
40
|
+
text: z.string(),
|
|
41
|
+
public: z.boolean()
|
|
42
|
+
})),
|
|
43
|
+
attachments: z.array(z.object({
|
|
44
|
+
id: z.string(),
|
|
45
|
+
name: z.string(),
|
|
46
|
+
mimeType: z.string(),
|
|
47
|
+
size: z.number(),
|
|
48
|
+
commentId: z.string().nullable(),
|
|
49
|
+
path: z.string().optional()
|
|
50
|
+
})),
|
|
51
|
+
links: z.array(z.object({ type: z.string(), issues: z.array(z.object({ id: z.string(), summary: z.string() })) }))
|
|
52
|
+
});
|
|
53
|
+
export function createServer() {
|
|
54
|
+
const server = new McpServer({ name: "youtrack", version: "0.1.0" });
|
|
55
|
+
server.registerTool("read_issue", {
|
|
56
|
+
title: "Прочитать задачу",
|
|
57
|
+
description: "Задача целиком: поля, описание, комментарии и вложения одним вызовом. " +
|
|
58
|
+
"Конкретика — номера, даты, периоды — обычно живёт в комментариях и вложениях, а не в описании, " +
|
|
59
|
+
"поэтому отдельного вызова за ними не нужно. " +
|
|
60
|
+
"У комментария `public` означает, что его видит заказчик: внутренняя заметка ответом заказчику не считается. " +
|
|
61
|
+
"С `attachments: true` файлы скачиваются на диск, и у каждого появляется `path` — содержимое читается оттуда, в ответ оно не попадает.",
|
|
62
|
+
inputSchema: {
|
|
63
|
+
id: z.string().describe("Идентификатор задачи, например PROJ-1234"),
|
|
64
|
+
attachments: z.boolean().default(false).describe("Скачать вложения на диск"),
|
|
65
|
+
profile: profileArg
|
|
66
|
+
},
|
|
67
|
+
outputSchema: IssueSchema.shape,
|
|
68
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
69
|
+
}, async ({ id, attachments, profile }) => {
|
|
70
|
+
const issue = await readIssue(await clientFor(profile), id, { downloadAttachments: attachments });
|
|
71
|
+
return { content: [{ type: "text", text: `${issue.id}: ${issue.summary}` }], structuredContent: issue };
|
|
72
|
+
});
|
|
73
|
+
server.registerTool("search_issues", {
|
|
74
|
+
title: "Найти задачи",
|
|
75
|
+
description: "Поиск запросом YouTrack, например `project: PROJ State: Open Assignee: me`. " +
|
|
76
|
+
"Возвращает только идентификатор, тему и поля — за подробностями по конкретной задаче идите в read_issue.",
|
|
77
|
+
inputSchema: {
|
|
78
|
+
query: z.string().describe("Поисковый запрос YouTrack"),
|
|
79
|
+
limit: z.number().int().min(1).max(200).default(50).describe("Сколько задач вернуть"),
|
|
80
|
+
profile: profileArg
|
|
81
|
+
},
|
|
82
|
+
outputSchema: {
|
|
83
|
+
issues: z.array(z.object({ id: z.string(), summary: z.string(), fields: z.record(z.string(), z.string()) }))
|
|
84
|
+
},
|
|
85
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
86
|
+
}, async ({ query, limit, profile }) => {
|
|
87
|
+
const issues = await searchIssues(await clientFor(profile), query, limit);
|
|
88
|
+
return { content: [{ type: "text", text: `Найдено задач: ${issues.length}` }], structuredContent: { issues } };
|
|
89
|
+
});
|
|
90
|
+
server.registerTool("whoami", {
|
|
91
|
+
title: "Кто я в YouTrack",
|
|
92
|
+
description: "Учётная запись, от имени которой идут вызовы. Нужна там, где скилл ставит Assignee на себя.",
|
|
93
|
+
inputSchema: { profile: profileArg },
|
|
94
|
+
outputSchema: { login: z.string(), name: z.string(), email: z.string() },
|
|
95
|
+
annotations: { readOnlyHint: true, openWorldHint: true }
|
|
96
|
+
}, async ({ profile }) => {
|
|
97
|
+
const user = await me(await clientFor(profile));
|
|
98
|
+
return { content: [{ type: "text", text: `${user.name} (${user.login})` }], structuredContent: user };
|
|
99
|
+
});
|
|
100
|
+
return server;
|
|
101
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@levinariy.fedorov/youtrack-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "MCP-сервер и библиотека для YouTrack: задача целиком одним вызовом — поля, комментарии, вложения, связи.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"mcp",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"youtrack",
|
|
10
|
+
"jetbrains",
|
|
11
|
+
"issue-tracking",
|
|
12
|
+
"claude"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "Levinariy Fedorov",
|
|
16
|
+
"homepage": "https://gitlab.ics-it.ru/levinariy.fedorov/youtrack-mcp",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://gitlab.ics-it.ru/levinariy.fedorov/youtrack-mcp.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://gitlab.ics-it.ru/levinariy.fedorov/youtrack-mcp/-/issues"
|
|
23
|
+
},
|
|
24
|
+
"bin": {
|
|
25
|
+
"youtrack-mcp": "dist/bin.js"
|
|
26
|
+
},
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist/"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"prepare": "tsc -p tsconfig.json",
|
|
41
|
+
"build": "tsc -p tsconfig.json",
|
|
42
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
43
|
+
"test": "node --test",
|
|
44
|
+
"dev": "node --watch src/bin.ts"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
48
|
+
"zod": "^4.5.4"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^22.13.5",
|
|
52
|
+
"typescript": "^5.7.3"
|
|
53
|
+
}
|
|
54
|
+
}
|