diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22288126..696a4fd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,14 @@ jobs: - name: Lint (ruff) run: ruff check src/ tests/ + - name: Client snippets cold start (G5) + # Каждый сниппет из `install.py --print-config` запускается на чистом + # раннере: initialize + tools/list. Покрывает все 5 клиентов, включая + # не-Zed/не-OpenCode (generic, claude-code, cursor). Таймаут 300s: на + # холодной машине сервер поднимает провайдеров дольше 60s (ONNX-ожидание + # в логах), и 120s оказалось мало на windows-latest (2026-11-12). + run: python scripts/verify_client_snippets.py --install-root "${{ github.workspace }}" --timeout 300 + - name: Layer-boundary gate (Фаза 1 Universal Engine) # Три оси ТЗ: ADAPTER → TRANSPORT → SOURCE → CORE. Гейт не даёт # mcp/tools импортировать adapters/src.sources и core — adapters.* diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index 04cfc0ab..05f139a8 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -1737,3 +1737,23 @@ BREACH: MSC-P-19 | date: 2026-10-03 | rule: §12 | guard_at_time: no | detected_ **Дефект (воспроизведён):** `lance error: query dim(768) doesn't match the column vector dim(384)`. Модель `embeddinggemma-2` отдаёт 768 (проверено `/v1/embeddings`), схема таблицы — 384 (осталась от e5-small). `.env`: `EMBEDDING_MODEL=embeddinggemma-2` при `MSCODEBASE_DENSE_OFF=true`. **Следствие:** переключать `DENSE_OFF=false` сейчас нельзя — сначала размерность из сервера + переиндексация, потом повторный `run_curve.py` в dense. Записано в KNOWN_ISSUES с точным сообщением. **Урок:** «включить dense и станет лучше» — непроверенная гипотеза; первый же замер дал паритет и конфигурационный дефект. +## [2026-11-12] G5: установка отвязана от Zed — сниппет на клиента вместо ручного конфига +**Симптом:** `install.py` v3.1 импортировал `adapters.zed.zed_config` на верхнем уровне и всегда писал конфиг Zed. На машине без Zed установка всё равно создавала `%APPDATA%/Zed`, а для Claude Code / Cursor / любого `mcpServers`-хоста команду и env приходилось собирать вручную по README (и она расходилась с кодом). +**Корень:** установщик и хост-адаптер были одним целым: каталог установки = каталог расширения Zed, шаг 11 = патч `settings.json`, generic-клиентам нечего было скопировать. +**Fix (v3.2):** +1. `MSCODEBASE_INSTALL_ROOT` + `resolve_install_root()`; историческое имя папки остаётся только default, содержимое — обычный venv. +2. Zed-адаптер импортируется лениво внутри `step_zed`/`step_zedcfg`, отказ адаптера не роняет установку; `--no-zed` — полностью нейтральный режим. +3. `--print-config --client {generic,opencode,zed,claude-code,cursor}` → один JSON-объект; формы конвертов в `adapters/clients/snippets.py` (zed-ключи сверены с `_make_server_entry`). +4. `scripts/verify_client_snippets.py`: рендерит сниппет каждого клиента, запускает его во временном каталоге и делает `initialize` + `tools/list`. Замер 2026-11-12: 5/5 `PASS ... tools=17`. +**Проверка:** `tests/test_install_cli.py` (16 тестов, в т.ч. реальный хендшейк из напечатанного сниппета); мутационная проба — переименование `PYTHONPATH` в env дало 5 красных. +**Найденный по ходу баг:** неинтерактивные режимы наследовали cp1251-stdout и падали на первом не-ASCII символе (`UnicodeEncodeError` на пути с кириллицей) — добавлен `_utf8_stdout()` для всех режимов, маркеры в отчёте сделаны ASCII. +**Урок:** «поддержка любого клиента» проверяется запуском сниппета, а не валидностью JSON: часть конвертов отвечала только после того, как запрос пошёл после `initialized`. +## [2026-11-12] G5: CI нашёл две вещи, которые локальный прогон не видел +**1. Платформенная проверка в тесте.** `test_print_config_is_valid_json_per_client` требовал, чтобы команда оканчивалась на `python.exe|python3|mscodebase-mcp*`. На ubuntu `sys.executable` = `.../venv/bin/python` → 5 красных в `test` и в `clean-state` (там тот же suite из чистого клона). Fix: проверять `Path(command[0]).name.startswith(("python","mscodebase-mcp"))` — платформенно-нейтрально; реализация не менялась. +**2. Холодная машина медленнее, чем ноутбук.** `scripts/verify_client_snippets.py` с таймаутом 120 s упал на `windows-latest` (шаг шёл ровно 121 s): первый запуск сервера на чистом раннере дольше 60 s (в логах — ожидание ONNX-сервера). Fix: таймаут по умолчанию 300 s, в CI передаётся `--timeout 300`; тот же запас в тесте-хендшейке. +**Урок:** «зелёный локально» не переносится на CI автоматически: POSIX-имя интерпретатора и холодный старт — две вещи, которые видны только на чужой машине. Оба места были бы пойманы раньше, если бы холодный старт гонялся в CI с первого коммита (теперь гоняется — шаг `Client snippets cold start (G5)`). +## [2026-11-12] G5: третья находка CI — забитый stderr-пайп, и зелёный прогон +**Симптом:** шаг `Client snippets cold start (G5)` на `windows-latest` висел 20+ минут. Причина в самом скрипте: `proc.stderr.readline()` в ветке FAIL блокировал чтение, если сервер ничего не писал в stderr, а `stderr=PIPE` никто не вычитывал — при старте сервер пишет лог больше буфера пайпа (64 KB) и встаёт на записи в stderr, так и не ответив на `initialize`. +**Fix:** `_stderr_drain()` — фоновый поток с ring-buffer на 20 строк (пайп всегда вычитывается, хвост попадает в сообщение FAIL), плюс `LOG_LEVEL=WARNING` для пробы (`src/main.py:126` читает эту переменную) — сервер не заливает пайп на холодной машине. `_read_message` остался ограниченным по времени. +**Проверка:** локально 5/5 PASS, 13 s; CI run `43092017919` (head `20f287b`) — **7/7 jobs success**: `test` (ubuntu + windows), `clean-state`, `manifest-parity`, `secret-scan`, `docker-build`, `transport-equiv-e2e`. +**Урок:** дочерний процесс с `stdout/stderr=PIPE` обязан иметь читателя на каждом пайпе; «я прочитаю stderr только при ошибке» — это дедлок, который воспроизводится только там, где логов больше буфера. diff --git a/README.md b/README.md index cfce8494..5fbc6132 100644 --- a/README.md +++ b/README.md @@ -112,18 +112,35 @@ number and the command that regenerates it are listed in ## Quick start -Requirements: Windows is the validated platform; Python 3.14+; an MCP host -(**OpenCode is the primary one**, the Zed extension still works). +Requirements: Windows is the validated platform; Python 3.14+; any MCP host +(OpenCode, Zed, Claude Code, Cursor, or a generic `mcpServers` client). ```bash cd -python install.py # full install: venv, pip, optional models -python install.py --sync # code-only sync into the installed copy +python install.py # venv + deps + code (model steps ask first) +python install.py --sync # code-only sync after a source change ``` -Restart the host after `install.py` finishes (the OpenCode session, or the Zed -window). The installed copy lives in `%LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence` -and both hosts launch that venv. Verify from the agent panel: +The install is host-neutral: it writes a plain Python environment (default +`%LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence` — the name is +historical). Point it anywhere with `MSCODEBASE_INSTALL_ROOT`. Then print the +config for your host instead of writing it by hand: + +```bash +python install.py --print-config --client generic # mcpServers-style hosts +python install.py --print-config --client opencode +python install.py --print-config --client claude-code +python install.py --print-config --client cursor +python install.py --print-config --client zed +``` + +Each prints one JSON object containing the interpreter, `-u -m src.main`, +`PYTHONPATH`, `PROJECT_PATH` and `PYTHONUTF8=1`; paste it into the host's MCP +config. Per-client copy-paste sections and the launch check +(`python scripts/verify_client_snippets.py`) are in +[docs/en/CLIENTS.md](docs/en/CLIENTS.md). Restart the host after configuring it. + +Verify from the agent panel: ```text tool_help() # profile, visible and hidden tools diff --git a/adapters/clients/README.md b/adapters/clients/README.md index 98366964..96cf97fb 100644 --- a/adapters/clients/README.md +++ b/adapters/clients/README.md @@ -1,33 +1,53 @@ -# MSCodeBase — конфиги клиентов (Фаза 5, план §4) +# MSCodeBase — client configs (Phase 5, plan §4) -Тонкие адаптеры-конфиги для подключения движка к внешним MCP-клиентам. -Каждый конфиг имеет stdio-вариант (локальный) и http-вариант (remote, Фаза 3). +Thin config shapes for connecting the engine to external MCP clients. Each file +has a stdio variant (local) and an HTTP variant (remote, Phase 3). -## stdin/stdout (локально) +**These are shapes, not the recommended way to get a config.** The generated +snippet knows the real paths of your installation: -- **Claude Code / Desktop** — `claude.code.mcp.json` (секция `mcpServers`). -- **VS Code** — `vscode.mcp.json` (`.vscode/mcp.json`, секция `servers`). -- **Cursor** — использует тот же Claude-формат (`mcpServers`), файл `.cursor/mcp.json` - или `.mcp.json` в корне проекта. +```bash +python install.py --print-config --client generic # mcpServers +python install.py --print-config --client claude-code +python install.py --print-config --client cursor +python install.py --print-config --client opencode +python install.py --print-config --client zed +``` -Заполнить плейсхолдеры: -- `/Scripts/python.exe` — venv-py движка (POSIX: `/bin/python3`); -- `PYTHONPATH=` — путь к дереву исходников (`src/`); -- `cwd=` — проект, который индексируется. +The generator lives in `adapters/clients/snippets.py`; the launch check is +`scripts/verify_client_snippets.py`. Full per-client instructions: +`docs/en/CLIENTS.md`. -`command = -m src.main` запускает server через `run_server` (stdio). +## stdin/stdout (local) -## HTTP (remote, Фаза 3) +- **Claude Code / Desktop** — `claude.code.mcp.json` (key `mcpServers`). +- **VS Code** — `vscode.mcp.json` (`.vscode/mcp.json`, key `servers`). +- **Cursor** — same Claude shape (`mcpServers`), file `.cursor/mcp.json` or + `.mcp.json` in the project root. -http-блок `mscodebase-remote`: -- `url = http://:8089/mcp` — Streamable HTTP вход (`src/remote_main.py`); -- `headers.Authorization = Bearer ` — обязателен для сети. +Placeholders to fill: -Сборка/запуск remote-сервера: см. `deploy/docker/README.md` (образ + compose) и -`src/remote_main.py`. +- `/Scripts/python.exe` — the engine's venv interpreter + (POSIX: `/bin/python3`); +- `PYTHONPATH=` — the tree that contains `src/` (the installed copy + or the checkout); +- `PROJECT_PATH=` — the project that gets indexed + (`cwd` is not required: the server reads `PROJECT_PATH`). -## CLI (без MCP, для CI/скриптов) +`command = -m src.main` starts the server through `run_server` (stdio). -`python -m src.cli ''` — прямой вызов tool-класса через DI, -см. `src/cli.py`. Curated allowlist: `get_task_status`, `stale_detector`, +## HTTP (remote, Phase 3) + +HTTP block `mscodebase-remote`: + +- `url = http://:8089/mcp` — Streamable HTTP entry (`src/remote_main.py`); +- `headers.Authorization = Bearer ` — required on the network. + +Building/running the remote server: see `deploy/docker/README.md` (image + compose) +and `src/remote_main.py`. + +## CLI (no MCP, for CI/scripts) + +`python -m src.cli ''` — direct tool-class call through DI, see +`src/cli.py`. Curated allowlist: `get_task_status`, `stale_detector`, `get_context`, `graph_query`, `find_similar_bugs`. diff --git a/adapters/clients/claude.code.mcp.json b/adapters/clients/claude.code.mcp.json index 398e71db..b922ea8b 100644 --- a/adapters/clients/claude.code.mcp.json +++ b/adapters/clients/claude.code.mcp.json @@ -3,10 +3,13 @@ "mcpServers": { "mscodebase": { "type": "stdio", - "command": "/Scripts/python.exe", + "command": "/venv/Scripts/python.exe", "args": ["-m", "src.main"], - "env": { "PYTHONPATH": "" }, - "cwd": "" + "env": { + "PYTHONPATH": "", + "PROJECT_PATH": "", + "PYTHONUTF8": "1" + } }, "mscodebase-remote": { "type": "http", diff --git a/adapters/clients/snippets.py b/adapters/clients/snippets.py new file mode 100644 index 00000000..5e308c63 --- /dev/null +++ b/adapters/clients/snippets.py @@ -0,0 +1,94 @@ +"""Ready-to-paste MCP client snippets (G5). + +The server is host-neutral: one stdio command plus a few environment variables. +Hosts differ only in the JSON envelope they read, so the envelope is generated +here and `install.py --print-config --client ` prints the result. + +Envelope shapes are not invented: each one mirrors what the corresponding +adapter/host consumes today. + + generic, claude-code, cursor -> {"mcpServers": {...}} + opencode -> {"mcp": {name: {"type": "local", ...}}} + zed -> {"context_servers": {name: {command, args, + env, enabled}}} — same keys that + adapters/zed/zed_config._make_server_entry + writes into settings.json. + +Usage: + from adapters.clients.snippets import render_snippet + print(render_snippet("generic", command=exe, args=["-u", "-m", "src.main"], + env={"PYTHONPATH": root, "PROJECT_PATH": proj})) +""" + +from __future__ import annotations + +import json +from typing import Mapping, Sequence + +__all__ = ["CLIENTS", "SERVER_NAME", "render_snippet", "snapshot"] + +SERVER_NAME = "mscodebase" + +#: Clients `--print-config` accepts. Order is the order documented in CLIENTS.md. +CLIENTS = ("generic", "opencode", "zed", "claude-code", "cursor") + + +def snapshot( + client: str, + *, + command: str, + args: Sequence[str], + env: Mapping[str, str], +) -> dict: + """Return the snippet as a dict (the JSON text is just its serialization).""" + if client not in CLIENTS: + raise ValueError(f"unknown client {client!r}; known: {', '.join(CLIENTS)}") + + env_dict = dict(env) + server = { + "command": command, + "args": list(args), + "env": env_dict, + } + + if client in ("generic", "claude-code", "cursor"): + return {"mcpServers": {SERVER_NAME: server}} + if client == "opencode": + # OpenCode V2: names live under mcp.servers, never directly under mcp. + # Source: https://opencode.ai/docs/mcp-servers ("V2 does not place server + # names directly under `mcp`"). The flat `mcp.` layout of V1 is + # still accepted for migrated configs, but new configs use `servers`. + return { + "mcp": { + "servers": { + SERVER_NAME: { + "type": "local", + "command": [command, *args], + "environment": env_dict, + } + } + } + } + # Zed: keys match adapters/zed/zed_config._make_server_entry. + return { + "context_servers": { + SERVER_NAME: { + "command": command, + "args": list(args), + "env": env_dict, + "enabled": True, + } + } + } + + +def render_snippet( + client: str, + *, + command: str, + args: Sequence[str], + env: Mapping[str, str], +) -> str: + """Pretty-printed JSON snippet, newline-terminated.""" + payload = snapshot(client, command=command, args=args, env=env) + return json.dumps(payload, indent=2, ensure_ascii=False) + "\n" diff --git a/adapters/clients/vscode.mcp.json b/adapters/clients/vscode.mcp.json index ac7f1c62..8c90f923 100644 --- a/adapters/clients/vscode.mcp.json +++ b/adapters/clients/vscode.mcp.json @@ -3,10 +3,13 @@ "servers": { "mscodebase": { "type": "stdio", - "command": "/Scripts/python.exe", + "command": "/venv/Scripts/python.exe", "args": ["-m", "src.main"], - "env": { "PYTHONPATH": "" }, - "cwd": "" + "env": { + "PYTHONPATH": "", + "PROJECT_PATH": "", + "PYTHONUTF8": "1" + } }, "mscodebase-remote": { "type": "http", diff --git a/docs/en/CLIENTS.md b/docs/en/CLIENTS.md index e3adf70e..4b2f4071 100644 --- a/docs/en/CLIENTS.md +++ b/docs/en/CLIENTS.md @@ -1,96 +1,221 @@ # Clients and deployment -> Rewritten 2026-11-08. Describes how the server is launched by each host and what -> `install.py` actually installs. Verified against `install.py`, `extension.toml`, -> `adapters/clients/*`, `.opencode/plugin/`, and the live OpenCode config on the -> development machine. +> Rewritten 2026-11-12. Describes how the server is launched by each host and what +> `install.py` actually installs. Verified against `install.py`, `adapters/clients/snippets.py`, +> `src/main.py`, `extension.toml`, `.opencode/plugin/`, `tests/test_install_cli.py` and +> `scripts/verify_client_snippets.py` (the last one starts the server from every snippet). ## 1. What the server is -A Python MCP server (`src/main.py`) speaking stdio, plus an optional Streamable-HTTP -entry point (`src/remote_main.py`) for the docker/remote deployment. It is not an -editor plugin by itself — a host launches it and talks MCP. - -## 2. OpenCode (primary) - -OpenCode starts the server as a local MCP server. On the development machine the -entry looks like this (paths shortened): - -```jsonc -"mcp": { - "mscodebase-intelligence": { - "type": "local", - "command": [ - "C:\\Users\\\\AppData\\Local\\Zed\\extensions\\mscodebase-intelligence\\venv\\Scripts\\mscodebase-mcp.exe", - "-u", "-m", "src.main" - ], - "environment": { - "PYTHONPATH": "C:\\Users\\\\AppData\\Local\\Zed\\extensions\\mscodebase-intelligence", - "PROJECT_PATH": "${workspaceRoot}", - "PYTHONUTF8": "1" +A Python MCP server (`src/main.py`, entry point `mscodebase-mcp = src.main:main` in +`pyproject.toml`) speaking stdio, plus an optional Streamable-HTTP entry point +(`src/remote_main.py`) for docker/remote deployment. It is not an editor plugin by +itself — a host launches it and talks MCP. + +Since v3.2 the installer is **host-neutral**: it puts a plain Python environment on +disk and any MCP host can launch it. Zed is one adapter among several, not a required +step. + +## 2. Install the server once + +```bash +cd +python install.py # venv + deps + code (model steps ask first) +python install.py --no-zed # do not touch Zed settings at all +python install.py --sync-only # after a code change: copy code only +``` + +Where it lands (default, historical path — nothing Zed-specific about the contents): + +``` +Windows : %LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence +macOS : ~/Library/Application Support/Zed/extensions/mscodebase-intelligence +Linux : ~/.local/share/zed/extensions/mscodebase-intelligence +``` + +Move it anywhere with `MSCODEBASE_INSTALL_ROOT=`; caches, logs and per-project +artifacts live elsewhere (`MSCODEBASE_DATA_DIR`, default `%LOCALAPPDATA%\mscodebase`). + + +Alternative without the installer (any Python package manager that exposes console +scripts): + +```bash +pipx install . # or: uv tool install . +mscodebase-mcp # the entry point is now on PATH +``` + +## 3. Get the config for your host + +Do not hand-write the command and the environment — print them: + +```bash +python install.py --print-config --client generic # any mcpServers-style host +python install.py --print-config --client opencode +python install.py --print-config --client zed +python install.py --print-config --client claude-code +python install.py --print-config --client cursor +``` + +Each command prints one JSON object and nothing else, so it can be redirected: +`python install.py --print-config --client cursor > .cursor/mcp.json`. The snippet +contains the resolved interpreter, `-u -m src.main`, `PYTHONPATH`, `PROJECT_PATH` and +`PYTHONUTF8=1`. + +### 3.1 Generic MCP client (`mcpServers`) + +File: wherever the host reads its MCP config. Replace `` with the workspace +you want indexed (this is what `PROJECT_PATH` means). + +```json +{ + "mcpServers": { + "mscodebase": { + "command": "/venv/Scripts/mscodebase-mcp.exe", + "args": ["-u", "-m", "src.main"], + "env": { + "PYTHONPATH": "", + "PROJECT_PATH": "", + "PYTHONUTF8": "1" + } } } } ``` -Two facts that matter: +### 3.2 OpenCode + +File: `opencode.json(c)` at the project root or in `~/.config/opencode/`. OpenCode V2 +places server names under **`mcp.servers`** — the flat V1 layout `mcp.` is still +accepted for migrated configs but is not what new configs use +(source: , "V2 does not place server names +directly under `mcp`"): + +```json +{ + "mcp": { + "servers": { + "mscodebase": { + "type": "local", + "command": ["/venv/Scripts/mscodebase-mcp.exe", "-u", "-m", "src.main"], + "environment": { + "PYTHONPATH": "", + "PROJECT_PATH": "", + "PYTHONUTF8": "1" + } + } + } + } +} +``` -1. The **installed copy** lives in the extension directory, not in the git checkout. - `install.py` is what puts current code there (see §5). -2. The directory is named `…\Zed\extensions\…` for historical reasons. It is a plain - Python environment; OpenCode uses it the same way the Zed extension does. +Or let the CLI write it (preserves unrelated settings): -Project-local configuration: `.opencode/plugin/mscodebase_gate.ts` (commit hooks) and -`.opencode/command/*.md` (reusable agent commands). See -[HOOKS_AND_GATES.md](HOOKS_AND_GATES.md). +```bash +opencode mcp add mscodebase -- /venv/Scripts/mscodebase-mcp.exe -u -m src.main +``` -## 3. Zed (supported, not primary) +`PROJECT_PATH` may be replaced by the host's workspace variable where the host +expands it. Config changes reload without a restart (`/reload` forces it). Project +automation lives in `.opencode/plugin/mscodebase_gate.ts` and +`.opencode/command/*.md` (see [HOOKS_AND_GATES.md](HOOKS_AND_GATES.md)). + +### 3.3 Zed (supported, not primary) + +Either let the installer patch `settings.json` (default step, skip with `--no-zed`), +or paste the printed snippet under `context_servers`: + +```json +{ + "context_servers": { + "mscodebase": { + "command": "/venv/Scripts/mscodebase-mcp.exe", + "args": ["-u", "-m", "src.main"], + "env": { + "PYTHONPATH": "", + "PROJECT_PATH": "$ZED_WORKTREE_ROOT", + "PYTHONUTF8": "1" + }, + "enabled": true + } + } +} +``` -The Zed extension (`extension.toml`, `__mscodebase_ext__.marker`) launches the same -server from the same directory. Behaviour that is specific to Windows/Zed is -documented in [ZED_WINDOWS_QUIRKS.md](ZED_WINDOWS_QUIRKS.md); treat Zed as an -alternative host, not the reference implementation. +Keys match what `adapters/zed/zed_config.py::_make_server_entry` writes. Windows/Zed +specifics: [ZED_WINDOWS_QUIRKS.md](ZED_WINDOWS_QUIRKS.md). -## 4. Other MCP clients +### 3.4 Claude Code -`adapters/clients/` contains ready shapes for stdio and Streamable-HTTP: +The file `.mcp.json` in the project root uses the same `mcpServers` shape as 3.1. +The CLI form is equivalent: -| File | Client | Section | -|---|---|---| -| `claude.code.mcp.json` | Claude Code / Desktop | `mcpServers` | -| `vscode.mcp.json` | VS Code (`.vscode/mcp.json`) | `servers` | +```bash +claude mcp add-json mscodebase '{"command":"/venv/Scripts/mscodebase-mcp.exe","args":["-u","-m","src.main"],"env":{"PYTHONPATH":"","PROJECT_PATH":"","PYTHONUTF8":"1"}}' +``` -Fill in ``, ``, ``. The HTTP variant -points at `http://:8089/mcp` and requires -`Authorization: Bearer `; the remote server is built from -`deploy/docker/`. +### 3.5 Cursor + +File: `.cursor/mcp.json` (or `~/.cursor/mcp.json` for all projects), same +`mcpServers` shape as 3.1: + +```json +{ + "mcpServers": { + "mscodebase": { + "command": "/venv/Scripts/mscodebase-mcp.exe", + "args": ["-u", "-m", "src.main"], + "env": { + "PYTHONPATH": "", + "PROJECT_PATH": "", + "PYTHONUTF8": "1" + } + } + } +} +``` -For scripts and CI there is no MCP at all: +## 4. Verify a snippet without the GUI ```bash -python -m src.cli '' [--project ] +python scripts/verify_client_snippets.py --install-root ``` -The CLI uses the same DI container and tool classes; its dispatcher is a curated -allowlist (`src/cli.py`), not the whole 69-tool surface. +It renders each client's snippet exactly as the installer does, launches that command +in a throwaway directory, performs an MCP `initialize` + `tools/list` handshake and +prints one line per client. Expected (measured 2026-11-12): -## 5. Installation and the update loop +``` +generic PASS: server=MSCodebase Intelligence Server tools=17 +opencode PASS: server=MSCodebase Intelligence Server tools=17 +zed PASS: server=MSCodebase Intelligence Server tools=17 +claude-code PASS: server=MSCodebase Intelligence Server tools=17 +cursor PASS: server=MSCodebase Intelligence Server tools=17 +``` + +`tools=17` is the default `core` profile (69 are registered, 17 visible); see the +profile table in the README. + +From the agent panel, in this order: -```bash -python install.py # interactive full install (venv, pip, llama.cpp, models) -python install.py --sync-only # code only: copy + drop __pycache__, no pip/models ``` +tool_help() # profile + visible/hidden tools +get_health_report() # index, providers, resources +codebase(action="index", path="status") # chunks > 0? +``` + +## 5. The update loop -`install.py` copies `src/`, scripts and support files into -`%LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence` (or the platform equivalent) -and keeps the extension venv. After a code change: +The installed copy is not the git checkout: ``` -edit → python install.py --sync-only → restart the host (OpenCode session / Zed window) +edit src/... -> python install.py --sync-only -> restart the host (session / window) ``` Without the restart the old process keeps serving the old code; without the sync the -new code is not on disk in the installed location. Both steps are required. +new code is not on disk in the installed location. Both steps are required. Serving +from a checkout instead? Point `command`/`PYTHONPATH` at the checkout and skip the +sync step. ## 6. Remote / Docker @@ -98,8 +223,30 @@ new code is not on disk in the installed location. Both steps are required. `deploy/docker/Dockerfile` + `docker-compose.yml` build the image, and CI runs a `docker-build` job. Docker is a deployment option, not a local requirement. -## 7. Which client should a bug report mention +```json +{ + "mcpServers": { + "mscodebase-remote": { + "type": "http", + "url": "http://:8089/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +## 7. Scripts and CI (no MCP at all) + +```bash +python -m src.cli '' [--project ] +echo '' | python -m src.cli - +``` + +The CLI uses the same DI container and tool classes; its dispatcher is a curated +allowlist (`src/cli.py`), not the whole 69-tool surface. + +## 8. Which client should a bug report mention -State the host (OpenCode / Zed / Claude / VS Code / CLI), the profile +State the host (OpenCode / Zed / Claude Code / Cursor / generic / CLI), the profile (`core`/`full`), and how the server was launched. A report that omits these three cannot distinguish "tool hidden by the profile" from "tool broken". diff --git a/docs/ru/CLIENTS.md b/docs/ru/CLIENTS.md index ece54621..29938df7 100644 --- a/docs/ru/CLIENTS.md +++ b/docs/ru/CLIENTS.md @@ -1,107 +1,252 @@ # Клиенты и развёртывание -> Перевод: 2026-11-08, источник — docs/en/CLIENTS.md. Английская версия — канон. +> Перевод: 2026-11-12, источник — docs/en/CLIENTS.md. Английская версия — канон. -> Переписано 2026-11-08. Описывает, как сервер запускается каждым хостом и что -> на самом деле устанавливает `install.py`. Проверено по `install.py`, `extension.toml`, -> `adapters/clients/*`, `.opencode/plugin/` и живому конфигу OpenCode на машине -> разработки. +> Переписано 2026-11-12. Описывает, как сервер запускается каждым хостом и что +> на самом деле устанавливает `install.py`. Проверено по `install.py`, +> `adapters/clients/snippets.py`, `src/main.py`, `extension.toml`, +> `.opencode/plugin/`, `tests/test_install_cli.py` и +> `scripts/verify_client_snippets.py` (последний запускает сервер из каждого сниппета). ## 1. Что такое сервер -Python MCP-сервер (`src/main.py`), говорящий по stdio, плюс опциональная точка -входа Streamable-HTTP (`src/remote_main.py`) для docker/удалённого развёртывания. +Python MCP-сервер (`src/main.py`, entry point `mscodebase-mcp = src.main:main` в +`pyproject.toml`), говорящий по stdio, плюс опциональная точка входа +Streamable-HTTP (`src/remote_main.py`) для docker/удалённого развёртывания. Сам по себе он не плагин редактора — хост запускает его и общается по MCP. -## 2. OpenCode (основной) - -OpenCode запускает сервер как локальный MCP-сервер. На машине разработки запись -выглядит так (пути сокращены): - -```jsonc -"mcp": { - "mscodebase-intelligence": { - "type": "local", - "command": [ - "C:\\Users\\\\AppData\\Local\\Zed\\extensions\\mscodebase-intelligence\\venv\\Scripts\\mscodebase-mcp.exe", - "-u", "-m", "src.main" - ], - "environment": { - "PYTHONPATH": "C:\\Users\\\\AppData\\Local\\Zed\\extensions\\mscodebase-intelligence", - "PROJECT_PATH": "${workspaceRoot}", - "PYTHONUTF8": "1" +С v3.2 установщик **нейтрален к хосту**: на диск кладётся обычное Python-окружение, +и любой MCP-клиент может его запустить. Zed — один из адаптеров, не обязательный шаг. + +## 2. Установить сервер один раз + +```bash +cd +python install.py # venv + зависимости + код (шаги моделей спрашивают) +python install.py --no-zed # не трогать настройки Zed вообще +python install.py --sync-only # после правки кода: только копирование кода +``` + +Куда ставится (по умолчанию исторический путь; к Zed относится только имя): + +``` +Windows : %LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence +macOS : ~/Library/Application Support/Zed/extensions/mscodebase-intelligence +Linux : ~/.local/share/zed/extensions/mscodebase-intelligence +``` + +Перенести в любое место: `MSCODEBASE_INSTALL_ROOT=`. Кэши, логи и артефакты +проектов лежат отдельно (`MSCODEBASE_DATA_DIR`, по умолчанию +`%LOCALAPPDATA%\mscodebase`). + +Альтернатива без установщика (любой менеджер, дающий console scripts): + +```bash +pipx install . # или: uv tool install . +mscodebase-mcp # entry point появляется в PATH +``` + +## 3. Получить конфиг для своего хоста + +Не пишите команду и окружение руками — напечатайте их: + +```bash +python install.py --print-config --client generic # любой mcpServers-хост +python install.py --print-config --client opencode +python install.py --print-config --client zed +python install.py --print-config --client claude-code +python install.py --print-config --client cursor +``` + +Каждая команда печатает один JSON-объект и ничего больше, поэтому вывод можно +перенаправить: `python install.py --print-config --client cursor > .cursor/mcp.json`. +В сниппете уже разрешённый интерпретатор, `-u -m src.main`, `PYTHONPATH`, +`PROJECT_PATH` и `PYTHONUTF8=1`. + +### 3.1 Универсальный MCP-клиент (`mcpServers`) + +Файл: там, где хост читает свой MCP-конфиг. `` — рабочее пространство, +которое нужно индексировать (это и есть `PROJECT_PATH`). + +```json +{ + "mcpServers": { + "mscodebase": { + "command": "/venv/Scripts/mscodebase-mcp.exe", + "args": ["-u", "-m", "src.main"], + "env": { + "PYTHONPATH": "", + "PROJECT_PATH": "", + "PYTHONUTF8": "1" + } } } } ``` -Два факта, которые важны: +### 3.2 OpenCode + +Файл: `opencode.json(c)` в корне проекта или в `~/.config/opencode/`. OpenCode V2 +держит имена серверов под **`mcp.servers`** — плоская V1-форма `mcp.` ещё +принимается для миграции, но в новых конфигах не используется (источник: +, «V2 does not place server names directly +under `mcp`»): + +```json +{ + "mcp": { + "servers": { + "mscodebase": { + "type": "local", + "command": ["/venv/Scripts/mscodebase-mcp.exe", "-u", "-m", "src.main"], + "environment": { + "PYTHONPATH": "", + "PROJECT_PATH": "", + "PYTHONUTF8": "1" + } + } + } + } +} +``` -1. **Установленная копия** живёт в каталоге расширения, а не в git-чекауте. - Именно `install.py` кладёт туда актуальный код (см. §5). -2. Каталог называется `…\Zed\extensions\…` по историческим причинам. Это обычное - окружение Python; OpenCode использует его так же, как расширение Zed. +Или пусть запишет CLI (сохраняет остальные настройки): -Локальная для проекта конфигурация: `.opencode/plugin/mscodebase_gate.ts` (хуки -коммитов) и `.opencode/command/*.md` (переиспользуемые команды агента). См. -[HOOKS_AND_GATES.md](HOOKS_AND_GATES.md). +```bash +opencode mcp add mscodebase -- /venv/Scripts/mscodebase-mcp.exe -u -m src.main +``` -## 3. Zed (поддерживается, но не основной) +`PROJECT_PATH` можно заменить переменной рабочего пространства, если хост её +раскрывает. Изменения конфига применяются без перезапуска (`/reload` — принудительно). +Проектная автоматика — `.opencode/plugin/mscodebase_gate.ts` и +`.opencode/command/*.md` (см. [HOOKS_AND_GATES.md](HOOKS_AND_GATES.md)). + +### 3.3 Zed (поддерживается, но не основной) + +Либо установщик патчит `settings.json` (шаг по умолчанию, отключается `--no-zed`), +либо вставьте напечатанный сниппет под `context_servers`: + +```json +{ + "context_servers": { + "mscodebase": { + "command": "/venv/Scripts/mscodebase-mcp.exe", + "args": ["-u", "-m", "src.main"], + "env": { + "PYTHONPATH": "", + "PROJECT_PATH": "$ZED_WORKTREE_ROOT", + "PYTHONUTF8": "1" + }, + "enabled": true + } + } +} +``` -Расширение Zed (`extension.toml`, `__mscodebase_ext__.marker`) запускает тот же -сервер из того же каталога. Поведение, специфичное для Windows/Zed, описано в -[ZED_WINDOWS_QUIRKS.md](ZED_WINDOWS_QUIRKS.md); относитесь к Zed как к -альтернативному хосту, а не как к эталонной реализации. +Ключи совпадают с тем, что пишет `adapters/zed/zed_config.py::_make_server_entry`. +Windows/Zed-особенности: [ZED_WINDOWS_QUIRKS.md](ZED_WINDOWS_QUIRKS.md). -## 4. Другие MCP-клиенты +### 3.4 Claude Code -`adapters/clients/` содержит готовые формы для stdio и Streamable-HTTP: +Файл `.mcp.json` в корне проекта — та же форма `mcpServers`, что в 3.1. +Эквивалентная CLI-форма: -| Файл | Клиент | Секция | -|---|---|---| -| `claude.code.mcp.json` | Claude Code / Desktop | `mcpServers` | -| `vscode.mcp.json` | VS Code (`.vscode/mcp.json`) | `servers` | +```bash +claude mcp add-json mscodebase '{"command":"/venv/Scripts/mscodebase-mcp.exe","args":["-u","-m","src.main"],"env":{"PYTHONPATH":"","PROJECT_PATH":"","PYTHONUTF8":"1"}}' +``` -Подставьте ``, ``, ``. HTTP-вариант -указывает на `http://:8089/mcp` и требует -`Authorization: Bearer `; удалённый сервер собирается из -`deploy/docker/`. +### 3.5 Cursor + +Файл: `.cursor/mcp.json` (или `~/.cursor/mcp.json` для всех проектов), форма +`mcpServers` как в 3.1: + +```json +{ + "mcpServers": { + "mscodebase": { + "command": "/venv/Scripts/mscodebase-mcp.exe", + "args": ["-u", "-m", "src.main"], + "env": { + "PYTHONPATH": "", + "PROJECT_PATH": "", + "PYTHONUTF8": "1" + } + } + } +} +``` -Для скриптов и CI MCP вообще не нужен: +## 4. Проверить сниппет без GUI ```bash -python -m src.cli '' [--project ] +python scripts/verify_client_snippets.py --install-root ``` -CLI использует тот же DI-контейнер и те же классы инструментов; его диспетчер — -отобранный allowlist (`src/cli.py`), а не вся поверхность из 69 инструментов. +Скрипт рендерит сниппет каждого клиента ровно так же, как установщик, запускает эту +команду во временном каталоге, делает MCP-хендшейк `initialize` + `tools/list` и +печатает строку на клиента. Ожидаемо (замер 2026-11-12): -## 5. Установка и цикл обновления +``` +generic PASS: server=MSCodebase Intelligence Server tools=17 +opencode PASS: server=MSCodebase Intelligence Server tools=17 +zed PASS: server=MSCodebase Intelligence Server tools=17 +claude-code PASS: server=MSCodebase Intelligence Server tools=17 +cursor PASS: server=MSCodebase Intelligence Server tools=17 +``` + +`tools=17` — профиль `core` по умолчанию (зарегистрировано 69, видно 17); таблица +профилей — в README. + +Из панели агента, по порядку: -```bash -python install.py # interactive full install (venv, pip, llama.cpp, models) -python install.py --sync-only # code only: copy + drop __pycache__, no pip/models ``` +tool_help() # профиль + видимые/скрытые инструменты +get_health_report() # индекс, провайдеры, ресурсы +codebase(action="index", path="status") # chunks > 0? +``` + +## 5. Цикл обновления -`install.py` копирует `src/`, скрипты и вспомогательные файлы в -`%LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence` (или платформенный -эквивалент) и сохраняет venv расширения. После изменения кода: +Установленная копия — не git-чекаут: ``` -edit → python install.py --sync-only → restart the host (OpenCode session / Zed window) +правка src/... -> python install.py --sync-only -> перезапуск хоста (сессия / окно) ``` -Без перезапуска старый процесс продолжает отдавать старый код; без синхронизации -нового кода нет на диске в установленном каталоге. Нужны оба шага. +Без перезапуска старый процесс продолжает отдавать старый код; без sync нового кода +нет в установленном месте. Нужны оба шага. Если сервер запускается прямо из чекаута — +укажите его в `command`/`PYTHONPATH` и sync не нужен. + +## 6. Remote / Docker + +`src/remote_main.py` отдаёт Streamable HTTP с bearer-токеном; +`deploy/docker/Dockerfile` + `docker-compose.yml` собирают образ, CI гоняет +job `docker-build`. Docker — вариант развёртывания, не локальное требование. -## 6. Удалённо / Docker +```json +{ + "mcpServers": { + "mscodebase-remote": { + "type": "http", + "url": "http://:8089/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +## 7. Скрипты и CI (без MCP) + +```bash +python -m src.cli '' [--project ] +echo '' | python -m src.cli - +``` -`src/remote_main.py` открывает Streamable HTTP с аутентификацией по bearer-токену; -`deploy/docker/Dockerfile` + `docker-compose.yml` собирают образ, а CI гоняет job -`docker-build`. Docker — вариант развёртывания, а не локальное требование. +CLI использует тот же DI-контейнер и те же классы инструментов; диспетчер — это +отобранный allowlist (`src/cli.py`), а не все 69 инструментов. -## 7. Какого клиента указывать в отчёте о баге +## 8. Что указать в баг-репорте -Укажите хост (OpenCode / Zed / Claude / VS Code / CLI), профиль -(`core`/`full`) и то, как был запущен сервер. Отчёт без этих трёх пунктов не -позволяет отличить «инструмент скрыт профилем» от «инструмент сломан». +Укажите хост (OpenCode / Zed / Claude Code / Cursor / generic / CLI), профиль +(`core`/`full`) и способ запуска сервера. Отчёт без этих трёх вещей не отличает +«инструмент скрыт профилем» от «инструмент сломан». diff --git a/docs/zh/CLIENTS.md b/docs/zh/CLIENTS.md index 97acac00..b0a94f1a 100644 --- a/docs/zh/CLIENTS.md +++ b/docs/zh/CLIENTS.md @@ -1,104 +1,242 @@ # 客户端与部署 -> 翻译:2026-11-08,来源 docs/en/CLIENTS.md。英文版为准。 +> 翻译:2026-11-12,来源 docs/en/CLIENTS.md。英文版为准。 -> 2026-11-08 重写。描述各宿主如何启动服务器,以及 `install.py` 究竟安装了什么。 -> 已对照 `install.py`、`extension.toml`、`adapters/clients/*`、`.opencode/plugin/` -> 以及开发机上的实时 OpenCode 配置核实。 +> 2026-11-12 重写。描述各宿主如何启动服务器,以及 `install.py` 究竟安装了什么。 +> 已对照 `install.py`、`adapters/clients/snippets.py`、`src/main.py`、`extension.toml`、 +> `.opencode/plugin/`、`tests/test_install_cli.py` 与 `scripts/verify_client_snippets.py` +> (后者会从每个片段真正启动服务器)核实。 ## 1. 服务器是什么 -一个使用 stdio 通信的 Python MCP 服务器(`src/main.py`),外加一个可选的 +一个使用 stdio 通信的 Python MCP 服务器(`src/main.py`,入口点 +`mscodebase-mcp = src.main:main`,见 `pyproject.toml`),外加一个可选的 Streamable-HTTP 入口(`src/remote_main.py`),用于 docker/远程部署。它本身不是 编辑器插件 —— 由宿主启动它并通过 MCP 通信。 -## 2. OpenCode(主要宿主) - -OpenCode 把服务器作为本地 MCP 服务器启动。开发机上的条目如下(路径已截短): - -```jsonc -"mcp": { - "mscodebase-intelligence": { - "type": "local", - "command": [ - "C:\\Users\\\\AppData\\Local\\Zed\\extensions\\mscodebase-intelligence\\venv\\Scripts\\mscodebase-mcp.exe", - "-u", "-m", "src.main" - ], - "environment": { - "PYTHONPATH": "C:\\Users\\\\AppData\\Local\\Zed\\extensions\\mscodebase-intelligence", - "PROJECT_PATH": "${workspaceRoot}", - "PYTHONUTF8": "1" +自 v3.2 起安装器**与宿主无关**:它只在磁盘上放置一个普通 Python 环境,任何 MCP +客户端都能启动它。Zed 只是其中一个适配器,不是必需步骤。 + +## 2. 安装一次服务器 + +```bash +cd +python install.py # venv + 依赖 + 代码(模型步骤会询问) +python install.py --no-zed # 完全不修改 Zed 设置 +python install.py --sync-only # 改代码后:仅复制代码 +``` + +默认安装位置(历史路径,仅名字与 Zed 有关): + +``` +Windows : %LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence +macOS : ~/Library/Application Support/Zed/extensions/mscodebase-intelligence +Linux : ~/.local/share/zed/extensions/mscodebase-intelligence +``` + +用 `MSCODEBASE_INSTALL_ROOT=` 可放到任意位置。缓存、日志与项目产物在别处 +(`MSCODEBASE_DATA_DIR`,默认 `%LOCALAPPDATA%\mscodebase`)。完整清单见 + + +不用安装器的替代方式(任何提供 console scripts 的包管理器): + +```bash +pipx install . # 或:uv tool install . +mscodebase-mcp # 入口点已在 PATH 中 +``` + +## 3. 为你的宿主生成配置 + +不要手写命令与环境变量 —— 让安装器打印: + +```bash +python install.py --print-config --client generic # 任何 mcpServers 风格宿主 +python install.py --print-config --client opencode +python install.py --print-config --client zed +python install.py --print-config --client claude-code +python install.py --print-config --client cursor +``` + +每条命令只打印一个 JSON 对象,因此可以重定向: +`python install.py --print-config --client cursor > .cursor/mcp.json`。片段中已包含 +解析后的解释器、`-u -m src.main`、`PYTHONPATH`、`PROJECT_PATH` 和 `PYTHONUTF8=1`。 + +### 3.1 通用 MCP 客户端(`mcpServers`) + +文件:宿主读取其 MCP 配置的位置。`` 是要索引的工作区(即 `PROJECT_PATH`)。 + +```json +{ + "mcpServers": { + "mscodebase": { + "command": "/venv/Scripts/mscodebase-mcp.exe", + "args": ["-u", "-m", "src.main"], + "env": { + "PYTHONPATH": "", + "PROJECT_PATH": "", + "PYTHONUTF8": "1" + } } } } ``` -两个关键事实: +### 3.2 OpenCode + +文件:项目根目录或 `~/.config/opencode/` 下的 `opencode.json(c)`。OpenCode V2 把 +服务器名称放在 **`mcp.servers`** 之下 —— 扁平的 V1 形式 `mcp.` 仍被接受 +(迁移兼容),但不是新配置的写法(来源:, +"V2 does not place server names directly under `mcp`"): + +```json +{ + "mcp": { + "servers": { + "mscodebase": { + "type": "local", + "command": ["/venv/Scripts/mscodebase-mcp.exe", "-u", "-m", "src.main"], + "environment": { + "PYTHONPATH": "", + "PROJECT_PATH": "", + "PYTHONUTF8": "1" + } + } + } + } +} +``` -1. **已安装副本**位于扩展目录,而不是 git checkout。把当前代码放进去的正是 - `install.py`(见 §5)。 -2. 该目录因历史原因命名为 `…\Zed\extensions\…`。它就是一个普通 Python 环境; - OpenCode 使用它的方式与 Zed 扩展完全相同。 +也可以让 CLI 写入(会保留其它设置): -项目级配置:`.opencode/plugin/mscodebase_gate.ts`(提交钩子)和 -`.opencode/command/*.md`(可复用的 agent 命令)。见 -[HOOKS_AND_GATES.md](HOOKS_AND_GATES.md)。 +```bash +opencode mcp add mscodebase -- /venv/Scripts/mscodebase-mcp.exe -u -m src.main +``` -## 3. Zed(受支持,但非主要) +若宿主会展开工作区变量,`PROJECT_PATH` 可替换为该变量。配置修改无需重启即生效 +(`/reload` 可强制重载)。项目级自动化在 `.opencode/plugin/mscodebase_gate.ts` 与 +`.opencode/command/*.md`(见 [HOOKS_AND_GATES.md](HOOKS_AND_GATES.md))。 + +### 3.3 Zed(支持,但不是主要宿主) + +可以让安装器修改 `settings.json`(默认步骤,用 `--no-zed` 关闭),或把打印出的片段 +粘贴到 `context_servers` 下: + +```json +{ + "context_servers": { + "mscodebase": { + "command": "/venv/Scripts/mscodebase-mcp.exe", + "args": ["-u", "-m", "src.main"], + "env": { + "PYTHONPATH": "", + "PROJECT_PATH": "$ZED_WORKTREE_ROOT", + "PYTHONUTF8": "1" + }, + "enabled": true + } + } +} +``` -Zed 扩展(`extension.toml`、`__mscodebase_ext__.marker`)从同一目录启动同一个 -服务器。Windows/Zed 特有的行为记录在 [ZED_WINDOWS_QUIRKS.md](ZED_WINDOWS_QUIRKS.md); -请把 Zed 视为替代宿主,而不是参考实现。 +键与 `adapters/zed/zed_config.py::_make_server_entry` 写入的一致。Windows/Zed 细节: +[ZED_WINDOWS_QUIRKS.md](ZED_WINDOWS_QUIRKS.md)。 -## 4. 其他 MCP 客户端 +### 3.4 Claude Code -`adapters/clients/` 中包含 stdio 与 Streamable-HTTP 的现成配置形状: +项目根目录的 `.mcp.json`,与 3.1 相同的 `mcpServers` 结构。等价的 CLI 写法: -| 文件 | 客户端 | 配置段 | -|---|---|---| -| `claude.code.mcp.json` | Claude Code / Desktop | `mcpServers` | -| `vscode.mcp.json` | VS Code(`.vscode/mcp.json`) | `servers` | +```bash +claude mcp add-json mscodebase '{"command":"/venv/Scripts/mscodebase-mcp.exe","args":["-u","-m","src.main"],"env":{"PYTHONPATH":"","PROJECT_PATH":"","PYTHONUTF8":"1"}}' +``` -填入 ``、``、``。HTTP 变体指向 -`http://:8089/mcp`,并要求 -`Authorization: Bearer `;远程服务器由 -`deploy/docker/` 构建。 +### 3.5 Cursor + +文件:`.cursor/mcp.json`(或 `~/.cursor/mcp.json` 用于所有项目),结构与 3.1 相同: + +```json +{ + "mcpServers": { + "mscodebase": { + "command": "/venv/Scripts/mscodebase-mcp.exe", + "args": ["-u", "-m", "src.main"], + "env": { + "PYTHONPATH": "", + "PROJECT_PATH": "", + "PYTHONUTF8": "1" + } + } + } +} +``` -脚本和 CI 完全不需要 MCP: +## 4. 不用 GUI 验证片段 ```bash -python -m src.cli '' [--project ] +python scripts/verify_client_snippets.py --install-root ``` -CLI 使用同一个 DI 容器和工具类;其分发器是一份精选 allowlist(`src/cli.py`), -而不是全部 69 个工具的接口。 +脚本按安装器的相同方式渲染每个客户端的片段,在临时目录中启动该命令,完成 MCP +`initialize` + `tools/list` 握手,并为每个客户端打印一行。预期结果(2026-11-12 实测): -## 5. 安装与更新循环 +``` +generic PASS: server=MSCodebase Intelligence Server tools=17 +opencode PASS: server=MSCodebase Intelligence Server tools=17 +zed PASS: server=MSCodebase Intelligence Server tools=17 +claude-code PASS: server=MSCodebase Intelligence Server tools=17 +cursor PASS: server=MSCodebase Intelligence Server tools=17 +``` -```bash -python install.py # interactive full install (venv, pip, llama.cpp, models) -python install.py --sync-only # code only: copy + drop __pycache__, no pip/models +`tools=17` 是默认 `core` 档案(注册 69,可见 17);档案表见 README。 + +在代理面板中依次执行: + +``` +tool_help() # 档案 + 可见/隐藏工具 +get_health_report() # 索引、提供方、资源 +codebase(action="index", path="status") # chunks > 0? ``` -`install.py` 把 `src/`、脚本和支持文件复制到 -`%LOCALAPPDATA%\Zed\extensions\mscodebase-intelligence`(或平台对应位置), -并保留扩展 venv。代码改动之后: +## 5. 更新循环 + +安装副本不是 git 检出: ``` -edit → python install.py --sync-only → restart the host (OpenCode session / Zed window) +修改 src/... -> python install.py --sync-only -> 重启宿主(会话 / 窗口) ``` -不重启,旧进程会继续提供旧代码;不同步,新代码就不在已安装位置的磁盘上。 -两个步骤都必需。 +不重启则旧进程继续提供旧代码;不 sync 则安装位置没有新代码。两步都必需。如果直接 +从检出运行 —— 把检出路径写进 `command`/`PYTHONPATH`,就不需要 sync。 ## 6. 远程 / Docker -`src/remote_main.py` 暴露带 bearer token 认证的 Streamable HTTP; -`deploy/docker/Dockerfile` + `docker-compose.yml` 构建镜像,CI 运行 -`docker-build` 任务。Docker 是部署选项,不是本地必需。 +`src/remote_main.py` 提供带 bearer 令牌的 Streamable HTTP; +`deploy/docker/Dockerfile` + `docker-compose.yml` 构建镜像,CI 运行 `docker-build` +job。Docker 是部署选项,不是本地必需。 + +```json +{ + "mcpServers": { + "mscodebase-remote": { + "type": "http", + "url": "http://:8089/mcp", + "headers": { "Authorization": "Bearer " } + } + } +} +``` + +## 7. 脚本与 CI(完全不用 MCP) + +```bash +python -m src.cli '' [--project ] +echo '' | python -m src.cli - +``` + +CLI 使用同一 DI 容器与同一批工具类;分发器是精选的 allowlist(`src/cli.py`), +而不是全部 69 个工具。 -## 7. bug 报告应说明哪个客户端 +## 8. 缺陷报告应包含什么 -说明宿主(OpenCode / Zed / Claude / VS Code / CLI)、profile(`core`/`full`), -以及服务器是如何启动的。缺少这三项的报告无法区分“工具被 profile 隐藏”与 -“工具损坏”。 +说明宿主(OpenCode / Zed / Claude Code / Cursor / generic / CLI)、档案 +(`core`/`full`)以及服务器的启动方式。缺少这三项的报告无法区分 +"工具被档案隐藏" 与 "工具损坏"。 diff --git a/install.py b/install.py index 6b3d6bad..050f6c9e 100644 --- a/install.py +++ b/install.py @@ -1,8 +1,19 @@ """ -MSCodebase Intelligence — Installer & Updater v3.1 +MSCodebase Intelligence — Installer & Updater v3.2 ================================================= Robust TUI with error recovery, self-healing, and safe cross-platform I/O. +Host-neutral since v3.2: the server is installed into a plain Python +environment and any MCP host can launch it. Zed is an optional adapter, not a +required step. The default install root keeps the historical Zed extension path +so existing installs keep updating in place; set MSCODEBASE_INSTALL_ROOT to put +it anywhere else. + +Non-interactive modes (no TUI, safe for scripts and tests): + install.py --print-config [--client generic|opencode|zed|claude-code|cursor] + print a ready-to-paste stdio snippet + install.py --help list modes + Design principles: - Every step wrapped in try/except with retry/skip/cancel - Progress bar via single \r line (no flicker) @@ -29,6 +40,13 @@ резолвится за пределы ZED_EXT_DIR. 7. step_pip теперь предпочитает requirements-lock.txt, если он есть рядом с requirements.txt (совместимо с dependency-lock практикой, принятой в проекте). + +Changelog v3.1 -> v3.2 (G5 universal install + G6 data hygiene): + 1. Zed больше не импортируется на верхнем уровне: install.py запускается на + машине без Zed и без adapters/ — адаптер подключается лениво внутри шагов. + 2. INSTALL_ROOT отвязан от Zed: MSCODEBASE_INSTALL_ROOT переопределяет каталог + установки (историческое имя папки расширения остаётся только default). + 3. Добавлены --print-config/--help: готовый stdio-сниппет для любого MCP-клиента. """ import io @@ -45,18 +63,34 @@ from pathlib import Path from typing import Optional -from adapters.zed.zed_config import get_zed_config_dir, patch_zed_settings - logger = logging.getLogger(__name__) logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s: %(message)s") PROJECT_ROOT = Path(__file__).resolve().parent -ZED_EXT_DIR = ( + +# ─── Roots: server installation vs host adapter ───────────────────────────── +# The installed copy is a plain Python environment. Nothing about it is +# Zed-specific; the path below is only the historical default so that existing +# installs keep updating in place. MSCODEBASE_INSTALL_ROOT moves it anywhere +# (a neutral location, a CI temp dir, a per-user tools directory). +LEGACY_INSTALL_ROOT = ( Path(os.environ.get("LOCALAPPDATA", os.path.expanduser("~"))) / "Zed" / "extensions" / "mscodebase-intelligence" ) + + +def resolve_install_root() -> Path: + """INSTALL_ROOT from MSCODEBASE_INSTALL_ROOT, else the legacy Zed path.""" + raw = os.environ.get("MSCODEBASE_INSTALL_ROOT", "").strip() + if raw: + return Path(raw).expanduser().resolve() + return LEGACY_INSTALL_ROOT + + +INSTALL_ROOT = resolve_install_root() +ZED_EXT_DIR = INSTALL_ROOT # legacy alias: the Zed extension reads this tree VENV_DIR = ZED_EXT_DIR / "venv" IS_WINDOWS = sys.platform == "win32" PYTHON_EXE = VENV_DIR / "Scripts" / "python.exe" if IS_WINDOWS else VENV_DIR / "bin" / "python3" @@ -383,6 +417,19 @@ def deco(fn): @_step(0) def step_zed(lines, lang): + """Optional host adapter: the Zed config directory only. + + The server itself does not need Zed. On a machine without the adapter the + step is a no-op, not a failure (G5: server != host adapter). + """ + if not zed_enabled(): + lines.append((C.D, "· Skipped (--no-zed)")) + return + try: + from adapters.zed.zed_config import get_zed_config_dir + except Exception as e: # adapter absent / broken: server still installs + lines.append((C.YEL, f"· Zed adapter unavailable ({e})")) + return zcd = get_zed_config_dir() zcd.mkdir(parents=True, exist_ok=True) lines.append((C.GRN, f"✓ {zcd}")) @@ -662,7 +709,10 @@ def step_llama(lines, lang): @_step(7) def step_gguf(lines, lang): - """Скачивает GGUF модели: multilingual-e5-small (embedder) + bge-reranker-v2-m3.""" + """Скачивает GGUF модели: multilingual-e5-small (embedder) + bge-reranker-v2-m3. + + """ + from src.providers.reranker.llama_runner import is_model_downloaded, download_gguf_model, GGUF_MODELS, _get_models_dir all_ok = True @@ -711,8 +761,9 @@ def step_gguf(lines, lang): def step_models(lines, lang): """Скачивает ONNX-модели: multilingual-e5-small-int8 (embedder) + bge-reranker-v2-m3. - Проверяет по порядку: ZED_EXT_DIR -> общий кэш ~/.cache/mscodebase -> PROJECT_ROOT. + Проверяет по порядку: ZED_EXT_DIR -> общий кэш data_root/models -> PROJECT_ROOT. Найдено в (2) или (3) -> копирует в (1). Не найдено нигде -> скачивает. + """ SHARED_DIR = Path.home() / ".cache" / "mscodebase" / "models" models = { @@ -839,6 +890,18 @@ def step_zedcfg(lines, lang): # Имя exe — то, что показывает Task Manager (у pythonw нет консоли для # title): запускаемся через mscodebase-mcp.exe-хардлинк → pythonw.exe, # fallback — plain pythonw при ошибке создания линка. + # + # G5: шаг опционален. Любой другой хост получает конфиг через + # `install.py --print-config --client <...>`; отказ Zed-адаптера не роняет + # установку. + if not zed_enabled(): + lines.append((C.D, "· Skipped (--no-zed)")) + return + try: + from adapters.zed.zed_config import patch_zed_settings + except Exception as e: + lines.append((C.YEL, f"· Zed adapter unavailable ({e})")) + return mcp_exe = str(_ensure_mcp_hardlink()) if IS_WINDOWS else str(MCP_PYTHON) cmd = f"{mcp_exe} -u -m src.main" if patch_zed_settings( @@ -888,6 +951,97 @@ def _build_uninstaller() -> str: ) +# ─── Non-interactive modes: config snippet (--print-config) ──────────────── +def zed_enabled(argv: Optional[list[str]] = None) -> bool: + """Zed adapter is opt-out: --no-zed keeps the install host-neutral.""" + argv = sys.argv if argv is None else argv + return "--no-zed" not in argv + + + + + + +def _mcp_launch_command(argv: list[str]) -> tuple[str, list[str]]: + """The exact command a host must run: installed entry point, else venv python. + + `--python PATH` overrides the interpreter (used by the cold-start test and by + people who installed with pipx/uv, where `mscodebase-mcp` is on PATH). + """ + if "--python" in argv: + idx = argv.index("--python") + if idx + 1 >= len(argv): + raise ValueError("--python needs a path") + return argv[idx + 1], ["-u", "-m", "src.main"] + for exe in (MCP_EXE, PYTHON_EXE): + try: + if Path(exe).exists(): + return str(exe), ["-u", "-m", "src.main"] + except OSError: + continue + return sys.executable, ["-u", "-m", "src.main"] + + +def _print_config(client: str, project: Path, argv: list[str]) -> int: + """Print a ready-to-paste stdio snippet for one MCP host.""" + try: + from adapters.clients.snippets import CLIENTS, render_snippet + except Exception as e: # pragma: no cover - adapters are shipped with the repo + print(f"error: client adapters unavailable: {e}", file=sys.stderr) + return 1 + + if client not in CLIENTS: + print( + f"error: unknown client '{client}'. Known: {', '.join(CLIENTS)}", + file=sys.stderr, + ) + return 2 + + command, args = _mcp_launch_command(argv) + env = { + "PYTHONPATH": str(INSTALL_ROOT), + "PROJECT_PATH": str(project), + "PYTHONUTF8": "1", + } + print(render_snippet(client, command=command, args=args, env=env), end="") + return 0 + + + + + + + + +def _print_help() -> int: + print( + "usage: install.py [mode] [options]\n\n" + "Modes (no TUI):\n" + " (default) interactive install: venv, pip, optional Zed adapter\n" + " --sync-only copy the current sources into the install root\n" + " --print-config print a stdio snippet for an MCP host\n" + " --client {generic,opencode,zed,claude-code,cursor} (default: generic)\n" + " --python PATH interpret the snippet around this interpreter\n" + "Install options:\n" + " --no-zed do not touch Zed settings; host-neutral install\n" + " --yes accept every step without prompts\n\n" + "Environment:\n" + " MSCODEBASE_INSTALL_ROOT where the installed copy lives\n" + " MSCODEBASE_DATA_DIR where caches/logs/project artifacts live\n" + " PROJECT_PATH project the host should index\n" + ) + return 0 + + +def _mode_from_argv(argv: list[str]) -> Optional[str]: + for mode in ("--print-config", "--help"): + if mode in argv: + return mode + if "-h" in argv: + return "--help" + return None + + # ─── Main ────────────────────────────────────────────────── def _record_install_meta() -> None: """DEV-ONLY: метаданные для детекции рассинхрона source<->extension. @@ -934,11 +1088,43 @@ def _record_install_meta() -> None: logger.debug("_record_install_meta failed: %s", e) +def _utf8_stdout() -> None: + """UTF-8 stdout on Windows for every mode (paths and reports contain non-ASCII). + + The TUI already did this; the non-interactive modes used to inherit cp1251 + and crashed on the first non-ASCII byte (found 2026-11-12 on a Cyrillic path). + """ + if not IS_WINDOWS: + return + try: + sys.stdout = io.TextIOWrapper( + sys.stdout.buffer, encoding="utf-8", errors="replace", line_buffering=True + ) + sys.stderr = io.TextIOWrapper( + sys.stderr.buffer, encoding="utf-8", errors="replace", line_buffering=True + ) + except Exception as e: # noqa: BLE001 - never fail because of cosmetics + logger.debug("utf-8 stdout wrap failed: %s", e) + + def main() -> None: + argv = sys.argv[1:] + _utf8_stdout() + mode = _mode_from_argv(argv) + if mode == "--help": + sys.exit(_print_help()) + if mode == "--print-config": + client = "generic" + if "--client" in argv: + idx = argv.index("--client") + client = argv[idx + 1] if idx + 1 < len(argv) else "" + sys.exit(_print_config(client, Path.cwd(), argv)) + _enable_ansi() lang = _detect_lang() - if IS_WINDOWS: - sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") + # stdout is already UTF-8: _utf8_stdout() ran at the top of main(). Re-wrapping + # here closed the previous wrapper's buffer (ValueError: I/O on closed file, + # found 2026-11-12 when the same wrap ran twice). STEPS.sort(key=lambda x: x[0]) @@ -971,7 +1157,10 @@ def main() -> None: max_retries = 2 step_status = "ok" - for attempt in range(0 if auto_yes else max_retries + 1): + # auto_yes means "no retry prompts", not "no steps": a zero-trip range() + # used to fall through to the for/else branch and mark every step failed + # without executing it (found 2026-11-12 by a clean-room install). + for attempt in range(1 if auto_yes else max_retries + 1): try: fn(lines, lang) if compact: diff --git a/scripts/verify_client_snippets.py b/scripts/verify_client_snippets.py new file mode 100644 index 00000000..6757e602 --- /dev/null +++ b/scripts/verify_client_snippets.py @@ -0,0 +1,180 @@ +"""Cold-start check for the client snippets printed by `install.py --print-config`. + +For every client it renders the snippet exactly as the installer does, launches +the command from it in a throwaway directory, performs an MCP `initialize` +handshake and then `tools/list`, and reports PASS/FAIL per client. This is the +mechanical proof behind the copy-paste sections of docs/CLIENTS.md: the snippet +is not just valid JSON, it starts a server that answers. + +Usage: + python scripts/verify_client_snippets.py [--python PATH] [--timeout SEC] +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO)) + +# ENCODING SAFETY (Windows): the server's stderr tail is Russian, and a cp1252 +# console crashes the report with UnicodeEncodeError (windows-latest, 2026-11-12). +if sys.stdout.encoding and sys.stdout.encoding.lower() != "utf-8": + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + except (AttributeError, ValueError): + pass + +from adapters.clients.snippets import CLIENTS, snapshot # noqa: E402 + +_TOP_LEVEL = { + "generic": "mcpServers", + "claude-code": "mcpServers", + "cursor": "mcpServers", + "opencode": "mcp", + "zed": "context_servers", +} + +INITIALIZE = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "snippet-smoke", "version": "0"}, + }, +} +LIST_TOOLS = {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}} + + +def _entry(client: str, command: str, args: list[str], env: dict[str, str]) -> tuple[str, list[str], dict[str, str]]: + payload = snapshot(client, command=command, args=args, env=env) + if client == "opencode": + # V2 nests the entry under mcp.servers. (see adapters/clients/snippets.py). + entry = payload["mcp"]["servers"]["mscodebase"] + return entry["command"][0], entry["command"][1:], entry["environment"] + entry = payload[_TOP_LEVEL[client]]["mscodebase"] + return entry["command"], entry["args"], entry["env"] + + +def _read_message(stream, timeout: float): + """Next JSON object from stdout, or None on timeout/EOF.""" + import queue + import threading + + box: queue.Queue = queue.Queue(maxsize=1) + + def _worker(): + while True: + line = stream.readline() + if not line: + box.put(None) + return + line = line.strip() + if line.startswith("{"): + try: + box.put(json.loads(line)) + return + except json.JSONDecodeError: + continue + + threading.Thread(target=_worker, daemon=True).start() + try: + return box.get(timeout=timeout) + except queue.Empty: + return None + + +def _stderr_drain(stream, sink) -> None: + """Keep reading stderr so the child never blocks on a full pipe. + + Found 2026-11-12 on windows-latest: the startup log is larger than the pipe + buffer, the server blocked writing stderr and never answered `initialize`. + """ + import threading + + def _worker(): + try: + for line in iter(stream.readline, ""): + sink.append(line.rstrip()) + except (ValueError, OSError): + pass + + threading.Thread(target=_worker, daemon=True).start() + + +def _probe(client: str, command: str, args: list[str], env: dict[str, str], timeout: int) -> str: + from collections import deque + + exe, cli_args, cli_env = _entry(client, command, args, env) + full_env = os.environ.copy() + full_env.update(cli_env) + # src/main.py:126 reads LOG_LEVEL; a quiet server keeps the stderr pipe from + # filling on a cold runner (the probe only needs the protocol answer). + full_env.setdefault("LOG_LEVEL", "WARNING") + stderr_tail: deque = deque(maxlen=20) + with tempfile.TemporaryDirectory(prefix="mcp-snippet-", ignore_cleanup_errors=True) as cwd: + proc = subprocess.Popen( + [exe, *cli_args], + cwd=cwd, + env=full_env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) + _stderr_drain(proc.stderr, stderr_tail) + try: + proc.stdin.write(json.dumps(INITIALIZE) + "\n") + proc.stdin.flush() + init = _read_message(proc.stdout, timeout) + if not init or "result" not in init: + tail = " | ".join(stderr_tail) if stderr_tail else "" + return f"FAIL: no initialize result ({tail[-300:]})" + + proc.stdin.write( + json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n" + ) + proc.stdin.write(json.dumps(LIST_TOOLS) + "\n") + proc.stdin.flush() + listing = _read_message(proc.stdout, timeout) + tools = len(listing.get("result", {}).get("tools", [])) if listing else 0 + name = init["result"].get("serverInfo", {}).get("name", "?") + return f"PASS: server={name} tools={tools}" + finally: + try: + proc.kill() + except OSError: + pass + proc.wait(timeout=30) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--python", default="", help="interpreter/entry point to probe (default: current)") + ap.add_argument("--timeout", type=int, default=300) + ap.add_argument("--install-root", default=os.environ.get("MSCODEBASE_INSTALL_ROOT", str(REPO))) + args = ap.parse_args() + + command = args.python or sys.executable + env = {"PYTHONPATH": args.install_root, "PROJECT_PATH": tempfile.gettempdir(), "PYTHONUTF8": "1"} + + failures = 0 + for client in CLIENTS: + result = _probe(client, command, ["-u", "-m", "src.main"], env, args.timeout) + failures += 0 if result.startswith("PASS") else 1 + print(f"{client:<12} {result}") + return 0 if failures == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_install_cli.py b/tests/test_install_cli.py new file mode 100644 index 00000000..e398dae5 --- /dev/null +++ b/tests/test_install_cli.py @@ -0,0 +1,229 @@ +"""Red-first tests for the installer's host-neutral CLI (G5). + +Every check drives `install.py` as a subprocess with isolated roots +(`MSCODEBASE_INSTALL_ROOT`, `MSCODEBASE_DATA_DIR`) so nothing touches the real +user profile. The snippets are not just parsed: the generic snippet's command is +actually launched and must answer an MCP `initialize` request. +""" + +from __future__ import annotations + +import importlib.util +import json +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] +INSTALL_PY = REPO / "install.py" + + +def _install_module(): + spec = importlib.util.spec_from_file_location("mscodebase_install", INSTALL_PY) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + +SNIPPET_CLIENTS = ["generic", "opencode", "zed", "claude-code", "cursor"] + +TOP_LEVEL_KEY = { + "generic": "mcpServers", + "claude-code": "mcpServers", + "cursor": "mcpServers", + "opencode": "mcp", + "zed": "context_servers", +} + + +def _env(install_root: Path, data_dir: Path) -> dict[str, str]: + env = os.environ.copy() + env.update( + { + "MSCODEBASE_INSTALL_ROOT": str(install_root), + "MSCODEBASE_DATA_DIR": str(data_dir), + "PYTHONIOENCODING": "utf-8", + "PYTHONUTF8": "1", + } + ) + return env + + +def run_install( + args: list[str], + *, + install_root: Path, + data_dir: Path, + cwd: Path, + stdin_text: str | None = None, + timeout: int = 120, +) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(INSTALL_PY), *args], + cwd=str(cwd), + env=_env(install_root, data_dir), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + input=stdin_text if stdin_text is not None else "", + timeout=timeout, + ) + + +def entry_json(client: str, payload: dict) -> dict: + if client == "opencode": + # V2: mcp.servers. + return payload["mcp"]["servers"]["mscodebase"] + return payload[TOP_LEVEL_KEY[client]]["mscodebase"] + + +# ─── G5: --print-config ──────────────────────────────────────────────── + + +@pytest.mark.parametrize("client", SNIPPET_CLIENTS) +def test_print_config_is_valid_json_per_client(client, tmp_path): + install_root = tmp_path / "install" + data_dir = tmp_path / "data" + project = tmp_path / "project" + project.mkdir() + + proc = run_install( + ["--print-config", "--client", client], + install_root=install_root, + data_dir=data_dir, + cwd=project, + ) + assert proc.returncode == 0, proc.stderr + payload = json.loads(proc.stdout) + entry = entry_json(client, payload) + + if client == "opencode": + command = entry["command"] + assert entry["type"] == "local" + env = entry["environment"] + assert "servers" in payload["mcp"], "OpenCode V2 keeps servers under mcp.servers" + elif client == "zed": + command = [entry["command"], *entry["args"]] + env = entry["env"] + assert entry["enabled"] is True + else: + command = [entry["command"], *entry["args"]] + env = entry["env"] + + name = Path(command[0]).name + assert name.startswith(("python", "mscodebase-mcp")), name + assert command[1:] == ["-u", "-m", "src.main"] + assert Path(env["PYTHONPATH"]).resolve() == install_root.resolve() + assert env["PYTHONUTF8"] == "1" + assert Path(env["PROJECT_PATH"]).resolve() == project.resolve() + + +def test_print_config_rejects_unknown_client(tmp_path): + proc = run_install( + ["--print-config", "--client", "not-a-client"], + install_root=tmp_path / "install", + data_dir=tmp_path / "data", + cwd=tmp_path, + ) + assert proc.returncode == 2 + assert "not-a-client" in (proc.stdout + proc.stderr) + + +def test_print_config_default_client_is_generic(tmp_path): + proc = run_install( + ["--print-config"], + install_root=tmp_path / "install", + data_dir=tmp_path / "data", + cwd=tmp_path, + ) + assert proc.returncode == 0, proc.stderr + assert "mcpServers" in json.loads(proc.stdout) + + +def test_generic_snippet_actually_starts_the_server(tmp_path): + """The printed snippet must be copy-paste launchable (real MCP handshake).""" + project = tmp_path / "project" + project.mkdir() + + proc = run_install( + ["--print-config", "--client", "generic", "--python", sys.executable], + install_root=REPO, + data_dir=tmp_path / "data", + cwd=project, + ) + assert proc.returncode == 0, proc.stderr + entry = entry_json("generic", json.loads(proc.stdout)) + + env = os.environ.copy() + env.update(entry["env"]) + env["MSCODEBASE_DATA_DIR"] = str(tmp_path / "data") + + request = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "snippet-probe", "version": "0"}, + }, + } + with subprocess.Popen( + [entry["command"], *entry["args"]], + cwd=str(project), + env=env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + ) as proc2: + out, err = proc2.communicate(json.dumps(request) + "\n", timeout=300) + + answer = None + for line in out.splitlines(): + line = line.strip() + if line.startswith("{"): + try: + candidate = json.loads(line) + except json.JSONDecodeError: + continue + if candidate.get("id") == 1: + answer = candidate + break + assert answer is not None, textwrap.shorten(out + err, 800) + assert answer["result"]["serverInfo"]["name"], answer + + +# ─── G5: --yes must run the steps, not skip all of them ──────────────── + + +def test_auto_yes_runs_each_step_once(monkeypatch): + """Regression: `range(0 if auto_yes else ...)` ran zero steps and marked all failed. + + Found by a clean-room install (2026-11-12): `install.py --yes` printed the + summary box while creating nothing on disk. + """ + mod = _install_module() + calls: list[str] = [] + + def _fake_step(lines, lang): + calls.append("ran") + + monkeypatch.setattr(mod, "STEPS", [(0, "step_fake", _fake_step)]) + monkeypatch.setattr(mod.sys, "argv", ["install.py", "--yes"]) + monkeypatch.setattr(mod, "_enable_ansi", lambda: None) + monkeypatch.setattr(mod, "_utf8_stdout", lambda: None) + monkeypatch.setattr(mod, "_record_install_meta", lambda: None) + monkeypatch.setattr(mod, "_box", lambda *a, **k: None) + monkeypatch.setattr(mod, "_line", lambda *a, **k: None) + + mod.main() + + assert calls == ["ran"], "auto-yes marked the step failed instead of running it" +