diff --git a/AGENTS.md b/AGENTS.md index c493309..b010a74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,11 +55,12 @@ site/ GitHub Pages landing page (Russian), deployed by .github/wor - **Account selection.** Without `KWORK_EXPECTED_USER_ID` the server serves the single account that login stored a token for (`load_server_config`); none or several stop startup with exit 2. The CLI never echoes argv. -- **Writes mode.** `KWORK_WRITES=confirm|auto|off` (default confirm). In confirm mode - `commit_write` asks the user through MCP elicitation when the client declares it - (`tools/write_tools.py`: imperative on handshake connections, `InputRequiredResult` on - 2026-07-28); otherwise the agent confirms in chat. off does not register prepare/commit. - The removed `KWORK_ENABLE_WRITES` stops startup (`reject_removed_settings`). +- **Writes mode.** `KWORK_WRITES=confirm|auto|off` (default confirm). confirm: the agent gets + the user's yes in chat (`prepare_write.confirmation=chat`, mode-specific server instructions); + the server cannot verify it. auto: the agent commits on its own. off: prepare/commit are not + registered. The removed `KWORK_ENABLE_WRITES` stops startup (`reject_removed_settings`). + MCP elicitation dialogs were tried in 1.5.0 and removed in 1.5.1: Claude Desktop lacks them + and Codex.app with `approval_policy = "never"` declines them on its own. - **Tools** get the gateway via `gateway_from_context(ctx)` and return a `ResultEnvelope` through `success()` / `failure()` / `unexpected_failure()`; `knowledge_state` is `known_data`, `known_empty` or `unknown_error`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 76bf765..21eab11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ ## [Unreleased] +## [1.5.1] - 2026-10-03 + +### Removed + +- Окно подтверждения отправки в клиенте (MCP elicitation) из 1.5.0. На практике + оно работало не везде: Claude Desktop его не поддерживает, а Codex.app в режиме + полного доступа (`approval_policy = "never"`) сам отвечает отказом, и отправка + становилась невозможной. Вместе с окном ушли ошибка `write_declined` и поле + `account_status.write_confirmation`. + +### Changed + +- `KWORK_WRITES=confirm` теперь работает одинаково во всех клиентах: агент + показывает точный текст, цену и получателя и отправляет только после вашего + «да» в чате (`prepare_write.confirmation` = `chat`). Сервер это «да» проверить + не может; вторую кнопку даёт разрешение на вызов инструмента в клиенте, в Codex + его включает `default_tools_approval_mode = "writes"`. Режимы `auto` и `off` + не изменились. + ## [1.5.0] - 2026-10-03 ### Added @@ -267,7 +286,8 @@ - Legacy `KWORK_TOKEN_FILE`, implicit `.env` loading and in-process-only limiter. - Experimental MCP Tasks and out-of-scope pipeline/business integrations. -[Unreleased]: https://github.com/simonether/kwork-mcp/compare/v1.5.0...HEAD +[Unreleased]: https://github.com/simonether/kwork-mcp/compare/v1.5.1...HEAD +[1.5.1]: https://github.com/simonether/kwork-mcp/releases/tag/v1.5.1 [1.5.0]: https://github.com/simonether/kwork-mcp/releases/tag/v1.5.0 [1.4.0]: https://github.com/simonether/kwork-mcp/releases/tag/v1.4.0 [1.3.0]: https://github.com/simonether/kwork-mcp/releases/tag/v1.3.0 diff --git a/README.md b/README.md index 145b20c..b357889 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,8 @@ `kwork-mcp` подключает ваш аккаунт [Kwork](https://kwork.ru) к ИИ-агенту: Claude Code, Claude Desktop, Codex, Cursor и другим MCP-клиентам. Агент ищет проекты на -бирже, читает диалоги, заказы и офферы, готовит отклики. По умолчанию каждую -отправку на Kwork вы подтверждаете сами: кнопкой в окне клиента или словом «да» -в чате. +бирже, читает диалоги, заказы и офферы, готовит отклики. По умолчанию агент +отправляет что-то на Kwork только после вашего «да» на точный текст. Сайт проекта: [simonether.github.io/kwork-mcp](https://simonether.github.io/kwork-mcp/). @@ -38,7 +37,7 @@ Windows не поддерживается. В обычном терминале: ```bash -uvx kwork-mcp@1.5.0 login +uvx kwork-mcp@1.5.1 login ``` Команда скрыто спросит логин и пароль Kwork, а также, по желанию, последние 4 цифры @@ -58,16 +57,16 @@ Desktop и Cursor. **Claude Code:** ```bash -claude mcp add kwork --scope user -- uvx kwork-mcp@1.5.0 +claude mcp add kwork --scope user -- uvx kwork-mcp@1.5.1 ``` **Codex:** ```bash -codex mcp add kwork -- uvx kwork-mcp@1.5.0 +codex mcp add kwork -- uvx kwork-mcp@1.5.1 ``` -**Cursor:** [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=kwork&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJrd29yay1tY3BAMS41LjAiXX0%3D) +**Cursor:** [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=kwork&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJrd29yay1tY3BAMS41LjEiXX0%3D)
Claude Desktop и другие клиенты @@ -80,7 +79,7 @@ Cursor без кнопки: `~/.cursor/mcp.json` или `.cursor/mcp.json` пр "mcpServers": { "kwork": { "command": "uvx", - "args": ["kwork-mcp@1.5.0"] + "args": ["kwork-mcp@1.5.1"] } } } @@ -102,9 +101,9 @@ JSON. Перезапустите клиент и попросите агента: «проверь статус аккаунта Kwork». Он вызовет `account_status` и покажет ваш `user_id` и имя. -Без клиента то же видно в терминале: `uvx kwork-mcp@1.5.0 status` покажет, с каким +Без клиента то же видно в терминале: `uvx kwork-mcp@1.5.1 status` покажет, с каким аккаунтом и сайтом запустится сервер, не обращаясь к Kwork. Сменить аккаунт: -`uvx kwork-mcp@1.5.0 logout`, затем снова `login`. +`uvx kwork-mcp@1.5.1 logout`, затем снова `login`. ## Отправка откликов и сообщений @@ -113,22 +112,22 @@ JSON. | `KWORK_WRITES` | Что происходит | |---|---| -| `confirm` (по умолчанию) | Перед каждой отправкой вы видите точный текст, цену и получателя и подтверждаете их | +| `confirm` (по умолчанию) | Перед каждой отправкой агент показывает точный текст, цену и получателя и ждёт вашего «да» | | `auto` | Агент отправляет сам, когда это нужно для вашей задачи, без отдельного подтверждения | | `off` | Только чтение: инструменты отправки скрыты, агент их не видит | -В режиме `confirm` Claude Code, Cursor и Codex показывают окно подтверждения: его -рисует сервер по сохранённой записи, и нажимаете в нём вы, а не агент. Отказ -ничего не отправляет. Клиенты без такого окна, например Claude Desktop, получают -от сервера указание показать вам текст в чате и отправлять только после вашего -«да». Какой способ действует, показывает `account_status`. Отметка диалога -прочитанным уходит без окна: её никто, кроме вас, не видит. +В режиме `confirm` сервер в ответе `prepare_write` и в своих инструкциях +указывает агенту показать вам запрос и отправлять только после вашего «да». Сам +проверить это «да» сервер не может. Вторую кнопку даёт клиент: Claude Code и +Claude Desktop по умолчанию спрашивают разрешение перед инструментом отправки, а в +Codex то же включает `default_tools_approval_mode = "writes"` в настройках +сервера `[mcp_servers.kwork]`. Режим выбирается в конфиге клиента, например для Claude Code: ```bash claude mcp remove kwork --scope user -claude mcp add kwork --scope user -e KWORK_WRITES=auto -- uvx kwork-mcp@1.5.0 +claude mcp add kwork --scope user -e KWORK_WRITES=auto -- uvx kwork-mcp@1.5.1 ``` В режиме `auto` от чужих команд в текстах проектов и сообщений защищает только @@ -148,7 +147,7 @@ claude mcp add kwork --scope user -e KWORK_WRITES=auto -- uvx kwork-mcp@1.5.0 `KWORK_SITE=com`. Например, для Claude Code вторым сервером рядом с kwork.ru: ```bash -claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.5.0 +claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.5.1 ``` Аккаунт и токен у kwork.ru и kwork.com общие, поэтому заново входить не нужно. @@ -161,13 +160,12 @@ claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.5.0 | Что видите | Что делать | |---|---| -| `auth_required` или `auth_expired` | Токен отсутствует или истёк: снова выполните `uvx kwork-mcp@1.5.0 login` и перезапустите клиент | -| Сервер не стартует: «аккаунт Kwork не подключён» | Выполните `uvx kwork-mcp@1.5.0 login` | -| Сервер не стартует: «подключено несколько аккаунтов Kwork» | Укажите нужный аккаунт в `KWORK_EXPECTED_USER_ID` или удалите лишний вход: `uvx kwork-mcp@1.5.0 logout ` | -| Непонятно, какой аккаунт и сайт использует сервер | `uvx kwork-mcp@1.5.0 status` покажет это без запросов к Kwork | +| `auth_required` или `auth_expired` | Токен отсутствует или истёк: снова выполните `uvx kwork-mcp@1.5.1 login` и перезапустите клиент | +| Сервер не стартует: «аккаунт Kwork не подключён» | Выполните `uvx kwork-mcp@1.5.1 login` | +| Сервер не стартует: «подключено несколько аккаунтов Kwork» | Укажите нужный аккаунт в `KWORK_EXPECTED_USER_ID` или удалите лишний вход: `uvx kwork-mcp@1.5.1 logout ` | +| Непонятно, какой аккаунт и сайт использует сервер | `uvx kwork-mcp@1.5.1 status` покажет это без запросов к Kwork | | Сервер не стартует, «некорректная конфигурация: …» | Проверьте названные переменные `KWORK_*` в конфиге клиента | | «KWORK_ENABLE_WRITES удалена в 1.5.0» | Замените её на `KWORK_WRITES=off`, `confirm` или `auto` | -| `write_declined` | Отправку отклонили в окне подтверждения, на Kwork ничего не ушло | | `write_disabled` | Сервер запущен с `KWORK_WRITES=off`, доступно только чтение | | `captcha` | Войдите в Kwork в браузере, пройдите капчу, затем повторите `login` | | Claude Desktop не видит сервер | Возьмите блок для Claude Desktop из вывода `login` (в нём полный путь к `uvx`) или укажите путь из `which uvx`, затем перезапустите приложение | @@ -177,9 +175,9 @@ claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.5.0 Ручная фиксация исхода запускается с теми же `KWORK_*` переменными, что у сервера: ```bash -uvx kwork-mcp@1.5.0 pending-writes -uvx kwork-mcp@1.5.0 resolve-write succeeded # операция на Kwork прошла -uvx kwork-mcp@1.5.0 resolve-write absent # операции на Kwork нет +uvx kwork-mcp@1.5.1 pending-writes +uvx kwork-mcp@1.5.1 resolve-write succeeded # операция на Kwork прошла +uvx kwork-mcp@1.5.1 resolve-write absent # операции на Kwork нет ``` ## Инструменты diff --git a/docs/architecture.md b/docs/architecture.md index e752aca..a5c3732 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -69,8 +69,8 @@ shared route limiter/circuit для `signIn`/`actor`, не удаляет ста Первый подтверждённый `user_id` закрепляется на срок жизни process: ни 401, ни peer rotation не могут незаметно переключить MCP на другой account. Writes требуют: -1. `KWORK_WRITES` не равен `off` (в режиме `confirm` клиент с окном подтверждения - дополнительно спрашивает пользователя перед `commit_write`); +1. `KWORK_WRITES` не равен `off` (в режиме `confirm` агент перед `commit_write` + получает «да» пользователя); 2. привязанный аккаунт (`KWORK_EXPECTED_USER_ID` или единственный сохранённый); 3. соответствие свежего `get_me` ожидаемому ID; 4. соответствие `KWORK_EXPECTED_USERNAME`, если он задан. diff --git a/docs/configuration.md b/docs/configuration.md index e0766a1..6a2de94 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -149,27 +149,19 @@ sleep сверх `RETRY_BACKOFF_MAX`; shared circuit никогда не отк `KWORK_WRITES` определяет, кто решает, уйдёт ли подготовленная запись на Kwork: -- `confirm` (по умолчанию). Если клиент объявил возможность `elicitation` в форме - (Claude Code, Cursor, Codex), `commit_write` перед отправкой показывает - пользователю окно с текстом из сохранённой записи; агент не может ни подменить - этот текст, ни ответить за пользователя. На соединениях 2025-11-25 и раньше окно - открывается запросом `elicitation/create` посреди вызова, на 2026-07-28 - `commit_write` возвращает `InputRequiredResult`, и клиент повторяет вызов с - ответом; запечатанный `request_state` привязывает ответ к `write_id` и - `payload_hash`. Отказ или закрытие окна переводит запись в `failed_known` с - `write_declined` без обращения к Kwork; сбой клиента при показе окна оставляет - запись `prepared`. Окно не показывается для записи, которую `commit` всё равно - не отправит (не `prepared`, истёк TTL, другая запись в `submission_unknown`), и - для `mark_dialog_read`. Клиент без `elicitation` (Claude Desktop) получает в - `prepare_write.confirmation` значение `chat` и указание запросить «да» в чате: - сервер не может проверить такое подтверждение. -- `auto`. Агент вызывает `commit_write` сам, окна нет. +- `confirm` (по умолчанию). `prepare_write` возвращает `confirmation: chat`, а + инструкции сервера требуют показать пользователю точный текст, цену и получателя + и вызывать `commit_write` только после его явного «да». Сервер не может проверить + это подтверждение; дополнительную кнопку даёт разрешение на вызов инструмента в + клиенте (в Codex `default_tools_approval_mode = "writes"`). +- `auto`. `prepare_write` возвращает `confirmation: none`, агент вызывает + `commit_write` сам. - `off`. Только чтение: `prepare_write` и `commit_write` не регистрируются, `get_write_status` и `reconcile_write` остаются. -`account_status.writes` показывает режим, `account_status.write_confirmation` -показывает, как подтверждается отправка в этом клиенте: `client`, `chat`, `none` -или `off`. Переменная `KWORK_ENABLE_WRITES` удалена в 1.5.0: сервер с ней не +`account_status.writes` показывает режим. Подтверждение окном клиента через MCP +elicitation было в 1.5.0 и убрано в 1.5.1: Claude Desktop его не поддерживает, а +Codex.app с `approval_policy = "never"` отклоняет такие запросы сам. Переменная `KWORK_ENABLE_WRITES` удалена в 1.5.0: сервер с ней не запускается и подсказывает замену, чтобы прежнее `false` не превратилось молча в отправку с подтверждением. diff --git a/docs/security.md b/docs/security.md index 68edec6..572d7e8 100644 --- a/docs/security.md +++ b/docs/security.md @@ -99,13 +99,11 @@ Credential files защищены permissions, но не application-level encry ## Write safety Write разрешён только после свежей проверки ожидаемого account ID. В режиме -`KWORK_WRITES=confirm` клиент с `elicitation` показывает пользователю окно с -текстом из ledger перед remote boundary; согласие, полученное для другой записи, -не принимается (ответ привязан к `write_id` и `payload_hash`), управляющие и bidi -символы в окне показываются как escape-последовательности. Без `elicitation` -подтверждение остаётся на агенте, а `KWORK_WRITES=auto` его отключает: тогда -защиту от prompt injection во внешних текстах даёт только поведение агента. -Prepare выполняет +`KWORK_WRITES=confirm` подтверждение пользователя запрашивает агент, сервер его не +проверяет; дополнительный барьер даёт разрешение на вызов инструмента в клиенте. +`KWORK_WRITES=auto` снимает и это требование: тогда защиту от prompt injection во +внешних текстах даёт только поведение агента. `KWORK_WRITES=off` не регистрирует +инструменты отправки вовсе. Prepare выполняет action-specific read preflight и сохраняет canonical exact payload. Commit требует совпадения `write_id`, SHA-256 `payload_hash` и confirmation token до TTL. diff --git a/scripts/smoke-wheel.sh b/scripts/smoke-wheel.sh index 87e6408..4606264 100644 --- a/scripts/smoke-wheel.sh +++ b/scripts/smoke-wheel.sh @@ -24,7 +24,7 @@ import kwork_mcp from kwork_mcp.bootstrap import run_bootstrap_cli from kwork_mcp.server import create_server -assert kwork_mcp.__version__ == "1.5.0" +assert kwork_mcp.__version__ == "1.5.1" assert callable(run_bootstrap_cli) create_server() scripts = { @@ -39,9 +39,9 @@ assert scripts == { PY "${runner[@]}" kwork-mcp-bootstrap --help >/dev/null -[[ "$("${runner[@]}" kwork-mcp-bootstrap --version)" == "1.5.0" ]] +[[ "$("${runner[@]}" kwork-mcp-bootstrap --version)" == "1.5.1" ]] "${runner[@]}" kwork-mcp --help >/dev/null -[[ "$("${runner[@]}" kwork-mcp --version)" == "1.5.0" ]] +[[ "$("${runner[@]}" kwork-mcp --version)" == "1.5.1" ]] set +e "${runner[@]}" kwork-mcp login login.stdout 2>login.stderr @@ -56,7 +56,7 @@ KWORK_STATE_DIR="${smoke_dir}/state" "${runner[@]}" kwork-mcp status >status.std status_status="$?" set -e [[ "$status_status" -eq 2 ]] -grep -q "kwork-mcp 1.5.0" status.stdout +grep -q "kwork-mcp 1.5.1" status.stdout [[ ! -e "${smoke_dir}/state" ]] set +e diff --git a/server.json b/server.json index 7f48f47..1920f2d 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "io.github.simonether/kwork-mcp", "title": "Kwork", "description": "Account-bound, typed and durable stdio MCP gateway for the Kwork freelance marketplace", - "version": "1.5.0", + "version": "1.5.1", "websiteUrl": "https://github.com/simonether/kwork-mcp", "repository": { "url": "https://github.com/simonether/kwork-mcp", @@ -13,7 +13,7 @@ { "registryType": "pypi", "identifier": "kwork-mcp", - "version": "1.5.0", + "version": "1.5.1", "runtimeHint": "uvx", "transport": { "type": "stdio" diff --git a/site/index.html b/site/index.html index 195ab94..5db62d2 100644 --- a/site/index.html +++ b/site/index.html @@ -57,7 +57,7 @@ "image": "https://simonether.github.io/kwork-mcp/og.png", "applicationCategory": "DeveloperApplication", "operatingSystem": "macOS, Linux", - "softwareVersion": "1.5.0", + "softwareVersion": "1.5.1", "softwareRequirements": "Python 3.12–3.14, uv", "programmingLanguage": "Python", "inLanguage": "ru", @@ -81,10 +81,10 @@ "@type": "FAQPage", "mainEntity": [ {"@type": "Question", "name": "Это официальный инструмент Kwork?", "acceptedAnswer": {"@type": "Answer", "text": "Нет. Это независимый проект с открытым кодом под лицензией MIT. Kwork его не выпускает и не поддерживает."}}, - {"@type": "Question", "name": "Может ли агент откликнуться на проект без меня?", "acceptedAnswer": {"@type": "Answer", "text": "Только если вы сами это разрешили. По умолчанию каждую отправку вы подтверждаете: в Claude Code, Cursor и Codex кнопкой в окне, которое показывает сервер, в Claude Desktop словом «да» в чате. С KWORK_WRITES=auto агент отправляет сам, с KWORK_WRITES=off только читает."}}, + {"@type": "Question", "name": "Может ли агент откликнуться на проект без меня?", "acceptedAnswer": {"@type": "Answer", "text": "Только если вы сами это разрешили. По умолчанию агент показывает точный текст, цену и получателя и отправляет только после вашего «да». С KWORK_WRITES=auto он отправляет сам, с KWORK_WRITES=off только читает."}}, {"@type": "Question", "name": "Что будет, если связь оборвётся при отправке?", "acceptedAnswer": {"@type": "Answer", "text": "Сервер не повторит запись сам и не угадает исход. Новые отправки для аккаунта ждут, пока агент не сверит результат с Kwork или вы не разберёте запись вручную командой kwork-mcp resolve-write."}}, {"@type": "Question", "name": "Где хранятся логин и пароль?", "acceptedAnswer": {"@type": "Answer", "text": "Нигде. Их спрашивает только kwork-mcp login через скрытый ввод, а на диск сохраняется токен в ~/.local/state/kwork-mcp с правами 0600. Токен не шифруется, поэтому включите шифрование диска."}}, - {"@type": "Question", "name": "Как сменить аккаунт?", "acceptedAnswer": {"@type": "Answer", "text": "Выполните в терминале uvx kwork-mcp@1.5.0 logout, а затем login с другим аккаунтом. Команда status покажет, с каким аккаунтом и сайтом запустится сервер, без запросов к Kwork."}}, + {"@type": "Question", "name": "Как сменить аккаунт?", "acceptedAnswer": {"@type": "Answer", "text": "Выполните в терминале uvx kwork-mcp@1.5.1 logout, а затем login с другим аккаунтом. Команда status покажет, с каким аккаунтом и сайтом запустится сервер, без запросов к Kwork."}}, {"@type": "Question", "name": "Какие клиенты поддерживаются?", "acceptedAnswer": {"@type": "Answer", "text": "Claude Code, Claude Desktop, Codex, Cursor и другие MCP-клиенты, которые умеют запускать локальный сервер через stdio."}}, {"@type": "Question", "name": "Работает ли на Windows?", "acceptedAnswer": {"@type": "Answer", "text": "Нет, только macOS и Linux."}}, {"@type": "Question", "name": "Сколько стоит?", "acceptedAnswer": {"@type": "Answer", "text": "Нисколько: kwork-mcp распространяется бесплатно под лицензией MIT."}} @@ -136,10 +136,10 @@

