Metadata-Version: 2.3
Name: zyfra-jira-mcp
Version: 0.3.2
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`. Точный повтор возвращает сохранённый
  результат без новых POST, включая частичный результат. При неизвестном исходе отправки
  повтор блокируется до ручной сверки. Не меняйте строки ради повторной отправки.

Ограничения 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` — неверный формат. Человека не
спрашивают о записи, которая заведомо не пройдёт.

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

### Уже согласованный план в Codex

Сервер не видит переписку и не может проверить фразу «Фиксируем это». Имя `codex`
само по себе не разрешает запись. Аннотации MCP и разрешение вызова инструмента
[не заменяют подтверждение на сервере](https://developers.openai.com/plugins/build/mcp-server).
Причину старой общей ошибки «Человек не подтвердил» по одному её тексту установить нельзя.

Оператор может отдельно включить доверие аттестации конкретного клиента для
`jira_worklog_batch`: `trusted_plan_clients = "codex"` в `config.toml` или
`ZYFRA_JIRA_TRUSTED_PLAN_CLIENTS=codex`. Фактическое имя клиента показывает `auth_status`;
`plan_approval_trusted` показывает, включён ли этот путь. По умолчанию он выключен.
Это сознательное доверие клиенту, который обязан проверить явное согласие человека:
имя клиента и текст согласия не являются доказательством личности или криптографической подписью.
Не включайте настройку для недоверенных клиентов; HTTP-токен и конфигурация доступны
только доверенным локальным процессам. Остальные инструменты продолжают использовать
свои существующие подтверждения.

Порядок работы доверенного клиента:

1. Вызвать `jira_worklog_check(entries)`; при `ok=true` получить `plan_digest`.
2. Показать человеку весь точный план и получить явное согласие на запись.
   Если согласие уже есть в текущей переписке, проверить, что оно относится к этому плану.
3. Вызвать `jira_worklog_batch` с теми же `entries` и
   `approval={"plan_digest": "<digest из проверки>", "user_statement": "Фиксируем это"}`.
   Текст должен быть дословным согласием пользователя. Изменение ключа, времени,
   комментария или порядка строк требует новой проверки и согласования.

Это отдельный явный путь, не автоматический повтор после decline/cancel или ошибки
elicitation. Переданный `approval` не принимается от клиента вне списка доверия.
Без `approval` elicitation работает как прежде, даже для доверенного клиента.

Журнал `worklog-batches.sqlite3` лежит рядом с `config.toml`, общий для stdio и HTTP
с одной конфигурацией. Он привязан к владельцу Jira-токена и нормализованному плану.
Захват плана сохраняется до первого POST, результат — после завершения; падение процесса
между ними оставляет блокировку `batch_pending`. Не удаляйте журнал при обновлениях:
неизвестный исход сначала сверяют с Jira вручную, автоматического повтора нет.
Существующие собственные ворклоги с тем же ключом, моментом начала и длительностью
также блокируют пакет до записи. Это не транзакция Jira: независимые установки с
разными журналами не имеют общей блокировки. Списания до введения журнала требуют
сверки с Jira; один лишь старый ответ инструмента не создаёт запись в новом журнале.

| Ключ `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`.
