From db50247fcc54459ade83603a76d1bf7538e759bd Mon Sep 17 00:00:00 2001 From: Simon <68971770+simonether@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:16:40 +0500 Subject: [PATCH] Start the server without a selected account and answer each tool with the fix --- AGENTS.md | 7 ++- CHANGELOG.md | 19 ++++++- README.md | 31 +++++----- docs/architecture.md | 4 +- docs/configuration.md | 10 +++- docs/security.md | 8 ++- scripts/smoke-wheel.sh | 43 ++++++++++++-- server.json | 4 +- site/index.html | 22 ++++---- src/kwork_mcp/__init__.py | 10 +++- src/kwork_mcp/bootstrap.py | 6 +- src/kwork_mcp/config.py | 19 ++++++- src/kwork_mcp/server.py | 16 +++++- src/kwork_mcp/tools/common.py | 9 ++- src/kwork_mcp/version.py | 2 +- tests/test_auth_bootstrap.py | 2 +- tests/test_hardening.py | 1 - tests/test_mcp_protocol.py | 2 +- tests/test_release_metadata.py | 2 +- tests/test_short_commands.py | 100 +++++++++++++++++++++++++++++---- 20 files changed, 248 insertions(+), 69 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b010a74..21cd728 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,8 +53,11 @@ site/ GitHub Pages landing page (Russian), deployed by .github/wor `KWORK_WRITES`, limits, `KWORK_STATE_DIR`, optional `KWORK_EXPECTED_USER_ID`). Login, password, token, phone and proxy go only through `kwork-mcp login` into the account store. - **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. + that login stored a token for (`load_server_config`). With none or several it still starts + without a session: every tool answers `auth_required` or `account_binding_required` + (`AccountSelectionError.code`), so the agent can tell the user to run login; directory + inspections (Glama, LobeHub) need the started server too. An unreadable token store stops + startup with exit 2. The CLI never echoes argv. - **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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 70c65ef..d0c0307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ ## [Unreleased] +## [1.5.2] - 2026-10-03 + +### Changed + +- Сервер запускается, даже если аккаунт Kwork не выбран: входа ещё нет или вы + входили в несколько аккаунтов и не указали `KWORK_EXPECTED_USER_ID`. Раньше он + сразу завершался, и клиент показывал только упавший сервер. Теперь каждый + инструмент возвращает агенту `auth_required` с подсказкой выполнить + `uvx kwork-mcp@1.5.2 login` или `account_binding_required` с просьбой выбрать + аккаунт, а к Kwork сервер не обращается. Каталоги MCP-серверов (Glama, LobeHub) + теперь могут проверить запуск и список инструментов без аккаунта. Если каталог + токенов не читается, сервер по-прежнему не запускается. +- `kwork-mcp status` различает два случая: «сервер не запустится» (секреты в + окружении, ошибка конфигурации) и «сервер запустится без аккаунта». Раньше + второй случай тоже назывался «не запустится». + ### Fixed - Сборка из исходников работает с uv 0.12: `tool.uv.required-version` больше не @@ -292,7 +308,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.1...HEAD +[Unreleased]: https://github.com/simonether/kwork-mcp/compare/v1.5.2...HEAD +[1.5.2]: https://github.com/simonether/kwork-mcp/releases/tag/v1.5.2 [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 diff --git a/README.md b/README.md index a032179..3043d73 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ Windows не поддерживается. В обычном терминале: ```bash -uvx kwork-mcp@1.5.1 login +uvx kwork-mcp@1.5.2 login ``` Команда скрыто спросит логин и пароль Kwork, а также, по желанию, последние 4 цифры @@ -58,16 +58,16 @@ Desktop и Cursor. **Claude Code:** ```bash -claude mcp add kwork --scope user -- uvx kwork-mcp@1.5.1 +claude mcp add kwork --scope user -- uvx kwork-mcp@1.5.2 ``` **Codex:** ```bash -codex mcp add kwork -- uvx kwork-mcp@1.5.1 +codex mcp add kwork -- uvx kwork-mcp@1.5.2 ``` -**Cursor:** [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=kwork&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJrd29yay1tY3BAMS41LjEiXX0%3D) +**Cursor:** [![Add to Cursor](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=kwork&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJrd29yay1tY3BAMS41LjIiXX0%3D)
Claude Desktop и другие клиенты @@ -80,7 +80,7 @@ Cursor без кнопки: `~/.cursor/mcp.json` или `.cursor/mcp.json` пр "mcpServers": { "kwork": { "command": "uvx", - "args": ["kwork-mcp@1.5.1"] + "args": ["kwork-mcp@1.5.2"] } } } @@ -102,9 +102,9 @@ JSON. Перезапустите клиент и попросите агента: «проверь статус аккаунта Kwork». Он вызовет `account_status` и покажет ваш `user_id` и имя. -Без клиента то же видно в терминале: `uvx kwork-mcp@1.5.1 status` покажет, с каким +Без клиента то же видно в терминале: `uvx kwork-mcp@1.5.2 status` покажет, с каким аккаунтом и сайтом запустится сервер, не обращаясь к Kwork. Сменить аккаунт: -`uvx kwork-mcp@1.5.1 logout`, затем снова `login`. +`uvx kwork-mcp@1.5.2 logout`, затем снова `login`. ## Отправка откликов и сообщений @@ -128,7 +128,7 @@ Codex то же включает `default_tools_approval_mode = "writes"` в н ```bash claude mcp remove kwork --scope user -claude mcp add kwork --scope user -e KWORK_WRITES=auto -- uvx kwork-mcp@1.5.1 +claude mcp add kwork --scope user -e KWORK_WRITES=auto -- uvx kwork-mcp@1.5.2 ``` В режиме `auto` от чужих команд в текстах проектов и сообщений защищает только @@ -148,7 +148,7 @@ claude mcp add kwork --scope user -e KWORK_WRITES=auto -- uvx kwork-mcp@1.5.1 `KWORK_SITE=com`. Например, для Claude Code вторым сервером рядом с kwork.ru: ```bash -claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.5.1 +claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.5.2 ``` Аккаунт и токен у kwork.ru и kwork.com общие, поэтому заново входить не нужно. @@ -161,10 +161,9 @@ claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.5.1 | Что видите | Что делать | |---|---| -| `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 | +| `auth_required` или `auth_expired` | Входа нет или он истёк: выполните `uvx kwork-mcp@1.5.2 login` и перезапустите клиент | +| `account_binding_required` | Вы входили в несколько аккаунтов: укажите нужный в `KWORK_EXPECTED_USER_ID` или удалите лишний вход командой `uvx kwork-mcp@1.5.2 logout ` | +| Непонятно, какой аккаунт и сайт использует сервер | `uvx kwork-mcp@1.5.2 status` покажет это без запросов к Kwork | | Сервер не стартует, «некорректная конфигурация: …» | Проверьте названные переменные `KWORK_*` в конфиге клиента | | «KWORK_ENABLE_WRITES удалена в 1.5.0» | Замените её на `KWORK_WRITES=off`, `confirm` или `auto` | | `write_disabled` | Сервер запущен с `KWORK_WRITES=off`, доступно только чтение | @@ -176,9 +175,9 @@ claude mcp add kwork-com --scope user -e KWORK_SITE=com -- uvx kwork-mcp@1.5.1 Ручная фиксация исхода запускается с теми же `KWORK_*` переменными, что у сервера: ```bash -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 нет +uvx kwork-mcp@1.5.2 pending-writes +uvx kwork-mcp@1.5.2 resolve-write succeeded # операция на Kwork прошла +uvx kwork-mcp@1.5.2 resolve-write absent # операции на Kwork нет ``` ## Инструменты diff --git a/docs/architecture.md b/docs/architecture.md index a5c3732..45f750a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -53,7 +53,9 @@ flowchart LR Production server startup принимает только account-scoped store и stable numeric ID аккаунта: явный `KWORK_EXPECTED_USER_ID` или, если он не задан, единственный аккаунт, для которого `kwork-mcp login` сохранил token (`tokens/account-.json`). -Несколько сохранённых аккаунтов без явного ID и ни одного — отказ запуска с exit `2`. Record проверяется локально на scope/user ID до network, а +Если сохранённых аккаунтов несколько без явного ID или нет ни одного, server запускается без +session, и каждый tool отвечает `account_binding_required` или `auth_required`; +нечитаемый каталог `tokens` — отказ запуска с exit `2`. Record проверяется локально на scope/user ID до network, а token подтверждается через `get_me`. Rejected token запоминается по hash на срок session и не проверяется циклически; другой token, записанный bootstrap/peer процессом, может быть принят после своей identity check. Username в record — diff --git a/docs/configuration.md b/docs/configuration.md index 6a2de94..1233a98 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -47,7 +47,8 @@ uvx kwork-mcp@<версия> login `status` без запросов к Kwork повторяет выбор аккаунта, который сделает сервер с переменными `KWORK_*` текущего терминала, и печатает аккаунт, сайт, режим записи, наличие прокси (без его значения), каталог хранилища и число несверенных отправок. -Если сервер не запустится, `status` объясняет причину и завершается кодом `2`. +Если сервер не запустится или запустится без аккаунта, `status` объясняет причину и +завершается кодом `2`. Токен при этом не проверяется. `logout [user_id]` в TTY и после явного «да» удаляет сохранённый токен аккаунта @@ -62,8 +63,11 @@ uvx kwork-mcp@<версия> login Если `KWORK_EXPECTED_USER_ID` не задан, сервер смотрит имена файлов `tokens/account-.json` в `KWORK_STATE_DIR`. Один файл означает аккаунт, который человек подтвердил при входе, и сервер обслуживает его. Ни одного файла: -сервер не запускается и просит выполнить `login`. Несколько файлов: сервер не -выбирает сам и просит указать `KWORK_EXPECTED_USER_ID`. Содержимое токена читает +сервер запускается без аккаунта, и каждый инструмент возвращает `auth_required` +с подсказкой выполнить `login`. Несколько файлов: сервер не выбирает сам, а каждый +инструмент возвращает `account_binding_required` и просит указать +`KWORK_EXPECTED_USER_ID`. После входа или выбора перезапустите MCP-клиент. Если +каталог `tokens` не читается, сервер не запускается. Содержимое токена читает только token store со всеми проверками прав, а `get_me` сверяет фактический аккаунт с выбранным ID, поэтому посторонний файл может выбрать только аккаунт, на котором запуск сорвётся. Те же правила действуют для `pending-writes` и `resolve-write`. diff --git a/docs/security.md b/docs/security.md index 572d7e8..f25b23b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -44,8 +44,10 @@ non-sticky `0777/child-0700` намеренно неприемлем. TTY/getpass; server не принимает их ни через argv, ни через env. CLI не выводит argv даже при ошибке, поэтому случайно вставленный пароль не попадёт в терминал. - Аккаунт подтверждает человек при `login`. Без `KWORK_EXPECTED_USER_ID` server - обслуживает единственный сохранённый аккаунт и отказывается запускаться, если их - несколько: выбор между аккаунтами всегда явный. Identity check через `get_me` + обслуживает единственный сохранённый аккаунт и сам не выбирает, если их + несколько: выбор между аккаунтами всегда явный. Без выбранного аккаунта server + запускается без session и на любой вызов отвечает `auth_required` или + `account_binding_required`, не обращаясь к Kwork. Identity check через `get_me` выполняется так же, как с явным ID. - Proxy URL допускает только `http`, `socks4` и `socks5` с явным port (то, что поддерживает connector); `https` и `socks5h` отклоняются. URL сохраняется как @@ -182,7 +184,7 @@ retryable `upstream_unavailable`; `duplicate` и `permission` требуют я |---|---| | `auth_required`, `auth_expired` | Запустить/повторить TTY bootstrap; normal server не логинится сам | | `auth_in_progress` | Другой process держит account auth/store lock; повторить после retry hint | -| `account_binding_required` | Задать expected account для writes | +| `account_binding_required` | Выбрать аккаунт: `KWORK_EXPECTED_USER_ID` или `logout` лишнего входа | | `account_mismatch` | Остановить write; проверить token/account | | `captcha` | Пройти captcha вне MCP и обновить авторизацию | | `permission`, `ip_blocked`, `csrf` | Проверить права, IP/proxy или web session | diff --git a/scripts/smoke-wheel.sh b/scripts/smoke-wheel.sh index 4606264..94b4c1d 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.1" +assert kwork_mcp.__version__ == "1.5.2" 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.1" ]] +[[ "$("${runner[@]}" kwork-mcp-bootstrap --version)" == "1.5.2" ]] "${runner[@]}" kwork-mcp --help >/dev/null -[[ "$("${runner[@]}" kwork-mcp --version)" == "1.5.1" ]] +[[ "$("${runner[@]}" kwork-mcp --version)" == "1.5.2" ]] set +e "${runner[@]}" kwork-mcp login login.stdout 2>login.stderr @@ -56,7 +56,42 @@ 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.1" status.stdout +grep -q "kwork-mcp 1.5.2" status.stdout +[[ ! -e "${smoke_dir}/state" ]] + +# Directory inspections start the bare server with no account: it must list the +# tools, answer every call with auth_required and create no state. +KWORK_STATE_DIR="${smoke_dir}/state" "${runner[@]}" python -I - <<'PY' +import json +import subprocess +import sys + +messages = [ + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { + "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "smoke", "version": "0"}}}, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, + {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "account_status", "arguments": {}}}, +] +server = subprocess.Popen( + [sys.executable, "-I", "-c", "import kwork_mcp; kwork_mcp.main([])"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, +) +assert server.stdin is not None and server.stdout is not None +answers = {} +for message in messages: + server.stdin.write(json.dumps(message) + "\n") + server.stdin.flush() + if "id" in message: + answers[message["id"]] = json.loads(server.stdout.readline()) +server.stdin.close() +server.wait(timeout=30) +assert len(answers[2]["result"]["tools"]) == 22 +assert answers[3]["result"]["structuredContent"]["error"]["code"] == "auth_required" +PY [[ ! -e "${smoke_dir}/state" ]] set +e diff --git a/server.json b/server.json index 1920f2d..b53244e 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.1", + "version": "1.5.2", "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.1", + "version": "1.5.2", "runtimeHint": "uvx", "transport": { "type": "stdio" diff --git a/site/index.html b/site/index.html index 5db62d2..49cabad 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.1", + "softwareVersion": "1.5.2", "softwareRequirements": "Python 3.12–3.14, uv", "programmingLanguage": "Python", "inLanguage": "ru", @@ -84,7 +84,7 @@ {"@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.1 logout, а затем login с другим аккаунтом. Команда status покажет, с каким аккаунтом и сайтом запустится сервер, без запросов к Kwork."}}, + {"@type": "Question", "name": "Как сменить аккаунт?", "acceptedAnswer": {"@type": "Answer", "text": "Выполните в терминале uvx kwork-mcp@1.5.2 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.1 login + uvx kwork-mcp@1.5.2 login
-

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

