Metadata-Version: 2.3
Name: zyfra-jira-mcp
Version: 0.3.1
Summary: MCP-сервер для Jira и Confluence Data Center инсталляции Zyfra
Author: Shahovalov MIkhail
Author-email: Shahovalov MIkhail <m.shahovalov@yandex.ru>
Requires-Dist: keyring>=25.7.0
Requires-Dist: markdownify>=0.13
Requires-Dist: mcp==2.2.0
Requires-Python: >=3.14
Description-Content-Type: text/markdown

# zyfra-jira-mcp

MCP-сервер для Jira и Confluence Data Center инсталляции `jira.zyfra.com`:
26 инструментов поверх REST API, ревизия протокола **2026-07-28**.

Сервер заточен под одну инсталляцию: адреса, проект `IDPPA`, пространство `ZPA`
и набор полей зашиты в код. Настраивается только то, что зависит от машины.

## Установка

Сервер ставится пакетом из реестра Gitea и даёт команду `zyfra-jira` в PATH:

```bash
uv tool install zyfra-jira-mcp --index https://git.shahovalov.ru/api/packages/mikhail/pypi/simple
```

Пакет в реестр кладёт CI (`release.yml`) по тегу `vX.Y.Z`, совпадающему с версией в
`pyproject.toml`; тесты гоняет `ci.yml` на каждый push в `main` и pull request. Из чекаута
пакет ставят копией в собственный venv инструмента:

```bash
uv tool install --reinstall W:/AiProjects/zyfra-jira-mcp
```

Для разработки — из чекаута: `uv sync`, команды через `uv run zyfra-jira …`. Клиенты
запускают установленную копию, а не чекаут, поэтому новая версия доходит до них
только переустановкой (см. «Подключение»).

## Токены

Хранятся в Windows Credential Manager (`zyfra-jira/jira_token`, `zyfra-jira/conf_token`).
Логин и пароль Zyfra сервер не хранит и не запрашивает: PAT выпускает человек
в браузере, сервер только подсказывает ссылку.

```bash
zyfra-jira creds set jira --prompt
```

То же для `conf`. Источник токена задаётся явным флагом:

| Флаг | Когда |
|---|---|
| `--prompt` | живая консоль; скрытый ввод с клавиатуры |
| `--from-file PATH` | токен положен в файл; файл удаляется после чтения (`--keep` оставляет) |
| `--stdin` | токен приходит по конвейеру |

Без флага команда печатает справку и выходит. Угадывать терминал нельзя:
`getpass` на Windows читает прямо с консоли мимо stdin, а `sys.stdin.isatty()`
под Git Bash возвращает `True` даже при перенаправлении — команда молча повисла бы.


Токен не попадает ни в аргументы процесса, ни в переписку с агентом.
Переопределить можно переменными `ZYFRA_JIRA_TOKEN` и `ZYFRA_CONF_TOKEN` — они
имеют приоритет над Credential Manager.

Confluence-PAT живёт около 90 дней. Когда он протухнет, любой инструмент вернёт
ссылку на страницу выпуска; после сохранения нового токена вызывается `auth_reload`.

## Подключение

Сервер умеет оба транспорта спецификации: stdio и Streamable HTTP. Настройки машины в
обоих режимах читаются из одного файла `%LOCALAPPDATA%\zyfra-jira\config.toml`, поэтому
в `.mcp.json` их нет. Переменные окружения с теми же именами перекрывают файл.

```toml
# socks = "192.168.250.2:1080"   # VPN в VM; без ключа — напрямую, VPN на этой машине
```

### stdio — основной режим

Клиент запускает сервер сам, в каждой сессии свой процесс; упавший перезапускает тоже он.
Фоновый процесс и автозапуск не нужны.

```json
"zyfra-jira": {
  "type": "stdio",
  "command": "C:/Users/mikhail/.local/bin/zyfra-jira.exe",
  "args": [],
  "env": {}
}
```

Команда — из установки `uv tool`, а не `uv run` в чекауте: сессии держат venv, из
которого запущены, и `uv sync` при разработке иначе падал бы на занятых файлах.
Переустановка (`uv tool install --reinstall …`) по той же причине идёт, когда сессии
Claude закрыты; новая версия доходит до них с новой сессией.

### Streamable HTTP — по желанию

Один общий процесс на loopback для всех клиентов. Его запускают вручную, автозапуска нет.

```bash
zyfra-jira creds new mcp
```

