From 79dd42036db83c575cfc8a9b5dcc45d1a7c7ca84 Mon Sep 17 00:00:00 2001 From: Simon <68971770+simonether@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:55:47 +0500 Subject: [PATCH] Drop the client confirmation dialog and confirm sends in chat The 1.5.0 elicitation dialog did not hold up in real clients: Claude Desktop does not support it, and Codex.app with approval_policy = "never" declines the request on its own within milliseconds, so in confirm mode nothing could ever be sent there. Supporting it meant two protocol paths, sealed request state and timing heuristics per client. KWORK_WRITES keeps its three modes. confirm now behaves the same in every client: prepare_write reports confirmation=chat and the server instructions require the user's explicit yes on the exact text, price and recipient before commit_write; the client's own tool approval is the second gate. auto and off are unchanged. Removed with the dialog: write_declined, the ledger's decline and credential-check transitions, account_status.write_confirmation and the dialog text builder. Version 1.5.1. --- AGENTS.md | 11 +- CHANGELOG.md | 22 +- README.md | 52 ++-- docs/architecture.md | 4 +- docs/configuration.md | 28 +- docs/security.md | 12 +- scripts/smoke-wheel.sh | 8 +- server.json | 4 +- site/index.html | 28 +- src/kwork_mcp/bootstrap.py | 2 +- src/kwork_mcp/coordination.py | 82 ------ src/kwork_mcp/errors.py | 1 - src/kwork_mcp/gateway/writes.py | 108 ------- src/kwork_mcp/models.py | 16 +- src/kwork_mcp/server.py | 8 +- src/kwork_mcp/tools/common.py | 17 +- src/kwork_mcp/tools/read_tools.py | 5 +- src/kwork_mcp/tools/write_tools.py | 83 +----- src/kwork_mcp/version.py | 2 +- tests/test_auth_bootstrap.py | 2 +- tests/test_mcp_protocol.py | 2 +- tests/test_release_metadata.py | 2 +- tests/test_short_commands.py | 2 +- tests/test_write_confirmation.py | 436 ----------------------------- tests/test_writes_mode.py | 120 ++++++++ 25 files changed, 233 insertions(+), 824 deletions(-) delete mode 100644 tests/test_write_confirmation.py create mode 100644 tests/test_writes_mode.py 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