+

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

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

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

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

терминал
-
uvx kwork-mcp@1.5.1 login
+
uvx kwork-mcp@1.5.2 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.1
+
claude mcp add kwork --scope user -- uvx kwork-mcp@1.5.2

Codex

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

Cursor

-

Добавить в Cursor

+

Добавить в Cursor

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

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

Claude Desktop

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

Claude Desktop

  • Проверьте

    -

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

    +

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

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

  • @@ -412,7 +412,7 @@

    Вопросы

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

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

    +

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

    Какие клиенты поддерживаются? diff --git a/src/kwork_mcp/__init__.py b/src/kwork_mcp/__init__.py index f33ee56..69ed99d 100644 --- a/src/kwork_mcp/__init__.py +++ b/src/kwork_mcp/__init__.py @@ -27,15 +27,21 @@ def main(argv: Sequence[str] | None = None) -> None: raise SystemExit(2) from pydantic import ValidationError - from kwork_mcp.config import AccountSelectionError, load_server_config + from kwork_mcp.config import AccountSelectionError, KworkConfig, load_server_config from kwork_mcp.server import create_server # Validator messages are static text without configured values. + config: KworkConfig | None try: config = load_server_config() except AccountSelectionError as exc: sys.stderr.write(f"kwork-mcp: {exc}.\n") - raise SystemExit(2) from None + if exc.code is None: + raise SystemExit(2) from None + # Start anyway: the client shows a server that exits at once as a bare + # failure, while every tool of a running one tells the agent the fix. + sys.stderr.write("kwork-mcp: сервер запущен без аккаунта, каждый инструмент вернёт эту подсказку.\n") + config = None except ValidationError as exc: problems = sorted( { diff --git a/src/kwork_mcp/bootstrap.py b/src/kwork_mcp/bootstrap.py index 293c1ff..fc11d11 100644 --- a/src/kwork_mcp/bootstrap.py +++ b/src/kwork_mcp/bootstrap.py @@ -747,6 +747,7 @@ async def _run_status(rest: Sequence[str], *, stdout: TextIO, stderr: TextIO) -> return 2 lines = [f"kwork-mcp {__version__}"] stopped = "Сервер с переменными KWORK_* этого терминала не запустится. " + idle = "Сервер запустится без аккаунта, инструменты ответят агенту ошибкой. " def reason(text: str) -> str: return text[:1].upper() + text[1:] @@ -759,7 +760,8 @@ def reason(text: str) -> str: account_id = cast(int, config.expected_user_id) record = _load_record(SecureTokenStore(config.state_dir, lock_timeout=config.auth_lock_timeout), account_id) except AccountSelectionError as exc: - stdout.write("\n".join([*lines, f"{stopped}{reason(str(exc))}."]) + "\n") + verdict = stopped if exc.code is None else idle + stdout.write("\n".join([*lines, f"{verdict}{reason(str(exc))}."]) + "\n") return 2 except ValidationError as exc: # Same reading as the server's startup: field names, or the rule text. @@ -782,7 +784,7 @@ def reason(text: str) -> str: return 2 if record is None: stdout.write( - "\n".join([*lines, f"{stopped}Для user_id {account_id} нет сохранённого входа, выполните login."]) + "\n" + "\n".join([*lines, f"{idle}Для user_id {account_id} нет сохранённого входа, выполните login."]) + "\n" ) return 2 explicit = any(name.upper() == "KWORK_EXPECTED_USER_ID" and value.strip() for name, value in os.environ.items()) diff --git a/src/kwork_mcp/config.py b/src/kwork_mcp/config.py index 17a0ad1..0b39591 100644 --- a/src/kwork_mcp/config.py +++ b/src/kwork_mcp/config.py @@ -19,6 +19,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from yarl import URL +from kwork_mcp.models import ErrorCode + _PERCENT_ESCAPE = re.compile(r"%[0-9A-Fa-f]{2}") @@ -363,7 +365,16 @@ def fresh_credentials_available(self) -> bool: class AccountSelectionError(ValueError): - """The server cannot tell which bound account to serve.""" + """The server cannot tell which bound account to serve. + + With `code` set (no account or several) the server still starts and every + tool answers with that error, so the agent can tell the user what to do. + An unreadable token store has no code and stops startup. + """ + + def __init__(self, message: str, code: ErrorCode | None = None) -> None: + super().__init__(message) + self.code = code def bound_account_ids(state_dir: Path) -> list[int]: @@ -391,13 +402,15 @@ def select_bound_account(state_dir: Path) -> int: accounts = bound_account_ids(state_dir) if not accounts: raise AccountSelectionError( - f"аккаунт Kwork не подключён: выполните в терминале «uvx kwork-mcp@{__version__} login»" + f"аккаунт Kwork не подключён: выполните в терминале «uvx kwork-mcp@{__version__} login»", + ErrorCode.AUTH_REQUIRED, ) if len(accounts) > 1: listed = ", ".join(str(account) for account in accounts) raise AccountSelectionError( f"подключено несколько аккаунтов Kwork ({listed}): укажите нужный в KWORK_EXPECTED_USER_ID " - "или удалите лишний вход командой «kwork-mcp logout »" + "или удалите лишний вход командой «kwork-mcp logout »", + ErrorCode.ACCOUNT_BINDING_REQUIRED, ) return accounts[0] diff --git a/src/kwork_mcp/server.py b/src/kwork_mcp/server.py index 9d2c742..16fc737 100644 --- a/src/kwork_mcp/server.py +++ b/src/kwork_mcp/server.py @@ -10,6 +10,7 @@ from loguru import logger from kwork_mcp.config import ( + AccountSelectionError, KworkConfig, load_server_config, secret_server_environment_present, @@ -19,6 +20,7 @@ from kwork_mcp.coordination import CoordinationStore from kwork_mcp.gateway import KworkGateway from kwork_mcp.middleware import SanitizedStrictInputMiddleware +from kwork_mcp.models import ErrorCode from kwork_mcp.security import SecureTokenStore, configure_logging from kwork_mcp.session import ClientFactory, KworkSessionManager from kwork_mcp.tools import register_all @@ -95,7 +97,19 @@ def create_server( @asynccontextmanager async def lifespan(_server: FastMCP) -> AsyncIterator[dict[str, Any]]: - active_config = validate_steady_state_server_config(config or load_server_config()) + unselected: ErrorCode | None = None + try: + active_config = validate_steady_state_server_config(config or load_server_config()) + except AccountSelectionError as exc: + if exc.code is None: + raise + unselected = exc.code + if unselected is not None: + # No session and no state: each tool answers with this code until a + # restart after `kwork-mcp login` selects the account. + logger.warning("Kwork MCP started without a selected account code={}", unselected.value) + yield {"account_error": unselected} + return configure_logging(active_config) coordinator = CoordinationStore(active_config) session = KworkSessionManager( diff --git a/src/kwork_mcp/tools/common.py b/src/kwork_mcp/tools/common.py index 69f71ce..182acef 100644 --- a/src/kwork_mcp/tools/common.py +++ b/src/kwork_mcp/tools/common.py @@ -57,9 +57,12 @@ def gateway_from_context(ctx: Context) -> KworkGateway: gateway = ctx.lifespan_context.get("gateway") - if not isinstance(gateway, KworkGateway): - raise RuntimeError("gateway lifespan context is unavailable") - return gateway + if isinstance(gateway, KworkGateway): + return gateway + account_error = ctx.lifespan_context.get("account_error") + if isinstance(account_error, ErrorCode): + raise GatewayError(account_error, diagnostic="account_not_selected") + raise RuntimeError("gateway lifespan context is unavailable") def write_confirmation(writes: str) -> WriteConfirmation: diff --git a/src/kwork_mcp/version.py b/src/kwork_mcp/version.py index 976643d..3981624 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.1" +__version__ = "1.5.2" diff --git a/tests/test_auth_bootstrap.py b/tests/test_auth_bootstrap.py index 9f19b56..2ce08fd 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.1"), + (["--version"], "1.5.2"), ], ) async def test_bootstrap_help_and_version_need_no_tty_or_configuration( diff --git a/tests/test_hardening.py b/tests/test_hardening.py index d17ce84..85d1cdd 100644 --- a/tests/test_hardening.py +++ b/tests/test_hardening.py @@ -113,7 +113,6 @@ def fake_create_server(*, config: Any) -> SimpleNamespace: [ ({"KWORK_STATE_DIR": "relative/state"}, "KWORK_STATE_DIR"), ({"KWORK_EXPECTED_USER_ID": "not-a-number"}, "KWORK_EXPECTED_USER_ID"), - ({}, "аккаунт Kwork не подключён"), ( { "KWORK_EXPECTED_USER_ID": "42", diff --git a/tests/test_mcp_protocol.py b/tests/test_mcp_protocol.py index 9a9f68f..23f9611 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.1" + assert negotiated.server_info.version == __version__ == "1.5.2" 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 2d9c3da..ec9b045 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.1" + assert registry["version"] == __version__ == "1.5.2" 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 9bfaf1a..b2c5d60 100644 --- a/tests/test_short_commands.py +++ b/tests/test_short_commands.py @@ -9,6 +9,7 @@ from typing import Any import pytest +from fastmcp import Client from kwork.schema.actor import Actor import kwork_mcp @@ -22,6 +23,7 @@ ) from kwork_mcp.coordination import CoordinationStore from kwork_mcp.security import SecureTokenStore, TokenRecord +from kwork_mcp.server import create_server from kwork_mcp.version import __version__ @@ -151,24 +153,99 @@ def fake_create_server(*, config: KworkConfig) -> SimpleNamespace: assert configs[0].expected_user_id == 42 -def test_main_explains_an_ambiguous_account_without_starting( +@pytest.mark.parametrize( + ("accounts", "explained"), + [ + ((), f"аккаунт Kwork не подключён: выполните в терминале «uvx kwork-mcp@{__version__} login»"), + ((42, 77), "несколько аккаунтов Kwork (42, 77)"), + ], +) +def test_main_starts_without_a_selected_account_and_explains_why( state_dir: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + accounts: tuple[int, ...], + explained: str, ) -> None: - _bind(state_dir, 42, 77) + _bind(state_dir, *accounts) + configs: list[KworkConfig | None] = [] + + def fake_create_server(*, config: KworkConfig | None) -> SimpleNamespace: + configs.append(config) + return SimpleNamespace(run=lambda **_kwargs: None) + + monkeypatch.setattr("kwork_mcp.server.create_server", fake_create_server) + + kwork_mcp.main([]) + + assert configs == [None] + captured = capsys.readouterr() + assert explained in captured.err + assert captured.out == "" + + +def test_main_stops_when_the_token_store_cannot_be_read( + state_dir: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + def deny(_self: Path) -> Any: + raise PermissionError("denied") + + monkeypatch.setattr(Path, "iterdir", deny) monkeypatch.setattr( "kwork_mcp.server.create_server", - lambda **_kwargs: pytest.fail("server must not start for an ambiguous account"), + lambda **_kwargs: pytest.fail("server must not start over an unreadable token store"), ) with pytest.raises(SystemExit) as exited: kwork_mcp.main([]) assert exited.value.code == 2 - captured = capsys.readouterr() - assert "несколько аккаунтов Kwork (42, 77)" in captured.err - assert captured.out == "" + assert "не удалось прочитать хранилище токенов (PermissionError)" in capsys.readouterr().err + + +@pytest.mark.asyncio +async def test_server_does_not_start_over_an_unreadable_token_store( + state_dir: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def deny(_self: Path) -> Any: + raise PermissionError("denied") + + server = create_server() + monkeypatch.setattr(Path, "iterdir", deny) + + with pytest.raises(RuntimeError, match="PermissionError"): + async with Client(server): + pytest.fail("the client must not connect") + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("accounts", "code"), + [((), "auth_required"), ((42, 77), "account_binding_required")], +) +async def test_server_without_a_selected_account_lists_tools_and_answers_with_the_reason( + state_dir: Path, + accounts: tuple[int, ...], + code: str, +) -> None: + _bind(state_dir, *accounts) + + async with Client(create_server()) as client: + names = {tool.name for tool in await client.list_tools()} + status = await client.call_tool("account_status", {}, raise_on_error=False) + prepared = await client.call_tool( + "prepare_write", + {"request": {"action": "mark_dialog_read", "user_id": 5}, "idempotency_key": "mark-read-5"}, + raise_on_error=False, + ) + + assert len(names) == 22 + for result in (status, prepared): + assert result.structured_content["knowledge_state"] == "unknown_error" + assert result.structured_content["error"]["code"] == code # --- terminal commands on the main entry point @@ -459,29 +536,32 @@ async def test_status_names_an_explicit_account_and_reads_the_ledger( @pytest.mark.asyncio -async def test_status_explains_why_the_server_would_not_start( +async def test_status_explains_why_the_server_would_not_work( state_dir: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: + idle = "Сервер запустится без аккаунта, инструменты ответят агенту ошибкой. " + code, output = await _status() assert code == 2 - assert "не запустится. Аккаунт Kwork не подключён" in output + assert idle + "Аккаунт Kwork не подключён" in output _store_login(state_dir, 42) _store_login(state_dir, 77) code, output = await _status() assert code == 2 + assert idle + "Подключено несколько аккаунтов Kwork (42, 77)" in output assert "kwork-mcp logout " in output monkeypatch.setenv("KWORK_EXPECTED_USER_ID", "99") code, output = await _status() assert code == 2 - assert "Для user_id 99 нет сохранённого входа" in output + assert idle + "Для user_id 99 нет сохранённого входа" in output monkeypatch.setenv("KWORK_PASSWORD", "status-secret-sentinel") code, output = await _status() assert code == 2 - assert "В окружении есть логин" in output + assert "не запустится. В окружении есть логин" in output assert "status-secret-sentinel" not in output