Команда создаёт токен самого сервера, кладёт его в Credential Manager
(`zyfra-jira/mcp_token`) и печатает один раз. Claude Code берёт его оттуда сам:
`headersHelper` вызывает `zyfra-jira creds header` при подключении, и токен не лежит
ни в `.mcp.json`, ни в переменных окружения. Клиенту без `headersHelper` его дают
переменной `ZYFRA_JIRA_MCP_TOKEN`. Новый токен отменяет старый после перезапуска сервера.

```bash
zyfra-jira http
```

Сервер слушает `http://127.0.0.1:8765/mcp` (`--host`, `--port` меняют адрес; адрес
не из loopback отвергается). Без токена сервер не запускается.

```json
"zyfra-jira": {
  "type": "http",
  "url": "http://127.0.0.1:8765/mcp",
  "headersHelper": "C:/Users/mikhail/.local/bin/zyfra-jira.exe creds header"
}
```

Путь в `headersHelper` полный: команду запускает оболочка клиента, и PATH у неё может
не совпадать с PATH терминала.

Запуск без окна консоли — `pythonw.exe` из venv инструмента с
`%LOCALAPPDATA%\zyfra-jira\zyfra-jira-http.pyw`: тот пишет журнал в
`zyfra-jira-http.log` рядом. В журнал попадает каждая новая пара «клиент — ревизия протокола», один раз за запуск:
`клиент claude-code 2.1.308, протокол 2026-07-28`. Когда в нём не останется ревизий
2025-11-25 и раньше, сессии можно выключить (`stateless_http=True`).

| Защита | Что закрывает |
|---|---|
| только loopback | Jira-токены человека не уходят в сеть |
| `Authorization: Bearer` | другие локальные процессы и страницы браузера: без токена — 401 |
| проверка `Host`/`Origin` (SDK) | DNS rebinding: чужое имя в `Host` — отказ |

> ⚠️ Подтверждение записи держится на доверии к клиенту с токеном сервера. Клиента
> `claude-code` сервер узнаёт по имени, которое тот сообщает о себе, и не задаёт ему
> вопрос: окно разрешения показывает сам Claude Code. Процесс с `ZYFRA_JIRA_MCP_TOKEN`
> мог бы назваться так же — или просто ответить «да» на elicitation. Поэтому токен
> сервера даётся только клиентам, которые сами спрашивают человека.

Сессии HTTP включены: на 2026-07-28 сервер и так работает без состояния, а клиенту
2025-11-25 сессия нужна, чтобы ответ на elicitation вернулся в вызов инструмента.
`tools/list` отдаёт `ttlMs` 10 минут: набор инструментов меняется только с новой версией.

## Инструменты

| Чтение | Запись |
|---|---|
| `jira_issue`, `jira_comments`, `jira_search` | `jira_comment_add` |
| `jira_parent`, `jira_transitions` | `jira_update` |
| `conf_images` | `jira_build_fields`, `jira_batch` (пакет разнотипных записей) |
| `jira_worklogs`, `jira_worklog_check` | `jira_worklog_add`, `jira_worklog_batch`, `jira_worklog_update` |
| `conf_page`, `conf_search`, `conf_page_by_jira` | `jira_transition`, `jira_worklog_delete` (destructive) |
| | `jira_attachments`, `conf_attachments` (пишут файлы) |
| `auth_status` | `auth_reload`, `jira_export`, `conf_export` (выгрузка в файл) |

`conf_page(format=…)` отдаёт тело в виде `html` (view с раскрытыми макросами),
`markdown` (тот же view общим переводом, без чистки под заметку) или `storage`
(исходный XHTML с `ac:`-макросами и шириной картинок).

`jira_export` и `conf_export` кладут сырые ответы REST в `<dest>/<KEY>.json` и
`<dest>/<pageId>.json` и возвращают только справку: путь, версию источника, размер и
список того, чего в выгрузке не хватает. Файл читает скрипт навыка, а не модель,
поэтому большой JSON не проходит через контекст.

Каталог `dest` у выгрузок и скачивания вложений выбирает агент: абсолютный путь —
как есть, относительный — от каталога проекта сессии (из него клиент запускает
stdio-сервер; `auth_status` показывает его в `relative_base`). Общий HTTP-процесс
проекта сессии не знает и принимает только абсолютный путь. Имена файлов приходят
из Jira, поэтому чистятся и не выходят за пределы `dest`.