MCP-сервер для Kwork

Установить
- uvx kwork-mcp@1.5.0 login + uvx kwork-mcp@1.5.1 login
-

Бесплатно и с открытым кодом (MIT). macOS и Linux, Python 3.12–3.14. Версия 1.5.0 на PyPI.

+

Бесплатно и с открытым кодом (MIT). macOS и Linux, Python 3.12–3.14. Версия 1.5.1 на PyPI.

@@ -233,7 +233,7 @@

Чем заканчивается отправка

  • Каждая отправка с вашего «да»

    -

    Перед отправкой вы видите точный текст, цену и получателя. В Claude Code, Cursor и Codex окно показывает сам сервер, и нажимаете в нём вы, а не агент.

    +

    Перед отправкой агент показывает точный текст, цену и получателя и ждёт вашего ответа. Большинство клиентов вдобавок спрашивают разрешение на вызов отправки.

  • Пароль не попадает в конфиг

    @@ -264,7 +264,7 @@

    Войдите в Kwork (один раз)

    Запустите в обычном терминале:

    терминал
    -
    uvx kwork-mcp@1.5.0 login
    +
    uvx kwork-mcp@1.5.1 login

    Команда скрыто спросит логин и пароль Kwork, покажет найденный аккаунт и после подтверждения сохранит токен в ~/.local/state/kwork-mcp. В конце она напечатает готовые команды подключения, а для Claude Desktop — блок с полным путём к uvx.

  • @@ -276,19 +276,19 @@

    Подключите агента

    Claude Code

    терминал
    -
    claude mcp add kwork --scope user -- uvx kwork-mcp@1.5.0
    +
    claude mcp add kwork --scope user -- uvx kwork-mcp@1.5.1

    Codex

    терминал
    -
    codex mcp add kwork -- uvx kwork-mcp@1.5.0
    +
    codex mcp add kwork -- uvx kwork-mcp@1.5.1

    Cursor

    -

    Добавить в Cursor

    +

    Добавить в Cursor

    Кнопка откроет Cursor и предложит добавить сервер. Вручную: блок из вкладки «Claude Desktop» в ~/.cursor/mcp.json.

    @@ -300,7 +300,7 @@

    Claude Desktop

    "mcpServers": { "kwork": { "command": "uvx", - "args": ["kwork-mcp@1.5.0"] + "args": ["kwork-mcp@1.5.1"] } } } @@ -312,7 +312,7 @@

    Claude Desktop

  • Проверьте

    -

    Перезапустите клиент и попросите агента: проверь статус аккаунта Kwork. Он покажет ваш user_id и имя. Без клиента то же видно в терминале: uvx kwork-mcp@1.5.0 status.

    +

    Перезапустите клиент и попросите агента: проверь статус аккаунта Kwork. Он покажет ваш user_id и имя. Без клиента то же видно в терминале: uvx kwork-mcp@1.5.1 status.

    Каждую отправку агент сначала покажет вам на подтверждение. Чтобы он отправлял сам, добавьте в конфиг KWORK_WRITES=auto, для одного только чтения — KWORK_WRITES=off (в Claude Code: -e KWORK_WRITES=off). Подробности, решение проблем и ручная сверка отправок описаны в README.

  • @@ -400,7 +400,7 @@

    Вопросы