Ключ задачи принимается в любом виде: `IDPPA-8352`, `8352`, `feat/IDPPA-8352`.

### Списание времени и Tempo

На `jira.zyfra.com` (Jira Server 9.13) ворклог Jira и есть ворклог Tempo: у записи
Tempo `jiraWorklogId` совпадает с её id, рабочих атрибутов Tempo не заведено
(`/rest/tempo-core/1/work-attribute` пуст). Поэтому инструменты ходят в
`/rest/api/2/issue/{key}/worklog`, а не в `tempo-timesheets`.

- `jira_worklog_add` — время в формате Jira (`1h 30m`) или секундами; `started` в ISO 8601,
  без пояса берётся пояс машины, по умолчанию — сейчас. Повторный вызов спишет время ещё раз.
- `jira_worklogs` — по умолчанию только свои ворклоги, с фильтром по дням `date_from`/`date_to`.
- `jira_worklog_update`, `jira_worklog_delete` — только свои ворклоги; чужие отвергаются до записи.

### План недели

План — список строк `{key, time_spent, started, comment}`. `started` — день `ГГГГ-ММ-ДД`
или время в ISO 8601; строки одного дня без времени встают подряд с 09:00 в порядке плана.
Время в пакете уходит в Jira секундами, `1d` = 8 часов.

```
jira_worklog_check(entries)   только чтение: раскладка по дням + уже списанное, проблемы
        │ план поправлен, ok = true
jira_worklog_batch(entries)   одно подтверждение на весь план → запись строка за строкой
```

- Проверка ищет всё сразу: формат, задача существует и не переехала, повтор строки,
  21 день вперёд, 12 часов в день вместе с уже списанным на любые задачи.
- `jira_worklog_batch` повторяет проверку перед записью и при любой проблеме не пишет ничего.
- Jira не умеет транзакций: если строка упала (например, неделя утверждена), созданные
  до неё остаются, остальные не пишутся. В ответе — `created`, `failed_index`, `error`,
  `not_attempted`; лишнее удаляет `jira_worklog_delete`. Повтор вызова спишет время второй раз.

Ограничения Tempo из глобальной настройки: не больше 12 часов в день, не дальше 21 дня
в будущее, утверждение по неделям — в утверждённую неделю Jira не пустит.

### Пакет записей

`jira_batch` — разнотипные записи по одной или многим задачам с одним подтверждением
человека: пять закрытий, десять сборок и три комментария — это один вопрос, а не восемнадцать.
Операция — объект с полем `op` (`comment`, `update`, `build_fields`, `transition`) и полями
соответствующего одиночного инструмента:

```json
[
  {"op": "comment", "key": "8352", "body": "Проверено на dev-03"},
  {"op": "build_fields", "key": "8352", "backend": "zpas-core:1.4.0-rc.2"},
  {"op": "transition", "key": "8352", "transition_id": "31"}
]
```

- Весь пакет проверяется до вопроса: ключи, формат сборок, доступность перехода по
  `jira_transitions`, повторы, не больше 50 записей. Проблемы собираются списком по всем
  строкам; при любой из них не пишется ничего.
- Записи идут по порядку. Jira не умеет транзакций: при сбое созданное остаётся, в ответе
  `done`, `failed_index`, `error`, `not_attempted`.
- Списание времени сюда не входит — оно отдельно, через `jira_worklog_batch`.

## Предохранители

Каждый изменяющий инструмент Jira (`jira_comment_add`, `jira_update`, `jira_build_fields`,
`jira_transition`, `jira_batch`, `jira_worklog_add/batch/update/delete`) пишет только после «да» человека. Кто спрашивает,
зависит от клиента; `auth_status` показывает это в поле `confirm`.

| Клиент | Кто спрашивает |
|---|---|
| Claude Code (`claude-code`): терминал, вкладка Code десктопа, SDK | сам клиент окном разрешения: у инструментов `_meta["anthropic/requiresUserInteraction"]`, окно появляется на каждом вызове даже в `auto` и `bypassPermissions`, в `dontAsk` вызов отклоняется |
| другой клиент с elicitation (form) | сервер: вопрос, что именно запишется и куда |
| клиент без elicitation | никто: запись отклоняется, обхода через конфигурацию нет |

Claude Code сервер через elicitation не спрашивает: вкладка Code десктопа запускает его
в print mode, и он отменяет вопрос сам, не показывая диалог (в логе MCP —
`Elicitation request received in print mode`).

Вход проверяется до вопроса и в одиночных инструментах: `jira_transition` отвергает id,
которого нет в `jira_transitions`; `jira_worklog_delete`/`update` — чужой или несуществующий
ворклог; `jira_update` — пустые поля; `jira_build_fields` — неверный формат. Человека не
спрашивают о записи, которая заведомо не пройдёт.

Отказ или отмена возвращают ошибку «Человек не подтвердил
запись». На ревизии 2026-07-28 вопрос уходит в `InputRequiredResult`, клиент отвечает
повторным вызовом. `requestState` между раундами запечатан SDK (AEAD, привязан к
аргументам вызова, TTL 10 мин): сервер между раундами ничего не хранит. Поэтому текст вопроса не меняется между раундами: без `started` в нём
«сейчас», а не текущее время.

| Ключ `config.toml` / переменная | По умолчанию | Что делает |
|---|---|---|
| `socks` / `ZYFRA_JIRA_SOCKS` | не задан | `хост:порт` SOCKS5-прокси до Jira, например VPN-VM `192.168.250.2:1080`; имя Jira разрешает прокси. Без него — напрямую |
| — / `ZYFRA_JIRA_CONFIG` | `%LOCALAPPDATA%\zyfra-jira\config.toml` | другой путь к файлу настроек |

`jira.zyfra.com` видна только через корпоративный VPN. Если связи нет, инструмент отвечает
«Нет связи с jira.zyfra.com (путь): причина» и просит проверить VPN, а не перевыпускать токены.
`auth_status` в этом случае показывает `offline` и путь запросов в поле `route`.

### Что ещё проверяется

- Запросы с Bearer уходят только на хост `jira.zyfra.com`: URL вложения из ответа API
  проверяется, редирект на чужой хост запрещён.
- 401 — токен не принят. 403 — `AuthError` только при заголовке `X-Seraph-LoginReason`,
  иначе это «нет прав» и обычная ошибка: перевыпуск токена тут не поможет.
- HTML вместо JSON (страница логина или SSO) — тоже `AuthError`.
- Имена вложений: зарезервированные имена Windows (`CON`, `NUL`, …) получают префикс `_`,
  коллизии — суффикс ` (2)`. Одно битое вложение не рвёт скачивание остальных: оно уходит в `skipped`.
- Битый `config.toml` даёт понятную ошибку, а не падение инструмента.

## Разработка

```bash
uv run pytest
```

Тесты сети не трогают. `tests/test_stdio.py` поднимает сервер отдельным процессом
и проверяет согласование ревизии, состав инструментов и работу предохранителей.

Модули `src/zyfra_jira_mcp/`:

```
server.py       сборка: порядок импорта = порядок tools/list
app.py          экземпляр MCPServer, аннотации, общие типы
confirm.py      согласие человека (режимы host / elicitation / none)
ops.py          записи как подготовленные операции: проверить → описать → выполнить;
                общие для одиночных инструментов и jira_batch
tools_jira.py   инструменты Jira
tools_conf.py   инструменты Confluence
tools_auth.py   auth_status, auth_reload
worklog.py      ворклоги и план недели
batch.py        jira_batch
cli.py          команда zyfra-jira
serve.py        Streamable HTTP
http.py, jira.py, confluence.py, export.py, markdown.py,
creds.py, config.py, util.py, models.py   как были
```

В `stdout` не должно попадать ничего, кроме JSON-RPC: одна строка `print` рвёт
сессию целиком. Диагностика — через `stderr` или логирование SDK.

## Что осталось в навыках

Сервер — только точка доступа к Jira и Confluence: чтение, поиск, скачивание, выгрузка
в файл и запись с согласия человека. Про Obsidian и формат заметок он не знает ничего.

| Что | Где теперь |
|---|---|
| теги, вид заметки, перспектива по префиксу, рендер заметки задачи | `zpas-task-note/scripts/task_note.py` (agent-skills-work) |
| шаблон заметки задачи | `System/Templates/jira/task-note.j2` в Obsidian-RDS |
| чистка экспорта постановки, ссылки на вложения, ширина картинок | `zpas-spec-import/scripts/spec_clean.py` (agent-skills-work) |

Скрипты читают файлы `jira_export` и `conf_export`, поэтому большой JSON не проходит
через контекст модели. Сервер оставляет у себя только общий перевод в Markdown —
wiki Jira и view Confluence — он нужен и `jira_issue`, и `conf_page`.