Может ли агент откликнуться на проект без меня? -

Только если вы сами это разрешили. По умолчанию каждую отправку вы подтверждаете: в Claude Code, Cursor и Codex кнопкой в окне, которое показывает сервер, в Claude Desktop словом «да» в чате. С KWORK_WRITES=auto агент отправляет сам, с KWORK_WRITES=off только читает.

+

Только если вы сами это разрешили. По умолчанию агент показывает точный текст, цену и получателя и отправляет только после вашего «да». С KWORK_WRITES=auto он отправляет сам, с KWORK_WRITES=off только читает.

Что будет, если связь оборвётся при отправке? @@ -412,7 +412,7 @@

Вопросы

Как сменить аккаунт? -

Выполните в терминале uvx kwork-mcp@1.5.0 logout, а затем login с другим аккаунтом. Команда status покажет, с каким аккаунтом и сайтом запустится сервер, без запросов к Kwork.

+

Выполните в терминале uvx kwork-mcp@1.5.1 logout, а затем login с другим аккаунтом. Команда status покажет, с каким аккаунтом и сайтом запустится сервер, без запросов к Kwork.

Какие клиенты поддерживаются? diff --git a/src/kwork_mcp/bootstrap.py b/src/kwork_mcp/bootstrap.py index 601e777..293c1ff 100644 --- a/src/kwork_mcp/bootstrap.py +++ b/src/kwork_mcp/bootstrap.py @@ -615,7 +615,7 @@ async def _run_write_admin( _SITE_URL = "https://simonether.github.io/kwork-mcp/#start" _WRITES_STATUS = { - "confirm": "Отправка: с подтверждением каждой (окно в клиенте или «да» в чате)", + "confirm": "Отправка: с подтверждением каждой, агент ждёт вашего «да»", "auto": "Отправка: агент отправляет сам, без подтверждения (KWORK_WRITES=auto)", "off": "Отправка: выключена, только чтение (KWORK_WRITES=off)", } diff --git a/src/kwork_mcp/coordination.py b/src/kwork_mcp/coordination.py index 0b2d8b4..3dd7862 100644 --- a/src/kwork_mcp/coordination.py +++ b/src/kwork_mcp/coordination.py @@ -897,88 +897,6 @@ def operation(conn: sqlite3.Connection) -> StoredWrite | None: return await self._async(lambda: self._transaction(operation)) - async def check_write_credentials( - self, - *, - write_id: str, - scope: str, - payload_hash: str, - confirmation_token: str, - ) -> StoredWrite: - """Verify commit credentials without claiming or changing the write.""" - - confirmation_hash = hashlib.sha256(confirmation_token.encode()).hexdigest() - - def operation(conn: sqlite3.Connection) -> StoredWrite: - row = conn.execute( - "SELECT * FROM writes WHERE write_id=? AND scope=?", - (write_id, scope), - ).fetchone() - if row is None: - raise GatewayError(ErrorCode.NOT_FOUND, diagnostic="write_id_not_found") - record = self._row_to_write(row) - if not hmac.compare_digest(record.payload_hash, payload_hash) or not hmac.compare_digest( - str(row["confirmation_hash"]), - confirmation_hash, - ): - raise GatewayError( - ErrorCode.INVALID_CONFIRMATION, - diagnostic="write_confirmation_mismatch", - ) - return record - - return await self._async(lambda: self._transaction(operation)) - - async def decline_write( - self, - *, - write_id: str, - scope: str, - payload_hash: str, - confirmation_token: str, - error: ErrorInfo, - ) -> StoredWrite: - """Settle a prepared write the user refused; nothing reached Kwork. - - Only a still-prepared, unexpired write changes. Anything else is - returned as it is, so a concurrent commit or expiry wins. - """ - - now = time.time() - confirmation_hash = hashlib.sha256(confirmation_token.encode()).hexdigest() - error_json = error.model_dump_json(exclude_none=True, by_alias=True) - - def operation(conn: sqlite3.Connection) -> StoredWrite: - row = conn.execute( - "SELECT * FROM writes WHERE write_id=? AND scope=?", - (write_id, scope), - ).fetchone() - if row is None: - raise GatewayError(ErrorCode.NOT_FOUND, diagnostic="write_id_not_found") - record = self._row_to_write(row) - if not hmac.compare_digest(record.payload_hash, payload_hash) or not hmac.compare_digest( - str(row["confirmation_hash"]), - confirmation_hash, - ): - raise GatewayError( - ErrorCode.INVALID_CONFIRMATION, - diagnostic="write_confirmation_mismatch", - ) - if record.state is not WriteState.PREPARED or record.expires_at <= now: - return record - conn.execute( - "UPDATE writes SET state=?,updated_at=?,error_json=? WHERE write_id=?", - (WriteState.FAILED_KNOWN.value, now, error_json, write_id), - ) - conn.execute( - "INSERT INTO write_events(write_id,state,occurred_at,note) VALUES(?,?,?,?)", - (write_id, WriteState.FAILED_KNOWN.value, now, "declined_by_user"), - ) - updated = conn.execute("SELECT * FROM writes WHERE write_id=?", (write_id,)).fetchone() - return self._row_to_write(updated) - - return await self._async(lambda: self._transaction(operation)) - async def claim_write( self, *, diff --git a/src/kwork_mcp/errors.py b/src/kwork_mcp/errors.py index fab0664..7d3d293 100644 --- a/src/kwork_mcp/errors.py +++ b/src/kwork_mcp/errors.py @@ -63,7 +63,6 @@ ErrorCode.INVALID_CONFIRMATION: "Подтверждение не соответствует подготовленной операции.", ErrorCode.WRITE_IN_PROGRESS: "Эту операцию уже выполняет другой процесс.", ErrorCode.WRITE_DISABLED: "Отправка выключена: сервер запущен с KWORK_WRITES=off, доступно только чтение.", - ErrorCode.WRITE_DECLINED: "Пользователь не подтвердил отправку в окне клиента; на Kwork ничего не ушло.", ErrorCode.NOT_FOUND: "Запрошенный объект Kwork не найден.", ErrorCode.VALIDATION: "Параметры операции не прошли проверку.", ErrorCode.SITE_UNSUPPORTED: "Операция недоступна на выбранном сайте Kwork: биржа проектов есть только на kwork.ru.", diff --git a/src/kwork_mcp/gateway/writes.py b/src/kwork_mcp/gateway/writes.py index 41d0897..845d98b 100644 --- a/src/kwork_mcp/gateway/writes.py +++ b/src/kwork_mcp/gateway/writes.py @@ -12,7 +12,6 @@ from loguru import logger from pydantic import JsonValue, TypeAdapter, ValidationError -from kwork_mcp.config import contains_unsafe_text_codepoint from kwork_mcp.coordination import StoredWrite from kwork_mcp.errors import AmbiguousWriteError, ContractDriftError, GatewayError from kwork_mcp.gateway.actions import ACTION_HANDLERS @@ -44,60 +43,6 @@ ) -def _visible(text: object) -> str: - """Show controls and bidi overrides as escapes, so the dialog shows what is sent.""" - - return "".join( - f"\\u{ord(char):04x}" if contains_unsafe_text_codepoint(char) and char != "\n" else char for char in str(text) - ) - - -def _rubles(amount: object) -> str: - return f"{amount:,}".replace(",", "\u00a0") if isinstance(amount, int) else _visible(amount) - - -def confirmation_message(record: StoredWrite) -> str: - """The text a person confirms before commit_write sends this write.""" - - payload = json.loads(record.payload_json) - request = payload.get("request") if isinstance(payload, dict) else None - if not isinstance(request, dict): - raise ContractDriftError("stored_write_request_invalid") - site = record.prepared_site - get = request.get - match record.action: - case WriteAction.SUBMIT_OFFER: - body = ( - f"Отклик на проект {_visible(get('project_id'))}\n" - f"Название: {_visible(get('title'))}\n" - f"Цена: {_rubles(get('price'))} ₽, срок {_visible(get('duration_days'))} дн.\n\n" - f"{_visible(get('description'))}" - ) - case WriteAction.DELETE_OFFER: - body = f"Удалить отклик {_visible(get('offer_id'))}" - case WriteAction.SEND_MESSAGE: - recipient = get("username") or f"user_id {get('user_id')}" - body = f"Сообщение для {_visible(recipient)}:\n\n{_visible(get('text'))}" - case WriteAction.EDIT_MESSAGE: - body = ( - f"Изменить сообщение {_visible(get('message_id'))} в диалоге с {_visible(get('username'))}. " - f"Новый текст:\n\n{_visible(get('text'))}" - ) - case WriteAction.DELETE_MESSAGE: - body = f"Удалить сообщение {_visible(get('message_id'))} в диалоге с {_visible(get('username'))}" - case WriteAction.SUBMIT_ORDER_APPROVAL: - files = get("file_ids") or [] - body = f"Сдать заказ {_visible(get('order_id'))} на проверку" + ( - f", файлов: {len(files)}" if isinstance(files, list) and files else "" - ) - case WriteAction.SET_KWORK_STATE: - verb = "Запустить" if get("target_state") == "active" else "Поставить на паузу" - body = f"{verb} кворк {_visible(get('kwork_id'))}" - case _: - body = _visible(json.dumps(request, ensure_ascii=False, sort_keys=True)) - return f"Отправить на kwork.{_visible(site)}?\n\n{body}" - - def _preflight_failure(error: GatewayError) -> GatewayError: """Read-only checks that cannot conclude are retryable, never reconcilable.""" @@ -438,59 +383,6 @@ async def _load_write_record( return record raise - async def pending_confirmation( - self, - *, - write_id: str, - payload_hash: str, - confirmation_token: str, - ) -> StoredWrite | None: - """The write to show the user before commit, or None when commit would not send now. - - A stored result, an expired or replayed write, a blocking unresolved - write and mark_dialog_read (nothing anyone else sees) skip the dialog, - so the user is never asked about something commit then refuses. - """ - - scope = self.session.scope - record = await self.coordinator.check_write_credentials( - write_id=write_id, - scope=scope, - payload_hash=payload_hash, - confirmation_token=confirmation_token, - ) - self._require_prepared_site(record) - if ( - record.state is not WriteState.PREPARED - or record.expires_at <= time.time() - or record.action is WriteAction.MARK_DIALOG_READ - ): - return None - unresolved = await self.coordinator.list_unresolved_writes(scope) - if any(other.write_id != write_id for other in unresolved): - return None - return record - - async def decline_write( - self, - *, - write_id: str, - payload_hash: str, - confirmation_token: str, - correlation_id: str, - ) -> WriteStatusData: - """Record that the user refused this write in the client dialog.""" - - declined = GatewayError(ErrorCode.WRITE_DECLINED, diagnostic="declined_in_client") - record = await self.coordinator.decline_write( - write_id=write_id, - scope=self.session.scope, - payload_hash=payload_hash, - confirmation_token=confirmation_token, - error=declined.to_info(correlation_id), - ) - return self._write_status(record, correlation_id=correlation_id) - async def commit_write( self, *, diff --git a/src/kwork_mcp/models.py b/src/kwork_mcp/models.py index b8c9b7b..ddff5fb 100644 --- a/src/kwork_mcp/models.py +++ b/src/kwork_mcp/models.py @@ -52,7 +52,6 @@ class ErrorCode(StrEnum): INVALID_CONFIRMATION = "invalid_confirmation" WRITE_IN_PROGRESS = "write_in_progress" WRITE_DISABLED = "write_disabled" - WRITE_DECLINED = "write_declined" NOT_FOUND = "not_found" VALIDATION = "validation" SITE_UNSUPPORTED = "site_unsupported" @@ -61,7 +60,7 @@ class ErrorCode(StrEnum): INTERNAL = "internal" -WriteConfirmation = Literal["client", "chat", "none", "off"] +WriteConfirmation = Literal["chat", "none"] class ErrorInfo(BaseModel): @@ -123,12 +122,8 @@ class AccountData(BaseModel): expected_username: str | None binding_state: Literal["bound", "unbound_reads_only"] writes: Literal["confirm", "auto", "off"] = Field( - description="KWORK_WRITES: confirm asks the user before each send, auto lets the agent send, off is read-only.", - ) - write_confirmation: WriteConfirmation | None = Field( - default=None, - description="How this client confirms a send: client (a dialog in the MCP client), chat (the agent asks " - "in the conversation), none (auto), off (no sends).", + description="KWORK_WRITES: confirm needs the user's yes before each send, auto lets the agent send, " + "off is read-only.", ) write_ready: bool unresolved_write_ids: list[str] = Field( @@ -412,9 +407,8 @@ class WriteStatusData(BaseModel): confirmation_token: str | None = None confirmation: WriteConfirmation | None = Field( default=None, - description="How commit_write will be confirmed: client means the server shows the user a dialog itself, " - "so do not ask again in chat; chat means show the exact payload and commit only after the user's explicit " - "yes; none means KWORK_WRITES=auto.", + description="Who confirms commit_write: chat means show the user the exact text, price and recipient and " + "commit only after their explicit yes; none means KWORK_WRITES=auto, the agent may commit on its own.", ) result: dict[str, JsonValue] | None = None terminal_error: ErrorInfo | None = None diff --git a/src/kwork_mcp/server.py b/src/kwork_mcp/server.py index 18b6212..9d2c742 100644 --- a/src/kwork_mcp/server.py +++ b/src/kwork_mcp/server.py @@ -33,11 +33,9 @@ _WRITES = { "confirm": ( "Отправки на Kwork выполняются только через prepare_write → commit_write с точным payload_hash и " - "confirmation_token. Поле confirmation в ответе prepare_write говорит, кто подтверждает отправку: client — " - "при commit_write сервер сам покажет пользователю окно с точным текстом, отдельно спрашивать в чате не нужно; " - "chat — покажите пользователю точный текст, цену и получателя и вызывайте commit_write только после его " - "явного «да». Ошибка write_declined значит, что пользователь отказался: не повторяйте эту отправку без его " - "новой просьбы." + "confirmation_token. Перед commit_write покажите пользователю точный текст, цену и получателя из ответа " + "prepare_write и вызывайте commit_write только после его явного «да» на эту отправку. Если пользователь " + "отказался, не отправляйте без его новой просьбы." ), "auto": ( "Отправки на Kwork выполняются только через prepare_write → commit_write с точным payload_hash и " diff --git a/src/kwork_mcp/tools/common.py b/src/kwork_mcp/tools/common.py index b14034b..69f71ce 100644 --- a/src/kwork_mcp/tools/common.py +++ b/src/kwork_mcp/tools/common.py @@ -62,19 +62,10 @@ def gateway_from_context(ctx: Context) -> KworkGateway: return gateway -def write_confirmation(ctx: Context, writes: str) -> WriteConfirmation: - """How a send is confirmed for this client under KWORK_WRITES.""" - - if writes == "off": - return "off" - if writes == "auto": - return "none" - capabilities = ctx.session.client_capabilities - elicitation = capabilities.elicitation if capabilities is not None else None - # An empty elicitation capability predates the form/url split and means form. - if elicitation is not None and (elicitation.form is not None or elicitation.url is None): - return "client" - return "chat" +def write_confirmation(writes: str) -> WriteConfirmation: + """Who confirms a send under KWORK_WRITES: the user in chat, or nobody (auto).""" + + return "none" if writes == "auto" else "chat" def correlation_id() -> str: diff --git a/src/kwork_mcp/tools/read_tools.py b/src/kwork_mcp/tools/read_tools.py index 20cf66b..dd6e523 100644 --- a/src/kwork_mcp/tools/read_tools.py +++ b/src/kwork_mcp/tools/read_tools.py @@ -30,7 +30,6 @@ gateway_from_context, success, unexpected_failure, - write_confirmation, ) AccountOutcome = ResultEnvelope[AccountData] @@ -64,9 +63,7 @@ async def account_status(ctx: Context) -> ToolResult: """ correlation = correlation_id() try: - gateway = gateway_from_context(ctx) - data = await gateway.account_status() - data = data.model_copy(update={"write_confirmation": write_confirmation(ctx, gateway.config.writes)}) + data = await gateway_from_context(ctx).account_status() return success( AccountOutcome, data=data, diff --git a/src/kwork_mcp/tools/write_tools.py b/src/kwork_mcp/tools/write_tools.py index 6432bdf..4d1ef3d 100644 --- a/src/kwork_mcp/tools/write_tools.py +++ b/src/kwork_mcp/tools/write_tools.py @@ -2,17 +2,13 @@ from __future__ import annotations -from typing import Annotated, Any +from typing import Annotated from fastmcp import Context, FastMCP from fastmcp.tools import ToolResult -from loguru import logger -from mcp.types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult -from mcp_types.version import MODERN_PROTOCOL_VERSIONS from pydantic import StringConstraints from kwork_mcp.errors import GatewayError -from kwork_mcp.gateway.writes import confirmation_message from kwork_mcp.models import ( ErrorCode, IdempotencyKey, @@ -35,54 +31,6 @@ WriteOutcome = ResultEnvelope[WriteStatusData] -_CONFIRM_KEY = "kwork_send" -_CONFIRM_SCHEMA: dict[str, Any] = { - "type": "object", - "properties": {"send": {"type": "boolean", "title": "Отправить", "default": True}}, - "required": ["send"], -} - - -def _accepted(answer: object) -> bool: - return ( - isinstance(answer, ElicitResult) - and answer.action == "accept" - and isinstance(answer.content, dict) - and answer.content.get("send") is True - ) - - -async def _ask_user(ctx: Context, message: str, state: str) -> bool | InputRequiredResult | None: - """Ask the person in the client to confirm; None means the client failed to ask. - - On 2026-07-28 connections the server cannot push a request mid-call, so the - tool returns an input request and the client calls again with the answer. - The sealed request_state binds that answer to this write and payload hash. - """ - - if ctx.session.protocol_version in MODERN_PROTOCOL_VERSIONS: - responses = ctx.input_responses - if responses is None or ctx.request_state != state or _CONFIRM_KEY not in responses: - return InputRequiredResult( - input_requests={ - _CONFIRM_KEY: ElicitRequest( - params=ElicitRequestFormParams(message=message, requested_schema=_CONFIRM_SCHEMA) - ) - }, - request_state=state, - ) - return _accepted(responses[_CONFIRM_KEY]) - try: - answer = await ctx.session.elicit( - message=message, - requested_schema=_CONFIRM_SCHEMA, - related_request_id=ctx.request_id, - ) - except Exception as exc: - logger.warning("write_confirmation_unavailable error={}", type(exc).__name__) - return None - return _accepted(answer) - def register(mcp: FastMCP, *, writes: str | None = None) -> None: """Register the write protocol; KWORK_WRITES=off leaves only status and reconcile.""" @@ -121,7 +69,7 @@ async def prepare_write( idempotency_key, correlation_id=correlation, ) - status = status.model_copy(update={"confirmation": write_confirmation(ctx, gateway.config.writes)}) + status = status.model_copy(update={"confirmation": write_confirmation(gateway.config.writes)}) return write_result(WriteOutcome, status=status, correlation=correlation) except GatewayError as error: return failure(WriteOutcome, error=error, correlation=correlation) @@ -157,40 +105,19 @@ async def commit_write( StringConstraints(strip_whitespace=True, min_length=32, max_length=256), ], ctx: Context, - ) -> ToolResult | InputRequiredResult: + ) -> ToolResult: """Выполнить ровно подготовленный payload в режиме shared one-writer. Commit повторяем только с теми же write_id/payload_hash/token: shared ledger вернёт сохранённый результат и не вызовет Kwork повторно. Remote writes никогда автоматически не retry. Timeout, proxy loss, 5xx или неподтверждённый offer_id дают submission_unknown/isError; после этого commit повторять нельзя — вызовите - reconcile_write. При KWORK_WRITES=confirm клиент с окном подтверждения - сначала покажет пользователю точный текст; если пользователь откажется, - запись завершится write_declined и на Kwork ничего не уйдёт. + reconcile_write. При KWORK_WRITES=confirm вызывайте commit_write только + после явного «да» пользователя на точный текст, цену и получателя. """ correlation = correlation_id() try: gateway = gateway_from_context(ctx) - if write_confirmation(ctx, gateway.config.writes) == "client": - record = await gateway.pending_confirmation( - write_id=write_id, - payload_hash=payload_hash, - confirmation_token=confirmation_token, - ) - if record is not None: - answer = await _ask_user(ctx, confirmation_message(record), f"{write_id}:{payload_hash}") - if isinstance(answer, InputRequiredResult): - return answer - if answer is None: - raise GatewayError(ErrorCode.WRITE_DECLINED, diagnostic="client_confirmation_failed") - if not answer: - declined = await gateway.decline_write( - write_id=write_id, - payload_hash=payload_hash, - confirmation_token=confirmation_token, - correlation_id=correlation, - ) - return write_result(WriteOutcome, status=declined, correlation=correlation) status = await gateway.commit_write( write_id=write_id, payload_hash=payload_hash, diff --git a/src/kwork_mcp/version.py b/src/kwork_mcp/version.py index aa0c8b2..976643d 100644 --- a/src/kwork_mcp/version.py +++ b/src/kwork_mcp/version.py @@ -1,3 +1,3 @@ """Application version exposed in package metadata and the MCP handshake.""" -__version__ = "1.5.0" +__version__ = "1.5.1" diff --git a/tests/test_auth_bootstrap.py b/tests/test_auth_bootstrap.py index bba03ec..9f19b56 100644 --- a/tests/test_auth_bootstrap.py +++ b/tests/test_auth_bootstrap.py @@ -959,7 +959,7 @@ async def test_bootstrap_cli_rejects_non_tty_and_unknown_argv_without_reflection [ (["--help"], "kwork-mcp-bootstrap"), (["-h"], "kwork-mcp-bootstrap"), - (["--version"], "1.5.0"), + (["--version"], "1.5.1"), ], ) async def test_bootstrap_help_and_version_need_no_tty_or_configuration( diff --git a/tests/test_mcp_protocol.py b/tests/test_mcp_protocol.py index 8ef1e7c..9a9f68f 100644 --- a/tests/test_mcp_protocol.py +++ b/tests/test_mcp_protocol.py @@ -387,7 +387,7 @@ async def test_in_memory_handshake_tools_schemas_annotations_and_results( assert negotiated.protocol_version == protocol_version assert negotiated.server_info is not None assert negotiated.server_info.name == "kwork" - assert negotiated.server_info.version == __version__ == "1.5.0" + assert negotiated.server_info.version == __version__ == "1.5.1" assert negotiated.instructions == server_instructions("auto") assert negotiated.server_capabilities is not None assert negotiated.server_capabilities.tools is not None diff --git a/tests/test_release_metadata.py b/tests/test_release_metadata.py index d839adb..2d9c3da 100644 --- a/tests/test_release_metadata.py +++ b/tests/test_release_metadata.py @@ -46,7 +46,7 @@ def test_versions_and_console_entrypoints_are_consistent() -> None: registry = json.loads((ROOT / "server.json").read_text()) assert project["dynamic"] == ["version"] - assert registry["version"] == __version__ == "1.5.0" + assert registry["version"] == __version__ == "1.5.1" assert registry["packages"][0]["version"] == __version__ assert project["scripts"] == { "kwork-mcp": "kwork_mcp:main", diff --git a/tests/test_short_commands.py b/tests/test_short_commands.py index 96ba87f..9bfaf1a 100644 --- a/tests/test_short_commands.py +++ b/tests/test_short_commands.py @@ -454,7 +454,7 @@ async def test_status_names_an_explicit_account_and_reads_the_ledger( assert code == 0 assert "(user_id 77), из KWORK_EXPECTED_USER_ID" in output - assert "Отправка: с подтверждением каждой" in output + assert "Отправка: с подтверждением каждой, агент ждёт" in output assert "Несверенные отправки: нет" in output diff --git a/tests/test_write_confirmation.py b/tests/test_write_confirmation.py deleted file mode 100644 index 6b3cead..0000000 --- a/tests/test_write_confirmation.py +++ /dev/null @@ -1,436 +0,0 @@ -"""KWORK_WRITES: a dialog before each send, sending on the agent's own say, or read-only.""" - -from __future__ import annotations - -import os -from collections.abc import Callable -from pathlib import Path -from types import SimpleNamespace -from typing import Any - -import pytest -from fastmcp import Client -from fastmcp.client.elicitation import ElicitResult -from mcp.types import ElicitResult as WireElicitResult -from mcp.types import InputRequiredResult - -import kwork_mcp -from kwork_mcp.config import KworkConfig, reject_removed_settings -from kwork_mcp.coordination import CoordinationStore -from kwork_mcp.errors import GatewayError -from kwork_mcp.gateway.writes import confirmation_message -from kwork_mcp.models import ( - DeleteMessageRequest, - DeleteOfferRequest, - DialogRecord, - EditMessageRequest, - ErrorCode, - MarkDialogReadRequest, - SendMessageRequest, - SetKworkStateRequest, - SubmitOrderApprovalRequest, - WriteAction, - WriteState, -) -from kwork_mcp.server import create_server, server_instructions -from kwork_mcp.tools.write_tools import _ask_user -from tests.test_mcp_protocol import _credentialless_server_config -from tests.test_write_safety import ACCOUNT_ID, SCOPE, SafetyGateway, TimeoutGateway, _offer, _prepare_and_commit - -OFFER = { - "action": "submit_offer", - "project_id": 77, - "title": "Реализация API", - "description": "Д" * 180, - "price": 5000, - "duration_days": 3, -} - - -class Harness: - """A real server over SafetyGateway, so the dialog runs on the real ledger path.""" - - def __init__(self, config_factory: Callable[..., KworkConfig], writes: str) -> None: - self.config = _credentialless_server_config(config_factory(writes=writes, expected_user_id=ACCOUNT_ID)) - self.gateway: SafetyGateway | None = None - self.dialogs: list[str] = [] - - def factory(config: KworkConfig, _coordinator: Any, _session: Any) -> SafetyGateway: - self.gateway = SafetyGateway(config) - return self.gateway - - self.server = create_server(config=self.config, gateway_factory=factory) - - def handler(self, answer: Any) -> Callable[..., Any]: - async def respond(message: str, _response_type: Any, _params: Any, _context: Any) -> Any: - self.dialogs.append(message) - if isinstance(answer, BaseException): - raise answer - return answer - - return respond - - async def prepare_and_commit(self, client: Client) -> tuple[Any, Any]: - prepared = await client.call_tool( - "prepare_write", {"request": OFFER, "idempotency_key": "offer-77"}, raise_on_error=False - ) - data = prepared.structured_content["data"] - committed = await client.call_tool( - "commit_write", - { - "write_id": data["write_id"], - "payload_hash": data["payload_hash"], - "confirmation_token": data["confirmation_token"], - }, - raise_on_error=False, - ) - return prepared.structured_content, committed.structured_content - - def sent(self) -> int: - assert self.gateway is not None - return self.gateway.web.calls.count("final") - - async def state(self, write_id: str) -> WriteState: - assert self.gateway is not None - record = await self.gateway.coordinator.get_write(write_id, scope=SCOPE) - assert record is not None - return record.state - - -# --- the dialog, through a real MCP client - - -@pytest.mark.asyncio -@pytest.mark.parametrize("mode", ["legacy", "auto"]) -async def test_confirmed_dialog_sends_exactly_once(config_factory: Callable[..., KworkConfig], mode: str) -> None: - harness = Harness(config_factory, "confirm") - - async with Client(harness.server, mode=mode, elicitation_handler=harness.handler({"send": True})) as client: - prepared, committed = await harness.prepare_and_commit(client) - - assert prepared["data"]["confirmation"] == "client" - assert committed["data"]["state"] == "succeeded" - assert len(harness.dialogs) == 1 - assert harness.dialogs[0].startswith("Отправить на kwork.ru?") - assert "Отклик на проект 77" in harness.dialogs[0] - assert "Цена: 5 000 ₽, срок 3 дн." in harness.dialogs[0] - assert harness.sent() == 1 - - -@pytest.mark.asyncio -@pytest.mark.parametrize("mode", ["legacy", "auto"]) -@pytest.mark.parametrize( - "answer", - [ElicitResult(action="decline"), ElicitResult(action="cancel"), {"send": False}], - ids=["decline", "cancel", "unticked"], -) -async def test_declined_dialog_sends_nothing_and_settles_the_write( - config_factory: Callable[..., KworkConfig], - mode: str, - answer: Any, -) -> None: - harness = Harness(config_factory, "confirm") - - async with Client(harness.server, mode=mode, elicitation_handler=harness.handler(answer)) as client: - prepared, committed = await harness.prepare_and_commit(client) - replay = await client.call_tool( - "commit_write", - { - "write_id": prepared["data"]["write_id"], - "payload_hash": prepared["data"]["payload_hash"], - "confirmation_token": prepared["data"]["confirmation_token"], - }, - raise_on_error=False, - ) - - assert committed["error"]["code"] == "write_declined" - assert committed["data"]["state"] == "failed_known" - assert replay.structured_content["data"]["state"] == "failed_known" - assert len(harness.dialogs) == 1 - assert harness.sent() == 0 - assert await harness.state(prepared["data"]["write_id"]) is WriteState.FAILED_KNOWN - - -@pytest.mark.asyncio -async def test_a_client_without_dialogs_falls_back_to_chat_confirmation( - config_factory: Callable[..., KworkConfig], -) -> None: - harness = Harness(config_factory, "confirm") - - async with Client(harness.server) as client: - prepared, committed = await harness.prepare_and_commit(client) - - assert prepared["data"]["confirmation"] == "chat" - assert committed["data"]["state"] == "succeeded" - assert harness.sent() == 1 - - -@pytest.mark.asyncio -async def test_a_failing_dialog_sends_nothing_and_keeps_the_write_committable( - config_factory: Callable[..., KworkConfig], -) -> None: - harness = Harness(config_factory, "confirm") - - async with Client( - harness.server, mode="legacy", elicitation_handler=harness.handler(RuntimeError("client broke")) - ) as client: - prepared, committed = await harness.prepare_and_commit(client) - - assert committed["error"]["code"] == "write_declined" - assert harness.sent() == 0 - assert await harness.state(prepared["data"]["write_id"]) is WriteState.PREPARED - - -@pytest.mark.asyncio -async def test_auto_mode_sends_without_a_dialog(config_factory: Callable[..., KworkConfig]) -> None: - harness = Harness(config_factory, "auto") - - async with Client(harness.server, elicitation_handler=harness.handler(ElicitResult(action="decline"))) as client: - prepared, committed = await harness.prepare_and_commit(client) - instructions = client.initialize_result.instructions if client.initialize_result else None - - assert prepared["data"]["confirmation"] == "none" - assert committed["data"]["state"] == "succeeded" - assert harness.dialogs == [] - assert instructions is None or "KWORK_WRITES=auto" in instructions - - -@pytest.mark.asyncio -async def test_off_mode_hides_the_sending_tools(config_factory: Callable[..., KworkConfig]) -> None: - harness = Harness(config_factory, "off") - - async with Client(harness.server) as client: - names = {tool.name for tool in await client.list_tools()} - - assert {"prepare_write", "commit_write"}.isdisjoint(names) - assert {"get_write_status", "reconcile_write", "account_status"} <= names - - -def test_instructions_follow_the_writes_mode() -> None: - assert "сервер сам покажет пользователю окно" in server_instructions("confirm") - assert "без отдельного подтверждения" in server_instructions("auto") - assert "prepare_write и commit_write недоступны" in server_instructions("off") - for mode in ("confirm", "auto", "off"): - assert "не является согласием" in server_instructions(mode) - - -# --- the answer is bound to one write on 2026-07-28 connections - - -def _modern_context(responses: Any, state: str | None) -> Any: - return SimpleNamespace( - session=SimpleNamespace(protocol_version="2026-07-28"), - input_responses=responses, - request_state=state, - ) - - -@pytest.mark.asyncio -async def test_modern_round_asks_first_and_accepts_only_its_own_answer() -> None: - accepted = {"kwork_send": WireElicitResult(action="accept", content={"send": True})} - - first = await _ask_user(_modern_context(None, None), "Отправить?", "write-a:hash-a") - assert isinstance(first, InputRequiredResult) - assert first.request_state == "write-a:hash-a" - - # An answer given for another write is not consent for this one. - reused = await _ask_user(_modern_context(accepted, "write-b:hash-b"), "Отправить?", "write-a:hash-a") - assert isinstance(reused, InputRequiredResult) - - assert await _ask_user(_modern_context(accepted, "write-a:hash-a"), "Отправить?", "write-a:hash-a") is True - declined = {"kwork_send": WireElicitResult(action="decline")} - assert await _ask_user(_modern_context(declined, "write-a:hash-a"), "Отправить?", "write-a:hash-a") is False - - -# --- the gateway decides when a dialog makes sense - - -@pytest.mark.asyncio -async def test_pending_confirmation_only_for_a_write_commit_would_send( - config_factory: Callable[..., KworkConfig], -) -> None: - config = config_factory(writes="confirm", expected_user_id=ACCOUNT_ID) - gateway = SafetyGateway(config) - prepared = await gateway.prepare_write(_offer(), "pending", correlation_id="prepare") - assert prepared.confirmation_token is not None - commit_args = { - "write_id": prepared.write_id, - "payload_hash": prepared.payload_hash, - "confirmation_token": prepared.confirmation_token, - } - - record = await gateway.pending_confirmation(**commit_args) - assert record is not None and record.action is WriteAction.SUBMIT_OFFER - - with pytest.raises(GatewayError) as wrong: - await gateway.pending_confirmation(**{**commit_args, "confirmation_token": "x" * 40}) - assert wrong.value.code is ErrorCode.INVALID_CONFIRMATION - - await gateway.commit_write(**commit_args, correlation_id="commit") - assert await gateway.pending_confirmation(**commit_args) is None - - -@pytest.mark.asyncio -async def test_no_dialog_for_marking_a_dialog_read_or_behind_an_unresolved_write( - config_factory: Callable[..., KworkConfig], -) -> None: - config = config_factory(writes="confirm", expected_user_id=ACCOUNT_ID) - gateway = TimeoutGateway(config) - unknown = await _prepare_and_commit(gateway, _offer(), "unknown") - assert unknown.state is WriteState.SUBMISSION_UNKNOWN - - blocked = await gateway.prepare_write(_offer(project_id=78), "blocked", correlation_id="prepare") - assert blocked.confirmation_token is not None - assert ( - await gateway.pending_confirmation( - write_id=blocked.write_id, - payload_hash=blocked.payload_hash, - confirmation_token=blocked.confirmation_token, - ) - is None - ) - - quiet_gateway = SafetyGateway(config_factory(writes="confirm", expected_user_id=ACCOUNT_ID)) - quiet_gateway.dialogs.append(DialogRecord(user_id=5, username="buyer", raw={"user_id": 5})) - read = await quiet_gateway.prepare_write( - MarkDialogReadRequest(action=WriteAction.MARK_DIALOG_READ, user_id=5), "read", correlation_id="prepare" - ) - assert read.confirmation_token is not None - assert ( - await quiet_gateway.pending_confirmation( - write_id=read.write_id, - payload_hash=read.payload_hash, - confirmation_token=read.confirmation_token, - ) - is None - ) - - -@pytest.mark.asyncio -async def test_declining_settles_only_a_prepared_write(config_factory: Callable[..., KworkConfig]) -> None: - gateway = SafetyGateway(config_factory(writes="confirm", expected_user_id=ACCOUNT_ID)) - prepared = await gateway.prepare_write(_offer(), "decline", correlation_id="prepare") - assert prepared.confirmation_token is not None - commit_args = { - "write_id": prepared.write_id, - "payload_hash": prepared.payload_hash, - "confirmation_token": prepared.confirmation_token, - } - - declined = await gateway.decline_write(**commit_args, correlation_id="decline") - assert declined.state is WriteState.FAILED_KNOWN - assert declined.terminal_error is not None - assert declined.terminal_error.code is ErrorCode.WRITE_DECLINED - - again = await gateway.decline_write(**commit_args, correlation_id="decline-again") - assert again.state is WriteState.FAILED_KNOWN - committed = await gateway.commit_write(**commit_args, correlation_id="commit") - assert committed.state is WriteState.FAILED_KNOWN - assert gateway.offers == [] - - with pytest.raises(GatewayError) as wrong: - await gateway.decline_write(**{**commit_args, "payload_hash": "f" * 64}, correlation_id="wrong") - assert wrong.value.code is ErrorCode.INVALID_CONFIRMATION - - -# --- what the person reads in the dialog - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("request_model", "expected"), - [ - (DeleteOfferRequest(action=WriteAction.DELETE_OFFER, offer_id=9), "Удалить отклик 9"), - ( - SendMessageRequest(action=WriteAction.SEND_MESSAGE, username="buyer", text="Здравствуйте!\nДа."), - "Сообщение для buyer:\n\nЗдравствуйте!\nДа.", - ), - ( - EditMessageRequest(action=WriteAction.EDIT_MESSAGE, message_id=5, username="buyer", text="Новый"), - "Изменить сообщение 5 в диалоге с buyer. Новый текст:\n\nНовый", - ), - ( - DeleteMessageRequest(action=WriteAction.DELETE_MESSAGE, message_id=5, username="buyer"), - "Удалить сообщение 5 в диалоге с buyer", - ), - ( - SubmitOrderApprovalRequest(action=WriteAction.SUBMIT_ORDER_APPROVAL, order_id=3, file_ids=[1, 2]), - "Сдать заказ 3 на проверку, файлов: 2", - ), - ( - SetKworkStateRequest(action=WriteAction.SET_KWORK_STATE, kwork_id=4, target_state="paused"), - "Поставить на паузу кворк 4", - ), - ( - SetKworkStateRequest(action=WriteAction.SET_KWORK_STATE, kwork_id=4, target_state="active"), - "Запустить кворк 4", - ), - ], -) -async def test_dialog_text_names_the_action_and_its_target( - config_factory: Callable[..., KworkConfig], - request_model: Any, - expected: str, -) -> None: - coordinator = CoordinationStore(config_factory(writes="confirm", expected_user_id=ACCOUNT_ID)) - prepared = await coordinator.prepare_write( - scope=SCOPE, - idempotency_key="text", - action=request_model.action, - payload={"request": request_model.model_dump(mode="json"), "resolved": {}, "prepared_account_id": 42}, - ) - - message = confirmation_message(prepared.record) - - assert message == f"Отправить на kwork.ru?\n\n{expected}" - - -@pytest.mark.asyncio -async def test_dialog_text_shows_hidden_characters_instead_of_obeying_them( - config_factory: Callable[..., KworkConfig], -) -> None: - coordinator = CoordinationStore(config_factory(writes="confirm", expected_user_id=ACCOUNT_ID)) - request = SendMessageRequest(action=WriteAction.SEND_MESSAGE, username="buyer", text="Цена 500‮0001 ₽\nок") - prepared = await coordinator.prepare_write( - scope=SCOPE, - idempotency_key="bidi", - action=WriteAction.SEND_MESSAGE, - payload={"request": request.model_dump(mode="json"), "resolved": {}, "prepared_account_id": 42}, - ) - - message = confirmation_message(prepared.record) - - assert "‮" not in message - assert "Цена 500\\u202e0001 ₽\nок" in message - - -# --- the removed KWORK_ENABLE_WRITES stops startup instead of being ignored - - -def test_removed_writes_flag_is_refused_with_its_replacement() -> None: - reject_removed_settings({"KWORK_WRITES": "off"}) - with pytest.raises(ValueError, match="KWORK_WRITES=off"): - reject_removed_settings({"kwork_enable_writes": "false"}) - - -def test_main_refuses_to_start_with_the_removed_flag( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], -) -> None: - for name in tuple(os.environ): - if name.upper().startswith("KWORK_"): - monkeypatch.delenv(name, raising=False) - monkeypatch.setenv("KWORK_EXPECTED_USER_ID", "42") - monkeypatch.setenv("KWORK_STATE_DIR", str(tmp_path)) - monkeypatch.setenv("KWORK_ENABLE_WRITES", "false") - monkeypatch.setattr( - "kwork_mcp.server.create_server", - lambda **_kwargs: pytest.fail("server must not start with a removed setting"), - ) - - with pytest.raises(SystemExit) as exited: - kwork_mcp.main([]) - - assert exited.value.code == 2 - assert "KWORK_ENABLE_WRITES удалена в 1.5.0" in capsys.readouterr().err diff --git a/tests/test_writes_mode.py b/tests/test_writes_mode.py new file mode 100644 index 0000000..cd342f8 --- /dev/null +++ b/tests/test_writes_mode.py @@ -0,0 +1,120 @@ +"""KWORK_WRITES: the user's yes before each send, sending on the agent's own say, or read-only.""" + +from __future__ import annotations + +import os +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import pytest +from fastmcp import Client + +import kwork_mcp +from kwork_mcp.config import KworkConfig, reject_removed_settings +from kwork_mcp.server import create_server, server_instructions +from tests.test_mcp_protocol import _credentialless_server_config +from tests.test_write_safety import ACCOUNT_ID, SafetyGateway + +OFFER = { + "action": "submit_offer", + "project_id": 77, + "title": "Реализация API", + "description": "Д" * 180, + "price": 5000, + "duration_days": 3, +} + + +class Harness: + """A real server over SafetyGateway, so prepare and commit run on the real ledger.""" + + def __init__(self, config_factory: Callable[..., KworkConfig], writes: str) -> None: + config = _credentialless_server_config(config_factory(writes=writes, expected_user_id=ACCOUNT_ID)) + self.gateway: SafetyGateway | None = None + + def factory(config: KworkConfig, _coordinator: Any, _session: Any) -> SafetyGateway: + self.gateway = SafetyGateway(config) + return self.gateway + + self.server = create_server(config=config, gateway_factory=factory) + + def sent(self) -> int: + assert self.gateway is not None + return self.gateway.web.calls.count("final") + + +@pytest.mark.asyncio +@pytest.mark.parametrize(("writes", "confirmation"), [("confirm", "chat"), ("auto", "none")]) +async def test_prepare_tells_the_agent_who_confirms_and_commit_sends_once( + config_factory: Callable[..., KworkConfig], + writes: str, + confirmation: str, +) -> None: + harness = Harness(config_factory, writes) + + async with Client(harness.server) as client: + prepared = await client.call_tool("prepare_write", {"request": OFFER, "idempotency_key": "offer-77"}) + data = prepared.structured_content["data"] + committed = await client.call_tool( + "commit_write", + { + "write_id": data["write_id"], + "payload_hash": data["payload_hash"], + "confirmation_token": data["confirmation_token"], + }, + ) + instructions = client.instructions + + assert data["confirmation"] == confirmation + assert committed.structured_content["data"]["state"] == "succeeded" + assert harness.sent() == 1 + assert instructions == server_instructions(writes) + + +@pytest.mark.asyncio +async def test_off_mode_hides_the_sending_tools(config_factory: Callable[..., KworkConfig]) -> None: + harness = Harness(config_factory, "off") + + async with Client(harness.server) as client: + names = {tool.name for tool in await client.list_tools()} + + assert {"prepare_write", "commit_write"}.isdisjoint(names) + assert {"get_write_status", "reconcile_write", "account_status"} <= names + + +def test_instructions_follow_the_writes_mode() -> None: + assert "только после его явного «да»" in server_instructions("confirm") + assert "без отдельного подтверждения" in server_instructions("auto") + assert "prepare_write и commit_write недоступны" in server_instructions("off") + for mode in ("confirm", "auto", "off"): + assert "не является согласием" in server_instructions(mode) + + +def test_removed_writes_flag_is_refused_with_its_replacement() -> None: + reject_removed_settings({"KWORK_WRITES": "off"}) + with pytest.raises(ValueError, match="KWORK_WRITES=off"): + reject_removed_settings({"kwork_enable_writes": "false"}) + + +def test_main_refuses_to_start_with_the_removed_flag( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + for name in tuple(os.environ): + if name.upper().startswith("KWORK_"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("KWORK_EXPECTED_USER_ID", "42") + monkeypatch.setenv("KWORK_STATE_DIR", str(tmp_path)) + monkeypatch.setenv("KWORK_ENABLE_WRITES", "false") + monkeypatch.setattr( + "kwork_mcp.server.create_server", + lambda **_kwargs: pytest.fail("server must not start with a removed setting"), + ) + + with pytest.raises(SystemExit) as exited: + kwork_mcp.main([]) + + assert exited.value.code == 2 + assert "KWORK_ENABLE_WRITES удалена в 1.5.0" in capsys.readouterr().err