diff --git a/README.md b/README.md index 6a56b40..dfc59fa 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,8 @@ softpractice update | `softpractice open` | Open the task, material, or latest result in the browser | | `softpractice mcp` | Run the MCP server for Claude Code, Codex, Claude Desktop, and other agents | | `softpractice mcp setup codex-desktop` | Configure MCP for Codex Desktop without installing Codex CLI | +| `softpractice mcp status` | Check the running server and its project | +| `softpractice mcp remove codex-desktop` | Remove the connection from Codex settings | | `softpractice project restore` | Restore a project from the latest usable revision | Run `softpractice help ` for detailed help. @@ -149,128 +151,120 @@ never replaces them, and they are not part of your submission. `softpractice mcp` connects a coding agent to your SoftPractice lesson: reading the task and material, running public checks, submitting a commit, and -reading the evaluation. It is a local **stdio** MCP server: your MCP client -starts the process and communicates with it. No server URL or listening port -is needed. +reading the evaluation. -### Before connecting +### Configure once, from any folder -Install the CLI (see [Install](#install)), sign in, and open an existing lesson -Git project, downloaded with `softpractice starter` or restored with -`softpractice project restore`: +Install the CLI (see [Install](#install)), then configure your desktop client. +You do not need a lesson project yet: ```bash -softpractice login -cd /path/to/your/lesson-project -softpractice status +softpractice mcp setup claude-desktop +# or +softpractice mcp setup codex-desktop ``` -The MCP server uses the CLI's saved sign-in. It never starts a sign-in itself; -there is no separate MCP OAuth login. Configure the launch command -`softpractice mcp` in your client. The client starts it for you, so you do not -need to leave it running in a terminal. +Setup registers the CLI's absolute executable path and `mcp connect` as the +launch arguments. It preserves other settings and saves the previous file as +`.bak`. Claude Desktop uses `claude_desktop_config.json`; on Windows, setup +also finds the configuration used by the Microsoft Store version. Codex uses +`~/.codex/config.toml` (or `$CODEX_HOME/config.toml`) and a tool timeout of +180 seconds. Codex Desktop, CLI, and the IDE extension share these settings. +No separate Codex CLI installation is needed for desktop setup. -By default it works in the directory where the client starts it. Use -`--project /absolute/path/to/your/lesson-project` to pin the folder, especially -for desktop clients. When switching projects, change that path. If the client -cannot find `softpractice` on `PATH`, use the executable's full path as the -command. The client must run on the computer where the CLI and project exist. +For manual configuration, append `--print` to either setup command. It prints +the JSON or TOML entry without writing settings. Replace an existing +`softpractice` entry rather than adding a duplicate. -### Claude Desktop +### Start the lesson server -From the lesson project folder: +Sign in, then start the server for a Git project downloaded with +`softpractice starter` or restored with `softpractice project restore`: ```bash -softpractice mcp setup claude-desktop +softpractice login +cd /path/to/your/lesson-project +softpractice mcp ``` -The command adds the `softpractice` server to the Claude Desktop settings -file, `claude_desktop_config.json`, with absolute paths to the program and the -project: Claude Desktop does not see the `PATH` from your terminal profile and -does not start the server in your project folder. Other servers and app -settings in the file are kept; the previous file is saved next to it as -`claude_desktop_config.json.bak`. On Windows, the command also finds the file -that the Microsoft Store version of the app actually reads. - -Then quit Claude Desktop completely, not just its window, and start it again. -The `softpractice` tools appear in the chat's tools menu. When you switch to -another lesson project, run the command again in its folder. - -To configure the app manually, print the entry and add it to `mcpServers` in -the file opened by **Settings → Developer → Edit Config**, keeping the servers -already configured: +Or start from any folder with an explicit project: ```bash -softpractice mcp setup claude-desktop --print +softpractice mcp --project /path/to/your/lesson-project ``` -### Claude Code +Keep this terminal open. The server validates the Git repository and +SoftPractice project link before accepting connections. It uses the CLI's +saved sign-in; it never opens a sign-in flow itself. -From the lesson project folder: +Start the server before restarting the desktop client. The client's stdio +bridge, `softpractice mcp connect`, forwards MCP messages to the running +server on `127.0.0.1:39473`. Connections require a random per-run key saved in +the CLI's user configuration folder. Both processes must run on the same +computer under the same user and use the same `SOFTPRACTICE_CONFIG_DIR` if +that environment variable is set. This address is not an HTTP URL. -```bash -claude mcp add --transport stdio --scope local softpractice -- softpractice mcp -claude mcp list -``` +Only one lesson server can run on this computer at a time. To switch projects, +stop it with Ctrl+C, start it in the other lesson folder, and reconnect the +client's MCP connection (or restart the client). Setup does not need to be +repeated. If the server is offline, the bridge prints the startup command to +stderr and exits; start the server and reconnect. -This registration is private to you and applies to this project. Start Claude -Code in the same folder; use `/mcp` to check the connection. See the -[Claude Code MCP documentation](https://code.claude.com/docs/en/mcp). +### Claude Code and Codex CLI -### Codex Desktop - -From the lesson project folder: +To use the same manually started server: ```bash -softpractice mcp setup codex-desktop +claude mcp add --transport stdio --scope user softpractice -- softpractice mcp connect +# or, if Codex Desktop setup has not already configured it: +codex mcp add softpractice -- softpractice mcp connect ``` -You do not need to install Codex CLI. The command adds the `softpractice` -server to `~/.codex/config.toml` with absolute paths to the program and project -and a tool timeout of 180 seconds. If `CODEX_HOME` is set, it uses -`config.toml` in that folder. Other settings, servers, and their comments -are kept; the previous file is saved next to it as `config.toml.bak`. -Running setup again for the same project leaves the file unchanged. +If the client cannot find `softpractice`, use the executable's full path. +See the [Claude Code MCP documentation](https://code.claude.com/docs/en/mcp) +and [Codex MCP documentation](https://developers.openai.com/codex/mcp). + +`softpractice mcp` always starts the local server, including with redirected +stdin or in the background. Only server startup selects the project. Setup +connects the client to the single running server and does not accept `--project`. -Restart Codex Desktop, open the lesson project folder, and check the -`softpractice` server in the MCP settings. Ask the agent: “Use the -SoftPractice MCP server to show my lesson status and current task.” It -should call `status` and `task`. When switching to another lesson project, -run the command again in its folder or pass -`--project /absolute/path/to/your/lesson-project`. +### Checking the server -To configure the app manually, print the TOML entry: +From any folder, check which server is running: ```bash -softpractice mcp setup codex-desktop --print +softpractice mcp status +softpractice mcp status --json ``` -Add it to `config.toml`, keeping the servers already configured. If -`[mcp_servers.softpractice]` already exists, replace that table instead of -adding a duplicate. Codex Desktop, CLI, and the IDE extension share these -settings on the same host. Setup pins one project to the `softpractice` -server for all these clients. A trusted project's `.codex/config.toml` can -override user-level settings. See the -[official Codex MCP documentation](https://developers.openai.com/codex/mcp). +The command checks a live connection and shows the lesson folder, PID, +local address, and CLI version. A stopped server returns `not_running`; +a key file left after a crash is not treated as proof of a running server. +No lesson project or sign-in is needed. This reports the local process; +lesson information remains available through `softpractice status` and the +MCP `status` tool. -### Codex CLI +### Stopping the server and removing the connection -If you already configured Codex Desktop with the command above, no further -registration is needed. Start `codex` in the lesson folder; `/mcp` shows -active servers. +Press Ctrl+C in the server's terminal to stop the lesson server. The client +configuration remains available for the next startup. -To configure using Codex CLI, replace the project path: +To remove the client connection, run from any folder: ```bash -codex mcp add softpractice -- softpractice mcp --project /path/to/your/lesson-project -codex mcp list +softpractice mcp remove claude-desktop +# or +softpractice mcp remove codex-desktop ``` -For local checks and evaluation polling, add `tool_timeout_sec = 180` to -the generated `[mcp_servers.softpractice]` table in `~/.codex/config.toml`. -If the client cannot find `softpractice`, use the executable's full path. -You can also use `softpractice mcp setup codex-desktop`: it configures the -same file. +This removes only the SoftPractice entry, preserves other settings, and saves +the previous file as `.bak`. Repeating the command is safe: missing entries +or files are left untouched. Restart the client to apply the change. Codex +shares these settings between Desktop, CLI, and the IDE extension; project +settings may override them. + +Removing the connection does not stop the lesson server. To reconnect, +run the corresponding `softpractice mcp setup` command again. ### Using the server diff --git a/README_RU.md b/README_RU.md index f54e256..8ecec92 100644 --- a/README_RU.md +++ b/README_RU.md @@ -91,6 +91,8 @@ softpractice update | `softpractice open` | Открыть задание, материал или последний результат в браузере | | `softpractice mcp` | Запустить MCP-сервер для Claude Code, Codex, Claude Desktop и других агентов | | `softpractice mcp setup codex-desktop` | Подключить MCP к Codex Desktop без установки Codex CLI | +| `softpractice mcp status` | Проверить работающий сервер и его проект | +| `softpractice mcp remove codex-desktop` | Убрать подключение из настроек Codex | | `softpractice project restore` | Восстановить проект из последней подходящей ревизии | Подробную справку можно получить командой `softpractice help <команда>`. @@ -149,127 +151,123 @@ Codex, Claude Code и другие агенты сразу получают пр ## MCP-сервер `softpractice mcp` соединяет агента с уроком SoftPractice: агент может читать -задание и материал, запускать публичные проверки, отправлять commit и получать -результат. Это локальный MCP-сервер на **stdio**: MCP-клиент сам запускает -процесс и общается с ним. Адрес сервера и сетевой порт не нужны. +задание и теорию, запускать публичные проверки, отправлять commit и читать +результат. -### Перед подключением +### Настрой один раз из любой папки -Установи CLI (см. [Установка](#установка)), войди и открой существующий -Git-проект урока, скачанный через `softpractice starter` или восстановленный -через `softpractice project restore`: +Установи CLI (см. [Установка](#установка)) и настрой Desktop-клиент. +Проект урока на этом этапе не нужен: ```bash -softpractice login -cd /path/to/your/lesson-project -softpractice status +softpractice mcp setup claude-desktop +# или +softpractice mcp setup codex-desktop ``` -MCP-сервер использует сохранённый вход CLI. Сам он вход не запускает; -отдельная OAuth-авторизация MCP не нужна. Укажи в клиенте команду запуска -`softpractice mcp`. Клиент запустит её сам, держать сервер в отдельном -терминале не нужно. +Команда записывает абсолютный путь к CLI и аргументы `mcp connect`. +Остальные настройки сохраняются, прежний файл остаётся рядом как `.bak`. +Claude Desktop использует `claude_desktop_config.json`; на Windows команда +также находит конфигурацию версии из Microsoft Store. Codex использует +`~/.codex/config.toml` (или `$CODEX_HOME/config.toml`) и таймаут инструментов +180 секунд. Эти настройки общие для Codex Desktop, CLI и расширения IDE. +Для настройки Desktop-приложения устанавливать Codex CLI не нужно. -По умолчанию сервер работает с папкой, в которой его запустил клиент. -Флаг `--project /absolute/path/to/your/lesson-project` задаёт конкретный -проект; особенно полезно указать его для настольного клиента. При смене -проекта поменяй этот путь. Если клиент не находит `softpractice` в `PATH`, -укажи полный путь к исполняемому файлу в поле команды. Клиент должен работать -на компьютере, где установлены CLI и проект. +Для ручной настройки добавь `--print`: команда выведет JSON или TOML, +не меняя файл настроек. Если запись `softpractice` уже есть, замени её, +чтобы не создавать дубликат. -### Claude Desktop +### Запусти сервер урока -Из папки проекта урока: +Войди в аккаунт и запусти сервер в Git-проекте, скачанном через +`softpractice starter` или восстановленном через `softpractice project restore`: ```bash -softpractice mcp setup claude-desktop +softpractice login +cd /path/to/your/lesson-project +softpractice mcp ``` -Команда добавляет сервер `softpractice` в файл настроек Claude Desktop, -`claude_desktop_config.json`, с абсолютными путями к программе и к проекту: -Claude Desktop не видит `PATH` из профиля терминала и запускает сервер не в -папке проекта. Остальные серверы и настройки приложения в файле сохраняются, -прежний файл остаётся рядом как `claude_desktop_config.json.bak`. В Windows -команда сама находит файл, который на самом деле читает версия приложения из -Microsoft Store. - -Затем полностью закрой Claude Desktop (не только окно) и запусти снова. -Инструменты `softpractice` появятся в меню инструментов чата. При переходе к -другому проекту урока повтори команду в его папке. - -Чтобы настроить приложение вручную, выведи запись и добавь её в `mcpServers` -файла, который открывает **Settings → Developer → Edit Config**, сохранив уже -настроенные серверы: +Либо укажи проект при запуске из любой папки: ```bash -softpractice mcp setup claude-desktop --print +softpractice mcp --project /path/to/your/lesson-project ``` -### Claude Code +Оставь терминал открытым. Перед подключением клиентов сервер проверяет +Git-репозиторий и привязку проекта к SoftPractice. Он использует сохранённый +вход CLI и сам не запускает авторизацию. -Из папки проекта урока: - -```bash -claude mcp add --transport stdio --scope local softpractice -- softpractice mcp -claude mcp list -``` +Запусти сервер перед перезапуском Desktop-клиента. Клиент запускает мост +`softpractice mcp connect`, который передаёт MCP-сообщения работающему серверу +на `127.0.0.1:39473`. Подключение требует случайный ключ, создаваемый заново +при каждом запуске и сохранённый в пользовательской папке настроек CLI. +Оба процесса должны работать на одном компьютере под одним пользователем +и с одинаковым `SOFTPRACTICE_CONFIG_DIR`, если эта переменная задана. +Этот адрес не является HTTP URL. -Подключение будет личным и только для этого проекта. Запусти Claude Code -в той же папке; команда `/mcp` покажет подключение. Подробнее — в -[документации Claude Code по MCP](https://code.claude.com/docs/en/mcp). +Одновременно на компьютере работает один сервер урока. Для смены проекта +останови его через Ctrl+C, запусти в другой папке и переподключи MCP +в клиенте или перезапусти клиент. Повторять `setup` не нужно. +Если сервер остановлен, мост выводит команду запуска в stderr и завершается; +запусти сервер и переподключи клиент. -### Codex Desktop +### Claude Code и Codex CLI -Из папки проекта урока: +Для подключения к тому же серверу, который ты запускаешь вручную: ```bash -softpractice mcp setup codex-desktop +claude mcp add --transport stdio --scope user softpractice -- softpractice mcp connect +# или, если настройки ещё не записаны через setup codex-desktop: +codex mcp add softpractice -- softpractice mcp connect ``` -Устанавливать Codex CLI для этого не нужно. Команда добавляет сервер -`softpractice` в `~/.codex/config.toml` с абсолютными путями к программе и -проекту, а также временем ожидания инструмента 180 секунд. Если задан -`CODEX_HOME`, используется `config.toml` в этой папке. Остальные настройки, -серверы и их комментарии сохраняются; прежний файл остаётся рядом как -`config.toml.bak`. Повторная настройка того же проекта не меняет файл. +Если клиент не находит `softpractice`, укажи полный путь к программе. +Подробнее — в [документации Claude Code](https://code.claude.com/docs/en/mcp) +и [документации Codex](https://developers.openai.com/codex/mcp). -Перезапусти Codex Desktop, открой папку проекта урока и проверь сервер -`softpractice` в настройках MCP. Попроси агента: «Через MCP SoftPractice -покажи статус моего урока и текущее задание». Он должен вызвать `status` -и `task`. При переходе к другому проекту урока повтори команду в его папке -или укажи `--project /absolute/path/to/your/lesson-project`. +`softpractice mcp` всегда запускает локальный сервер, в том числе при +перенаправленном stdin и фоновом запуске. Проект выбирается только при запуске +сервера. `setup` настраивает подключение к единственному работающему серверу +и не принимает `--project`. -Чтобы настроить приложение вручную, выведи TOML-запись: +### Проверка сервера + +Из любой папки проверь, какой сервер сейчас работает: ```bash -softpractice mcp setup codex-desktop --print +softpractice mcp status +softpractice mcp status --json ``` -Добавь её в `config.toml`, сохранив уже настроенные серверы. Если таблица -`[mcp_servers.softpractice]` уже есть, замени её вместо добавления второй. -Настройки общие для Codex Desktop, CLI и расширения IDE на одном компьютере. -Настройка закрепляет один проект за сервером `softpractice` во всех этих -клиентах. Проектная `.codex/config.toml` в доверенном проекте может -переопределить пользовательские настройки. Подробнее — в -[официальной документации Codex по MCP](https://developers.openai.com/codex/mcp). +Команда проверяет соединение с сервером и показывает папку урока, PID, +локальный адрес и версию CLI. Остановленный сервер возвращает состояние +`not_running`; оставшийся после сбоя файл ключа не считается признаком работы. +Для этой проверки не нужны проект урока и вход в аккаунт. Это статус +локального процесса; задание и результаты урока по-прежнему доступны через +`softpractice status` и инструмент MCP `status`. -### Codex CLI +### Остановка и удаление подключения -Если уже настроил Codex Desktop командой выше, повторная регистрация -не нужна. Запусти `codex` в папке проекта урока; `/mcp` покажет активные серверы. +Чтобы остановить сервер урока, нажми Ctrl+C в терминале, где он запущен. +Настройка клиента сохраняется для следующего запуска. -Для настройки через Codex CLI подставь путь к проекту: +Чтобы убрать подключение из настроек клиента, выполни из любой папки: ```bash -codex mcp add softpractice -- softpractice mcp --project /path/to/your/lesson-project -codex mcp list +softpractice mcp remove claude-desktop +# или +softpractice mcp remove codex-desktop ``` -Для локальных проверок и ожидания результата добавь -`tool_timeout_sec = 180` в созданную таблицу `[mcp_servers.softpractice]` -в `~/.codex/config.toml`. Если клиент не находит `softpractice`, укажи полный -путь к исполняемому файлу. Можно также использовать -`softpractice mcp setup codex-desktop`: она настраивает тот же файл. +Команда удаляет только запись SoftPractice, сохраняет остальные настройки +и создаёт резервную копию `.bak`. Повторный запуск безопасен: если записи +или файла уже нет, ничего не меняется. Перезапусти клиент, чтобы применить +изменение. У Codex это общие настройки Desktop, CLI и расширения IDE; +настройки отдельных проектов могут переопределять их. + +Удаление подключения не останавливает сервер урока. Чтобы вернуть подключение, +снова выполни соответствующую команду `softpractice mcp setup`. ### Использование сервера diff --git a/cmd/softpractice/main.go b/cmd/softpractice/main.go index 96d4ae5..99f194f 100644 --- a/cmd/softpractice/main.go +++ b/cmd/softpractice/main.go @@ -243,6 +243,12 @@ func run( case "open": return openCurrent(ctx, client, "", remaining[1:], output, errorOutput) case "mcp": + if len(remaining) > 1 && remaining[1] == "status" { + return mcpStatusCommand(ctx, remaining[2:], output, errorOutput) + } + if len(remaining) > 1 && remaining[1] == "remove" { + return mcpRemoveCommand(ctx, remaining[2:], output) + } if len(remaining) > 1 && remaining[1] == "setup" { return mcpSetupCommand(ctx, remaining[2:], output, errorOutput) } diff --git a/cmd/softpractice/mcp.go b/cmd/softpractice/mcp.go index 3030533..7c8be00 100644 --- a/cmd/softpractice/mcp.go +++ b/cmd/softpractice/mcp.go @@ -20,7 +20,6 @@ import ( "time" "github.com/modelcontextprotocol/go-sdk/mcp" - "golang.org/x/term" "github.com/arthur-s/softpractice-cli/internal/learnercli" ) @@ -70,6 +69,12 @@ func mcpCommand( input io.Reader, output, errorOutput io.Writer, ) error { + if len(args) > 0 && args[0] == "connect" { + if len(args) != 1 { + return usage(ctx, "использование: softpractice mcp connect", "usage: softpractice mcp connect") + } + return connectLocalMCP(ctx, input, output) + } flags := flag.NewFlagSet("mcp", flag.ContinueOnError) flags.SetOutput(errorOutput) project := flags.String("project", "", "project folder; defaults to the current directory") @@ -90,80 +95,38 @@ func mcpCommand( } directory = absolute } - server, err := newMCPServer(ctx, newLearnerUseCases(client, directory), errorOutput) + root, err := mcpProjectRoot(ctx, directory) if err != nil { return err } - interactive := isTerminal(input) - if interactive { - printMCPBanner(ctx, client, directory, errorOutput) - } - // Standard output belongs to JSON-RPC: nothing else may write to it. - transport := &mcp.IOTransport{Reader: io.NopCloser(input), Writer: nopWriteCloser{output}} - err = server.Run(ctx, transport) - if interactive && (err == nil || errors.Is(err, context.Canceled)) { - fmt.Fprintln(errorOutput, text(ctx, "MCP-сервер остановлен.", "MCP server stopped.")) - return nil - } - return err -} - -// isTerminal reports whether a person typed the command rather than an MCP -// client starting it with a pipe on standard input. -func isTerminal(input io.Reader) bool { - file, ok := input.(*os.File) - if !ok { - return false - } - return term.IsTerminal(int(file.Fd())) -} - -// printMCPBanner tells a person who started the server by hand that it is -// meant for an agent, how to register it, and whether the agent's calls would -// work from here. It runs only local checks and writes to standard error. -func printMCPBanner(ctx context.Context, client *learnercli.Client, directory string, output io.Writer) { - fmt.Fprintf(output, text(ctx, - "SoftPractice MCP-сервер %s запущен и ждёт MCP-клиента на stdin.\n"+ - "Эту команду запускает агент, а не человек. Подключи сервер к агенту из папки проекта урока:\n\n", - "SoftPractice MCP server %s is running and waiting for an MCP client on stdin.\n"+ - "An agent starts this command, not a person. Register the server from the lesson project folder:\n\n", - ), cliVersion) - repository, projectErr := inspectGitRepository(ctx, directory, false) - if projectErr == nil { - _, projectErr = learnercli.LoadProjectLink(repository.Root) - } - projectPath := "/path/to/your/lesson-project" - if projectErr == nil { - projectPath = repository.Root - } - fmt.Fprintln(output, " Claude Desktop: softpractice mcp setup claude-desktop") - fmt.Fprintln(output, " Codex Desktop: softpractice mcp setup codex-desktop") - fmt.Fprintln(output, " Claude Code: claude mcp add --transport stdio --scope local softpractice -- softpractice mcp") - fmt.Fprintf(output, " Codex CLI: codex mcp add softpractice -- softpractice mcp --project %s\n\n", projectPath) - if projectErr == nil { - fmt.Fprintf(output, text(ctx, "Проект: %s\n", "Project: %s\n"), repository.Root) - } else { - location := directory - if location == "" { - location, _ = os.Getwd() + + server, err := newMCPServer(ctx, newLearnerUseCases(client, root), errorOutput) + if err != nil { + return err + } + return serveLocalMCP(ctx, server, root, errorOutput) +} + +func mcpProjectRoot(ctx context.Context, directory string) (string, error) { + if directory != "" { + absolute, err := filepath.Abs(directory) + if err != nil { + return "", err } - fmt.Fprintf(output, text(ctx, - "Внимание: в %s нет проекта SoftPractice (%v). Запусти сервер в папке урока или укажи --project.\n", - "Warning: %s is not a SoftPractice project (%v). Start the server in the lesson folder or pass --project.\n", - ), location, projectErr) + directory = absolute } - if _, err := client.Store.Load(); errors.Is(err, learnercli.ErrLoginRequired) { - fmt.Fprintln(output, text(ctx, "Внимание: вход не выполнен. Сначала выполни softpractice login.", "Warning: not signed in. Run softpractice login first.")) - } else if err != nil { - fmt.Fprintf(output, text(ctx, "Внимание: не удалось прочитать данные входа: %v\n", "Warning: could not read sign-in credentials: %v\n"), err) + repository, err := inspectGitRepository(ctx, directory, false) + if err == nil { + _, err = learnercli.LoadProjectLink(repository.Root) } - fmt.Fprintln(output, text(ctx, "Подробнее: softpractice help mcp. Остановить: Ctrl+C.", "More: softpractice help mcp. Stop: Ctrl+C.")) + if err != nil { + return "", fmt.Errorf(text(ctx, + "запусти сервер в папке проекта урока или укажи --project: %w", + "start the server in the lesson project folder or pass --project: %w"), err) + } + return repository.Root, nil } -type nopWriteCloser struct{ io.Writer } - -func (nopWriteCloser) Close() error { return nil } - func newMCPServer(ctx context.Context, useCases learnerUseCases, logOutput io.Writer) (*mcp.Server, error) { key := make([]byte, 32) if _, err := rand.Read(key); err != nil { diff --git a/cmd/softpractice/mcp_local.go b/cmd/softpractice/mcp_local.go new file mode 100644 index 0000000..973e806 --- /dev/null +++ b/cmd/softpractice/mcp_local.go @@ -0,0 +1,187 @@ +package main + +import ( + "bufio" + "context" + "crypto/hmac" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "sync" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// A fixed loopback port permits only one active lesson server at a time. The +// bridge authenticates with a per-run secret before forwarding MCP messages. +const localMCPAddress = "127.0.0.1:39473" + +func localMCPTokenPath() (string, error) { + store, err := defaultConfigStore() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(store.Path), "mcp-local.token"), nil +} + +func serveLocalMCP(ctx context.Context, server *mcp.Server, project string, output io.Writer) error { + listener, err := net.Listen("tcp4", localMCPAddress) + if err != nil { + return fmt.Errorf(text(ctx, "не удалось запустить MCP-сервер: %w. Возможно, уже запущен сервер другого урока; останови его перед переключением проекта", "could not start the MCP server: %w. Another lesson server may be running; stop it before switching projects"), err) + } + defer listener.Close() + path, err := localMCPTokenPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return err + } + token := hex.EncodeToString(secret) + // Recreate the file to avoid retaining loose permissions or following a + // symlink left at this path. The listener prevents competing server writes. + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + defer os.Remove(path) + _, writeErr := io.WriteString(file, token) + closeErr := file.Close() + if err := errors.Join(writeErr, closeErr); err != nil { + return err + } + fmt.Fprintf(output, text(ctx, + "SoftPractice MCP-сервер запущен.\nПроект: %s\nПодключение: %s (только этот компьютер).\nНастрой клиент из любой папки: softpractice mcp setup claude-desktop или softpractice mcp setup codex-desktop.\nОставь терминал открытым. Остановить: Ctrl+C.\n", + "SoftPractice MCP server is running.\nProject: %s\nConnection: %s (this computer only).\nConfigure the client from any folder: softpractice mcp setup claude-desktop or softpractice mcp setup codex-desktop.\nKeep this terminal open. Stop: Ctrl+C.\n"), project, localMCPAddress) + err = acceptLocalMCP(ctx, listener, server, token, localMCPStatus{Kind: "softpractice.mcp.status", State: "running", Project: project, PID: os.Getpid(), Address: localMCPAddress, Version: cliVersion}) + if ctx.Err() != nil { + return nil + } + return err +} + +func acceptLocalMCP(ctx context.Context, listener net.Listener, server *mcp.Server, token string, status localMCPStatus) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + stop := context.AfterFunc(ctx, func() { _ = listener.Close() }) + defer stop() + var sessions sync.WaitGroup + defer sessions.Wait() + for { + connection, err := listener.Accept() + if err != nil { + cancel() + return err + } + sessions.Add(1) + go func() { + defer sessions.Done() + defer connection.Close() + stop := context.AfterFunc(ctx, func() { _ = connection.Close() }) + defer stop() + _ = connection.SetDeadline(time.Now().Add(5 * time.Second)) + reader := bufio.NewReaderSize(connection, 4096) + line, err := reader.ReadSlice('\n') + + if err != nil { + return + } + if hmac.Equal(line, []byte(token+" status\n")) { + _ = json.NewEncoder(connection).Encode(status) + return + } + if !hmac.Equal(line, []byte(token+"\n")) { + return + } + + if _, err := io.WriteString(connection, "OK\n"); err != nil { + return + } + _ = connection.SetDeadline(time.Time{}) + _ = server.Run(ctx, &mcp.IOTransport{Reader: io.NopCloser(reader), Writer: connection}) + }() + } +} + +func connectLocalMCP(ctx context.Context, input io.Reader, output io.Writer) error { + unavailable := func(err error) error { + return fmt.Errorf(text(ctx, + "MCP-сервер недоступен: %w. Запусти `softpractice mcp` в папке урока или `softpractice mcp --project DIR`, затем переподключи MCP-клиент", + "MCP server is unavailable: %w. Run `softpractice mcp` in the lesson folder or `softpractice mcp --project DIR`, then reconnect the MCP client"), err) + } + connection, err := dialLocalMCP(ctx, "") + if err != nil { + return unavailable(err) + } + defer connection.Close() + stop := context.AfterFunc(ctx, func() { _ = connection.Close() }) + defer stop() + reader := bufio.NewReader(connection) + if ack, err := reader.ReadString('\n'); err != nil || ack != "OK\n" { + if err == nil { + err = errors.New("local MCP authentication failed") + } + return unavailable(err) + } + _ = connection.SetDeadline(time.Time{}) + // stdin may be an uninterruptible pipe: do not wait for its goroutine when + // the server disconnects. Closing the TCP connection ends its next write. + copied := make(chan error, 1) + go func() { + _, err := io.Copy(connection, input) + _ = connection.(*net.TCPConn).CloseWrite() + copied <- err + }() + _, err = io.Copy(output, reader) + if ctx.Err() != nil { + return nil + } + select { + case copyErr := <-copied: + return errors.Join(err, copyErr) + default: + return unavailable(errors.Join(err, io.EOF)) + } +} + +// The suffix selects an internal management request after authentication. +// It is never forwarded to the MCP client or exposed as a learner tool. +func dialLocalMCP(ctx context.Context, suffix string) (net.Conn, error) { + path, err := localMCPTokenPath() + if err != nil { + return nil, err + } + token, err := os.ReadFile(path) + if err != nil { + return nil, err + } + if len(token) != 64 { + return nil, errors.New("invalid local MCP token") + } + connection, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, "tcp4", localMCPAddress) + if err != nil { + return nil, err + } + stop := context.AfterFunc(ctx, func() { _ = connection.Close() }) + defer stop() + _ = connection.SetDeadline(time.Now().Add(5 * time.Second)) + if _, err := fmt.Fprintf(connection, "%s%s\n", token, suffix); err != nil { + _ = connection.Close() + return nil, err + } + return connection, nil +} diff --git a/cmd/softpractice/mcp_local_test.go b/cmd/softpractice/mcp_local_test.go new file mode 100644 index 0000000..5002fdf --- /dev/null +++ b/cmd/softpractice/mcp_local_test.go @@ -0,0 +1,209 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io" + "net" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestLocalMCPBridgeAndProjectSwitch(t *testing.T) { + t.Setenv(configDirectoryEnv, t.TempDir()) + path, err := localMCPTokenPath() + if err != nil { + t.Fatal(err) + } + var previousToken string + for attempt := range 2 { + root := createLinkedGitRepository(t, uuid.NewString()) + ctx, cancel := context.WithCancel(context.Background()) + var banner lockedBuffer + done := make(chan error, 1) + args := []string{"--project", root} + if attempt == 0 { + t.Chdir(root) + args = nil + } else { + t.Chdir(t.TempDir()) + } + go func() { + done <- mcpCommand(ctx, nil, args, strings.NewReader(""), io.Discard, &banner) + }() + deadline := time.Now().Add(5 * time.Second) + for !strings.Contains(banner.String(), "39473") { + select { + case err := <-done: + cancel() + t.Fatalf("server startup: %v", err) + default: + } + if time.Now().After(deadline) { + cancel() + t.Fatal("server startup timed out") + } + time.Sleep(10 * time.Millisecond) + } + token, err := os.ReadFile(path) + if err != nil || len(token) != 64 || string(token) == previousToken { + cancel() + t.Fatalf("token: %q, %v", token, err) + } + previousToken = string(token) + if info, err := os.Stat(path); err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0o600) { + cancel() + t.Fatalf("secret permissions: %v, %v", info, err) + } + resolved, _ := filepath.EvalSymlinks(root) + if !strings.Contains(banner.String(), resolved) { + cancel() + t.Fatalf("project missing: %s", banner.String()) + } + var statusOutput bytes.Buffer + if err := mcpStatusCommand(ctx, []string{"--json"}, &statusOutput, io.Discard); err != nil { + cancel() + t.Fatal(err) + } + var status localMCPStatus + if err := json.Unmarshal(statusOutput.Bytes(), &status); err != nil || status.State != "running" || status.Project != resolved || status.PID != os.Getpid() || status.Version != cliVersion { + cancel() + t.Fatalf("running status: %+v, %v", status, err) + } + + // Starting another project cannot replace the active server's secret. + if err := mcpCommand(ctx, nil, []string{"--project", createLinkedGitRepository(t, uuid.NewString())}, strings.NewReader(""), io.Discard, io.Discard); err == nil { + cancel() + t.Fatal("second server started") + } + sameToken, _ := os.ReadFile(path) + if !bytes.Equal(token, sameToken) { + cancel() + t.Fatal("second server changed the secret") + } + // An unauthenticated process never gets an MCP session. + connection, err := net.DialTimeout("tcp4", localMCPAddress, time.Second) + if err != nil { + cancel() + t.Fatal(err) + } + _ = connection.SetDeadline(time.Now().Add(time.Second)) + _, _ = io.WriteString(connection, "wrong-secret status\n") + response, _ := io.ReadAll(connection) + _ = connection.Close() + if len(response) != 0 { + cancel() + t.Fatalf("unauthorized response: %q", response) + } + + input, writer := io.Pipe() + output, outputWriter := io.Pipe() + bridgeDone := make(chan error, 1) + go func() { bridgeDone <- connectLocalMCP(ctx, input, outputWriter); _ = outputWriter.Close() }() + reader := bufio.NewReader(output) + send := func(message string) { + t.Helper() + if _, err := io.WriteString(writer, message+"\n"); err != nil { + cancel() + t.Fatal(err) + } + } + read := func() map[string]any { + t.Helper() + line, err := reader.ReadBytes('\n') + if err != nil { + cancel() + t.Fatal(err) + } + var message map[string]any + if err := json.Unmarshal(line, &message); err != nil || message["jsonrpc"] != "2.0" { + cancel() + t.Fatalf("MCP response: %q, %v", line, err) + } + return message + } + send(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"bridge-test","version":"1"}}}`) + read() + send(`{"jsonrpc":"2.0","method":"notifications/initialized"}`) + send(`{"jsonrpc":"2.0","id":2,"method":"tools/list"}`) + if tools := read()["result"].(map[string]any)["tools"].([]any); len(tools) != 9 { + cancel() + t.Fatalf("tools: %v", tools) + } + cancel() + _ = writer.Close() + _ = output.Close() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("server did not stop") + } + select { + case <-bridgeDone: + case <-time.After(5 * time.Second): + t.Fatal("bridge did not stop") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("token was not removed: %v", err) + } + stopped, err := readLocalMCPStatus(context.Background()) + if err != nil || stopped.State != "not_running" { + t.Fatalf("stopped status: %+v, %v", stopped, err) + } + // A key left by an abnormal exit does not imply a running server. + if err := os.WriteFile(path, token, 0o600); err != nil { + t.Fatal(err) + } + stopped, err = readLocalMCPStatus(context.Background()) + if err != nil || stopped.State != "not_running" { + t.Fatalf("stale status: %+v, %v", stopped, err) + } + _ = os.Remove(path) + + } +} + +func TestLocalMCPRejectsMissingProjectAndExplainsOfflineServer(t *testing.T) { + t.Setenv(configDirectoryEnv, t.TempDir()) + var output bytes.Buffer + err := mcpCommand(context.Background(), nil, []string{"--project", t.TempDir()}, strings.NewReader(""), &output, io.Discard) + if err == nil || !strings.Contains(err.Error(), "--project") || output.Len() != 0 { + t.Fatalf("missing project: %v, stdout=%s", err, output.String()) + } + err = connectLocalMCP(context.Background(), strings.NewReader(""), &output) + if err == nil || !strings.Contains(err.Error(), "softpractice mcp --project DIR") || output.Len() != 0 { + t.Fatalf("offline bridge: %v, stdout=%s", err, output.String()) + } +} + +func TestMCPRejectsRemovedTransportOptions(t *testing.T) { + for _, option := range []string{"--stdio", "--listen"} { + t.Run(option, func(t *testing.T) { + var output bytes.Buffer + err := mcpCommand(context.Background(), nil, []string{option}, strings.NewReader(""), &output, io.Discard) + if err == nil || output.Len() != 0 { + t.Fatalf("removed option: err=%v, stdout=%s", err, output.String()) + } + }) + } + for _, client := range []string{"claude-desktop", "codex-desktop"} { + t.Run(client, func(t *testing.T) { + var output bytes.Buffer + err := mcpSetupCommand(context.Background(), []string{client, "--project", t.TempDir(), "--print"}, &output, io.Discard) + if err == nil || output.Len() != 0 { + t.Fatalf("project-bound setup: err=%v, stdout=%s", err, output.String()) + } + }) + } +} diff --git a/cmd/softpractice/mcp_remove.go b/cmd/softpractice/mcp_remove.go new file mode 100644 index 0000000..d774859 --- /dev/null +++ b/cmd/softpractice/mcp_remove.go @@ -0,0 +1,47 @@ +package main + +import ( + "context" + "fmt" + "io" +) + +// Removing a client connection does not stop the lesson server or change it. +func mcpRemoveCommand(ctx context.Context, args []string, output io.Writer) error { + if len(args) != 1 || (args[0] != "claude-desktop" && args[0] != "codex-desktop") { + return usage(ctx, "использование: softpractice mcp remove ", "usage: softpractice mcp remove ") + } + var path, backup string + var changed bool + var err error + var client string + if args[0] == "codex-desktop" { + client = "Codex" + path, err = codexConfigPath() + if err == nil { + backup, changed, err = updateCodexMCPServerConfig(ctx, path, nil) + } + } else { + client = "Claude Desktop" + path, err = claudeDesktopConfigPath(ctx, false) + if err == nil { + backup, changed, err = updateMCPServerConfig(ctx, path, nil) + } + } + if err != nil { + return err + } + if !changed { + fmt.Fprintf(output, text(ctx, "Подключение softpractice уже отсутствует в настройках %s: %s\n", "The softpractice connection is already absent from the %s settings: %s\n"), client, path) + return nil + } + fmt.Fprintf(output, text(ctx, "Подключение softpractice удалено из настроек %s: %s\n", "Removed the softpractice connection from the %s settings: %s\n"), client, path) + fmt.Fprintf(output, text(ctx, "Прежний файл сохранён: %s\n", "The previous file is saved as %s\n"), backup) + fmt.Fprintf(output, text(ctx, + "Перезапусти клиент, чтобы применить изменение. Работающий сервер урока можно остановить через Ctrl+C в его терминале. Чтобы подключить MCP снова: softpractice mcp setup %s.\n", + "Restart the client to apply the change. Stop the running lesson server with Ctrl+C in its terminal. To reconnect MCP: softpractice mcp setup %s.\n"), args[0]) + if args[0] == "codex-desktop" { + fmt.Fprintln(output, text(ctx, "Изменение действует на общие настройки Codex Desktop, CLI и расширения IDE. Настройки отдельных проектов могут переопределять их.", "This changes the shared settings of Codex Desktop, CLI, and the IDE extension. Project settings may override them.")) + } + return nil +} diff --git a/cmd/softpractice/mcp_remove_test.go b/cmd/softpractice/mcp_remove_test.go new file mode 100644 index 0000000..a6138af --- /dev/null +++ b/cmd/softpractice/mcp_remove_test.go @@ -0,0 +1,184 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pelletier/go-toml/v2" +) + +func TestMCPRemovePreservesOtherSettingsAndBackup(t *testing.T) { + tests := []struct { + name, original string + remove func(context.Context, string) (string, bool, error) + verify func(*testing.T, []byte) + }{ + {"Claude", `{"preferences":{"theme":"dark"},"mcpServers":{"other":{"command":"other"},"softpractice":{"command":"sp"}},"zeta":1}`, + func(ctx context.Context, path string) (string, bool, error) { + return updateMCPServerConfig(ctx, path, nil) + }, + func(t *testing.T, data []byte) { + var config map[string]any + if err := json.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + servers := config["mcpServers"].(map[string]any) + if len(servers) != 1 || servers["other"].(map[string]any)["command"] != "other" || config["preferences"].(map[string]any)["theme"] != "dark" || config["zeta"] != float64(1) { + t.Fatalf("settings: %s", data) + } + }}, + {"Codex", "# preferences\nmodel = 'example'\n\n[mcp_servers.other]\ncommand = 'other' # keep\n\n[mcp_servers.softpractice]\ncommand = 'sp'\n\n[mcp_servers.softpractice.env]\nLANG = 'en'\n\n# trusted project\n[projects.'/lesson']\ntrust_level = 'trusted'\n", + func(ctx context.Context, path string) (string, bool, error) { + return updateCodexMCPServerConfig(ctx, path, nil) + }, + func(t *testing.T, data []byte) { + var config map[string]any + if err := toml.Unmarshal(data, &config); err != nil { + t.Fatal(err) + } + servers := config["mcp_servers"].(map[string]any) + if len(servers) != 1 || servers["other"].(map[string]any)["command"] != "other" || config["model"] != "example" || !bytes.Contains(data, []byte("command = 'other' # keep")) || !bytes.Contains(data, []byte("# trusted project\n[projects.'/lesson']")) { + t.Fatalf("settings: %s", data) + } + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config") + if err := os.WriteFile(path, []byte(test.original), 0o600); err != nil { + t.Fatal(err) + } + backup, changed, err := test.remove(context.Background(), path) + if err != nil || !changed || backup != path+".bak" { + t.Fatalf("remove: %q %v %v", backup, changed, err) + } + saved, _ := os.ReadFile(backup) + if string(saved) != test.original { + t.Fatalf("backup: %s", saved) + } + updated, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + test.verify(t, updated) + backup, changed, err = test.remove(context.Background(), path) + if err != nil || changed || backup != "" { + t.Fatalf("repeat: %q %v %v", backup, changed, err) + } + repeated, _ := os.ReadFile(path) + if !bytes.Equal(updated, repeated) { + t.Fatal("repeat changed settings") + } + saved, _ = os.ReadFile(path + ".bak") + if string(saved) != test.original { + t.Fatal("repeat replaced backup") + } + }) + } +} + +func TestMCPRemoveNoConnectionDoesNotCreateOrRewriteFiles(t *testing.T) { + for _, format := range []string{"json", "toml"} { + t.Run(format, func(t *testing.T) { + remove := func(path string) (string, bool, error) { + if format == "json" { + return updateMCPServerConfig(context.Background(), path, nil) + } + return updateCodexMCPServerConfig(context.Background(), path, nil) + } + path := filepath.Join(t.TempDir(), "missing", "config") + if backup, changed, err := remove(path); err != nil || changed || backup != "" { + t.Fatalf("missing: %q %v %v", backup, changed, err) + } + if _, err := os.Stat(filepath.Dir(path)); !os.IsNotExist(err) { + t.Fatalf("created directory: %v", err) + } + path = filepath.Join(t.TempDir(), "config") + original := "{\"mcpServers\":{\"other\":{\"command\":\"other\"}}}" + if format == "toml" { + original = "[mcp_servers.other]\ncommand = 'other'\n" + } + if err := os.WriteFile(path, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + if backup, changed, err := remove(path); err != nil || changed || backup != "" { + t.Fatalf("absent: %q %v %v", backup, changed, err) + } + data, _ := os.ReadFile(path) + if string(data) != original { + t.Fatal("changed unrelated settings") + } + }) + } +} + +func TestCodexRemoveOnlyServerAndRejectsUnsafeLayouts(t *testing.T) { + for _, original := range []string{"[mcp_servers.softpractice]\ncommand = 'sp'\n", "[mcp_servers]\n[mcp_servers.softpractice]\ncommand = 'sp'\n"} { + path := filepath.Join(t.TempDir(), "config.toml") + _ = os.WriteFile(path, []byte(original), 0o600) + if _, changed, err := updateCodexMCPServerConfig(context.Background(), path, nil); err != nil || !changed { + t.Fatalf("only server: %v %v", changed, err) + } + data, _ := os.ReadFile(path) + if bytes.Contains(data, []byte("softpractice")) { + t.Fatalf("server remains: %s", data) + } + } + for _, original := range []string{"[broken", "mcp_servers = 42", "mcp_servers.softpractice = {command = 'sp'}", "[mcp_servers]\nsoftpractice = 42"} { + path := filepath.Join(t.TempDir(), "config.toml") + _ = os.WriteFile(path, []byte(original), 0o600) + if _, changed, err := updateCodexMCPServerConfig(context.Background(), path, nil); err == nil || changed { + t.Fatalf("unsafe layout: %v %v", changed, err) + } + data, _ := os.ReadFile(path) + if string(data) != original { + t.Fatal("changed unsupported settings") + } + } +} + +func TestMCPRemoveCommandOutsideProject(t *testing.T) { + t.Setenv(configDirectoryEnv, t.TempDir()) + t.Setenv("SOFTPRACTICE_CREDENTIALS_DIR", t.TempDir()) + t.Setenv("CODEX_HOME", t.TempDir()) + t.Chdir(t.TempDir()) + path, err := codexConfigPath() + if err != nil { + t.Fatal(err) + } + if _, _, err := addCodexMCPServerToConfig(context.Background(), path, codexServerEntry("/opt/sp")); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + if err := run(context.Background(), []string{"mcp", "remove", "codex-desktop"}, strings.NewReader(""), &output, &output); err != nil || !strings.Contains(output.String(), "Codex") { + t.Fatalf("command: %v, %s", err, output.String()) + } + data, _ := os.ReadFile(path) + if bytes.Contains(data, []byte("softpractice")) { + t.Fatalf("connection remains: %s", data) + } +} + +func TestClaudeRemoveRejectsInvalidConfig(t *testing.T) { + for _, original := range []string{`[]`, `{"mcpServers": []}`, `{"mcpServers":`, `{} {}`} { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + if _, changed, err := updateMCPServerConfig(context.Background(), path, nil); err == nil || changed { + t.Fatalf("invalid config: %v %v", changed, err) + } + saved, _ := os.ReadFile(path) + if string(saved) != original { + t.Fatal("modified invalid config") + } + if _, err := os.Stat(path + ".bak"); !os.IsNotExist(err) { + t.Fatal("created backup on failure") + } + } +} diff --git a/cmd/softpractice/mcp_setup.go b/cmd/softpractice/mcp_setup.go index 9bd3843..0b0aab9 100644 --- a/cmd/softpractice/mcp_setup.go +++ b/cmd/softpractice/mcp_setup.go @@ -13,8 +13,6 @@ import ( "path/filepath" "runtime" "strings" - - "github.com/arthur-s/softpractice-cli/internal/learnercli" ) const mcpServerName = "softpractice" @@ -23,15 +21,14 @@ const mcpServerName = "softpractice" func mcpSetupCommand(ctx context.Context, args []string, output, errorOutput io.Writer) error { usageText := func() error { return usage(ctx, - "использование: softpractice mcp setup [--project DIR] [--print]", - "usage: softpractice mcp setup [--project DIR] [--print]") + "использование: softpractice mcp setup [--print]", + "usage: softpractice mcp setup [--print]") } if len(args) == 0 || (args[0] != "claude-desktop" && args[0] != "codex-desktop") { return usageText() } flags := flag.NewFlagSet("mcp setup "+args[0], flag.ContinueOnError) flags.SetOutput(errorOutput) - project := flags.String("project", "", "lesson project folder; defaults to the current directory") printOnly := flags.Bool("print", false, "print the configuration entry instead of writing it") if err := parseFlags(flags, args[1:]); err != nil { return err @@ -39,21 +36,17 @@ func mcpSetupCommand(ctx context.Context, args []string, output, errorOutput io. if flags.NArg() != 0 { return usageText() } - root, err := mcpSetupProjectRoot(ctx, strings.TrimSpace(*project)) - if err != nil { - return err - } executable, err := mcpSetupExecutable(ctx) if err != nil { return err } if args[0] == "codex-desktop" { - return setupCodexDesktop(ctx, executable, root, *printOnly, output) + return setupCodexDesktop(ctx, executable, *printOnly, output) } entry, err := json.Marshal(struct { Command string `json:"command"` Args []string `json:"args"` - }{executable, []string{"mcp", "--project", root}}) + }{executable, []string{"mcp", "connect"}}) if err != nil { return err } @@ -65,7 +58,7 @@ func mcpSetupCommand(ctx context.Context, args []string, output, errorOutput io. _, err = output.Write(document) return err } - path, err := claudeDesktopConfigPath(ctx) + path, err := claudeDesktopConfigPath(ctx, true) if err != nil { return err } @@ -78,7 +71,7 @@ func mcpSetupCommand(ctx context.Context, args []string, output, errorOutput io. } else { fmt.Fprintf(output, text(ctx, "Claude Desktop уже настроен: %s\n", "Claude Desktop is already configured: %s\n"), path) } - fmt.Fprintf(output, text(ctx, " Программа: %s\n Проект: %s\n", " Program: %s\n Project: %s\n"), executable, root) + printMCPSetupTarget(ctx, executable, output) if backup != "" { fmt.Fprintf(output, text(ctx, "Прежний файл сохранён: %s\n", "The previous file is saved as %s\n"), backup) } @@ -87,32 +80,10 @@ func mcpSetupCommand(ctx context.Context, args []string, output, errorOutput io. "\nПолностью закрой Claude Desktop (не только окно) и запусти снова: инструменты softpractice появятся в меню инструментов чата.", "\nQuit Claude Desktop completely, not just its window, and start it again: the softpractice tools appear in the chat's tools menu.")) } - fmt.Fprintln(output, text(ctx, - "Для другого проекта урока повтори команду в его папке: Claude Desktop работает с одним проектом.", - "For another lesson project, run the command again in its folder: Claude Desktop works with one project.")) + printMCPSetupNextStep(ctx, output) return nil } -func mcpSetupProjectRoot(ctx context.Context, directory string) (string, error) { - if directory != "" { - absolute, err := filepath.Abs(directory) - if err != nil { - return "", err - } - directory = absolute - } - repository, err := inspectGitRepository(ctx, directory, false) - if err == nil { - _, err = learnercli.LoadProjectLink(repository.Root) - } - if err != nil { - return "", fmt.Errorf(text(ctx, - "запусти команду в папке проекта урока или укажи --project: %w", - "run the command in the lesson project folder or pass --project: %w"), err) - } - return repository.Root, nil -} - // mcpSetupExecutable is the native program, not the npm wrapper that started // it: a desktop client may find neither the wrapper nor Node.js on its PATH. func mcpSetupExecutable(ctx context.Context) (string, error) { @@ -134,7 +105,7 @@ func mcpSetupExecutable(ctx context.Context) (string, error) { // claudeDesktopConfigPath is the file Claude Desktop reads. Its Microsoft // Store (MSIX) build reads a virtualized copy of %APPDATA% once it has // written one, while its Edit Config button still opens the real file. -func claudeDesktopConfigPath(ctx context.Context) (string, error) { +func claudeDesktopConfigPath(ctx context.Context, requireApp bool) (string, error) { const name = "claude_desktop_config.json" if runtime.GOOS == "windows" { if local := os.Getenv("LOCALAPPDATA"); local != "" { @@ -151,7 +122,7 @@ func claudeDesktopConfigPath(ctx context.Context) (string, error) { return "", err } directory := filepath.Join(base, "Claude") - if info, err := os.Stat(directory); err != nil || !info.IsDir() { + if info, err := os.Stat(directory); requireApp && (err != nil || !info.IsDir()) { return "", fmt.Errorf(text(ctx, "Claude Desktop не найден (нет папки %s); установи и запусти его один раз или получи запись для ручной настройки: softpractice mcp setup claude-desktop --print", "Claude Desktop is not found (no folder %s); install and start it once, or get the entry for manual setup: softpractice mcp setup claude-desktop --print"), directory) @@ -162,13 +133,21 @@ func claudeDesktopConfigPath(ctx context.Context) (string, error) { // addMCPServerToConfig sets mcpServers.softpractice and keeps every other // member as it was, in its order: the file also holds the app's preferences. // A file that is not a JSON object is left untouched. -func addMCPServerToConfig(ctx context.Context, path string, entry json.RawMessage) (backup string, changed bool, err error) { +func addMCPServerToConfig(ctx context.Context, path string, entry json.RawMessage) (string, bool, error) { + return updateMCPServerConfig(ctx, path, entry) +} + +// A nil entry removes only SoftPractice. Missing files and entries are a no-op. +func updateMCPServerConfig(ctx context.Context, path string, entry json.RawMessage) (backup string, changed bool, err error) { mode := fs.FileMode(0o644) original, err := os.ReadFile(path) if err != nil && !errors.Is(err, fs.ErrNotExist) { return "", false, err } exists := err == nil + if !exists && entry == nil { + return "", false, nil + } if exists { if info, err := os.Stat(path); err == nil { mode = info.Mode().Perm() @@ -194,7 +173,23 @@ func addMCPServerToConfig(ctx context.Context, path string, entry json.RawMessag } } } - servers = setJSONMember(servers, mcpServerName, entry) + if entry == nil { + kept := servers[:0] + found := false + for _, member := range servers { + if member.Key == mcpServerName { + found = true + } else { + kept = append(kept, member) + } + } + if !found { + return "", false, nil + } + servers = kept + } else { + servers = setJSONMember(servers, mcpServerName, entry) + } encodedServers, err := encodeJSONObject(servers) if err != nil { return "", false, err @@ -315,3 +310,13 @@ func mustEncodeJSONObject(members []jsonMember) json.RawMessage { data, _ := encodeJSONObject(members) return data } + +func printMCPSetupTarget(ctx context.Context, executable string, output io.Writer) { + fmt.Fprintf(output, text(ctx, " Программа: %s\n", " Program: %s\n"), executable) +} + +func printMCPSetupNextStep(ctx context.Context, output io.Writer) { + fmt.Fprintln(output, text(ctx, + "Запусти softpractice mcp в папке урока или softpractice mcp --project DIR и оставь терминал открытым. При смене проекта останови сервер, запусти его в новой папке и переподключи MCP-клиент. Повторять setup не нужно.", + "Run softpractice mcp in the lesson folder or softpractice mcp --project DIR and keep the terminal open. To switch projects, stop the server, start it in the new folder, and reconnect the MCP client. Setup does not need to be repeated.")) +} diff --git a/cmd/softpractice/mcp_setup_codex.go b/cmd/softpractice/mcp_setup_codex.go index 934d979..7e152e0 100644 --- a/cmd/softpractice/mcp_setup_codex.go +++ b/cmd/softpractice/mcp_setup_codex.go @@ -28,17 +28,17 @@ func codexConfigPath() (string, error) { return filepath.Abs(filepath.Join(directory, "config.toml")) } -func codexServerEntry(executable, root string) map[string]any { +func codexServerEntry(executable string) map[string]any { return map[string]any{ "command": executable, - "args": []string{"mcp", "--project", root}, + "args": []string{"mcp", "connect"}, "tool_timeout_sec": int64(180), "enabled": true, } } -func setupCodexDesktop(ctx context.Context, executable, root string, printOnly bool, output io.Writer) error { - entry := codexServerEntry(executable, root) +func setupCodexDesktop(ctx context.Context, executable string, printOnly bool, output io.Writer) error { + entry := codexServerEntry(executable) if printOnly { document, err := toml.Marshal(map[string]any{"mcp_servers": map[string]any{mcpServerName: entry}}) if err != nil { @@ -60,7 +60,7 @@ func setupCodexDesktop(ctx context.Context, executable, root string, printOnly b } else { fmt.Fprintf(output, text(ctx, "Codex Desktop уже настроен: %s\n", "Codex Desktop is already configured: %s\n"), path) } - fmt.Fprintf(output, text(ctx, " Программа: %s\n Проект: %s\n", " Program: %s\n Project: %s\n"), executable, root) + printMCPSetupTarget(ctx, executable, output) if backup != "" { fmt.Fprintf(output, text(ctx, "Прежний файл сохранён: %s\n", "The previous file is saved as %s\n"), backup) } @@ -69,9 +69,7 @@ func setupCodexDesktop(ctx context.Context, executable, root string, printOnly b "\nПерезапусти Codex Desktop и открой папку проекта урока. Проверь сервер softpractice в настройках MCP и попроси агента показать статус урока и задание.", "\nRestart Codex Desktop and open the lesson project folder. Check the softpractice server in the MCP settings and ask the agent to show the lesson status and task.")) } - fmt.Fprintln(output, text(ctx, - "Настройки общие с Codex CLI и расширением IDE. Для другого проекта урока повтори команду в его папке.", - "Settings are shared with Codex CLI and the IDE extension. For another lesson project, run the command again in its folder.")) + printMCPSetupNextStep(ctx, output) return nil } @@ -116,7 +114,7 @@ func replaceCodexServerTables(original, entry []byte) ([]byte, error) { if bytes.Contains(original, []byte("\r\n")) { newline = []byte("\r\n") } - if updated.Len() > 0 { + if len(entry) > 0 && updated.Len() > 0 { if !bytes.HasSuffix(updated.Bytes(), []byte("\n")) { updated.Write(newline) } @@ -143,11 +141,19 @@ func tableStart(document []byte, header int) int { } func addCodexMCPServerToConfig(ctx context.Context, path string, entry map[string]any) (string, bool, error) { + return updateCodexMCPServerConfig(ctx, path, entry) +} + +// A nil entry removes only SoftPractice, including its nested tables. +func updateCodexMCPServerConfig(ctx context.Context, path string, entry map[string]any) (string, bool, error) { original, err := os.ReadFile(path) if err != nil && !errors.Is(err, fs.ErrNotExist) { return "", false, err } exists := err == nil + if !exists && entry == nil { + return "", false, nil + } mode := fs.FileMode(0o600) if exists { info, err := os.Stat(path) @@ -157,6 +163,9 @@ func addCodexMCPServerToConfig(ctx context.Context, path string, entry map[strin mode = info.Mode().Perm() } unchanged := func(err error) (string, bool, error) { + if entry == nil { + return "", false, fmt.Errorf(text(ctx, "Не удалось удалить подключение из %s, файл не изменён: %w", "Could not remove the connection from %s; the file is unchanged: %w"), path, err) + } return "", false, fmt.Errorf(text(ctx, "Не удалось обновить %s, файл не изменён; используй softpractice mcp setup codex-desktop --print для ручной настройки: %w", "Could not update %s; the file is unchanged. Use softpractice mcp setup codex-desktop --print for manual setup: %w"), path, err) @@ -170,6 +179,9 @@ func addCodexMCPServerToConfig(ctx context.Context, path string, entry map[strin if _, exists := config["mcp_servers"]; exists { return unchanged(errors.New("mcp_servers is not a TOML table")) } + if entry == nil { + return "", false, nil + } servers = map[string]any{} config["mcp_servers"] = servers } @@ -181,6 +193,9 @@ func addCodexMCPServerToConfig(ctx context.Context, path string, entry map[strin return unchanged(errors.New("mcp_servers.softpractice is not a TOML table")) } } + if entry == nil && servers[mcpServerName] == nil { + return "", false, nil + } previous, _ := toml.Marshal(server) // A stdio entry must not retain a previous HTTP transport. for _, key := range []string{"url", "bearer_token_env_var", "http_headers", "env_http_headers", "oauth"} { @@ -193,24 +208,30 @@ func addCodexMCPServerToConfig(ctx context.Context, path string, entry map[strin if err != nil { return unchanged(err) } - if exists && bytes.Equal(previous, current) { + if entry != nil && exists && bytes.Equal(previous, current) { return "", false, nil } - servers[mcpServerName] = server - document, err := toml.Marshal(map[string]any{"mcp_servers": map[string]any{mcpServerName: server}}) - if err != nil { - return unchanged(err) + + var document []byte + if entry == nil { + delete(servers, mcpServerName) + } else { + servers[mcpServerName] = server + document, err = toml.Marshal(map[string]any{"mcp_servers": map[string]any{mcpServerName: server}}) + if err != nil { + return unchanged(err) + } } updated, err := replaceCodexServerTables(original, document) if err != nil { return unchanged(err) } - var verified map[string]any + verified := map[string]any{} if err := toml.Unmarshal(updated, &verified); err != nil { return unchanged(err) } // Inline or dotted server definitions cannot be safely replaced as sections. - var expected map[string]any + expected := map[string]any{} canonical, err := toml.Marshal(config) if err != nil { return unchanged(err) @@ -218,6 +239,12 @@ func addCodexMCPServerToConfig(ctx context.Context, path string, entry map[strin if err := toml.Unmarshal(canonical, &expected); err != nil { return unchanged(err) } + if entry == nil && len(servers) == 0 { + delete(expected, "mcp_servers") + if remaining, ok := verified["mcp_servers"].(map[string]any); ok && len(remaining) == 0 { + delete(verified, "mcp_servers") + } + } if !reflect.DeepEqual(verified, expected) { return unchanged(errors.New("unsupported TOML server layout")) } diff --git a/cmd/softpractice/mcp_setup_codex_test.go b/cmd/softpractice/mcp_setup_codex_test.go index eff2a15..1995e5b 100644 --- a/cmd/softpractice/mcp_setup_codex_test.go +++ b/cmd/softpractice/mcp_setup_codex_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "encoding/json" "os" "os/exec" "path/filepath" @@ -11,18 +12,17 @@ import ( "strings" "testing" - "github.com/google/uuid" "github.com/pelletier/go-toml/v2" ) -func TestCodexSetupPreservesSettingsAndSwitchesProject(t *testing.T) { +func TestCodexSetupPreservesSettingsAndUpdatesExecutable(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") prefix := "# My preferences\nmodel = 'example'\nnote = '''\n[mcp_servers.softpractice]\nthis is just text\n'''\n\n[mcp_servers.other]\ncommand = 'other' # keep this\n" original := prefix + "\n[mcp_servers.'softpractice']\nurl = 'https://example.com/mcp'\nenabled = false\ndisabled_tools = ['submit']\n\n[mcp_servers.softpractice.env]\nSOFTPRACTICE_LANGUAGE = 'en'\n\n[projects.'/work/other']\ntrust_level = 'trusted'\n" if err := os.WriteFile(path, []byte(original), 0o600); err != nil { t.Fatal(err) } - entry := codexServerEntry(`C:\Program Files\SoftPractice\softpractice.exe`, `/work/lesson "one"`) + entry := codexServerEntry(`C:\Program Files\SoftPractice\softpractice.exe`) backup, changed, err := addCodexMCPServerToConfig(context.Background(), path, entry) if err != nil || !changed || backup != path+".bak" { t.Fatalf("backup=%q changed=%v err=%v", backup, changed, err) @@ -44,7 +44,7 @@ func TestCodexSetupPreservesSettingsAndSwitchesProject(t *testing.T) { } server := config["mcp_servers"].(map[string]any)[mcpServerName].(map[string]any) if server["command"] != entry["command"] || server["enabled"] != true || server["tool_timeout_sec"] != int64(180) || - !reflect.DeepEqual(server["args"], []any{"mcp", "--project", `/work/lesson "one"`}) || + !reflect.DeepEqual(server["args"], []any{"mcp", "connect"}) || server["url"] != nil || server["env"].(map[string]any)["SOFTPRACTICE_LANGUAGE"] != "en" || !reflect.DeepEqual(server["disabled_tools"], []any{"submit"}) { t.Fatalf("server = %#v", server) @@ -57,7 +57,7 @@ func TestCodexSetupPreservesSettingsAndSwitchesProject(t *testing.T) { if !bytes.Equal(data, repeated) { t.Fatal("repeat changed the file") } - entry = codexServerEntry("/opt/softpractice", "/work/second") + entry = codexServerEntry("/opt/softpractice") if _, changed, err := addCodexMCPServerToConfig(context.Background(), path, entry); err != nil || !changed { t.Fatalf("switch: changed=%v err=%v", changed, err) } @@ -66,8 +66,8 @@ func TestCodexSetupPreservesSettingsAndSwitchesProject(t *testing.T) { t.Fatal(err) } server = config["mcp_servers"].(map[string]any)[mcpServerName].(map[string]any) - if !reflect.DeepEqual(server["args"], []any{"mcp", "--project", "/work/second"}) { - t.Fatalf("switched args = %#v", server["args"]) + if server["command"] != "/opt/softpractice" || !reflect.DeepEqual(server["args"], []any{"mcp", "connect"}) { + t.Fatalf("updated server = %#v", server) } info, err := os.Stat(path) if err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0o600) { @@ -77,7 +77,7 @@ func TestCodexSetupPreservesSettingsAndSwitchesProject(t *testing.T) { func TestCodexSetupCreatesConfigDirectory(t *testing.T) { path := filepath.Join(t.TempDir(), ".codex", "config.toml") - backup, changed, err := addCodexMCPServerToConfig(context.Background(), path, codexServerEntry("/opt/softpractice", "/work/lesson")) + backup, changed, err := addCodexMCPServerToConfig(context.Background(), path, codexServerEntry("/opt/softpractice")) if err != nil || !changed || backup != "" { t.Fatalf("backup=%q changed=%v err=%v", backup, changed, err) } @@ -99,7 +99,7 @@ func TestCodexSetupLeavesInvalidOrUnsupportedConfigUntouched(t *testing.T) { if err := os.WriteFile(path, []byte(original), 0o600); err != nil { t.Fatal(err) } - if _, _, err := addCodexMCPServerToConfig(context.Background(), path, codexServerEntry("/opt/softpractice", "/work/lesson")); err == nil { + if _, _, err := addCodexMCPServerToConfig(context.Background(), path, codexServerEntry("/opt/softpractice")); err == nil { t.Fatal("setup succeeded") } data, err := os.ReadFile(path) @@ -115,11 +115,6 @@ func TestCodexSetupLeavesInvalidOrUnsupportedConfigUntouched(t *testing.T) { func TestCodexDesktopSetupCommandPrintAndWrite(t *testing.T) { t.Setenv("CODEX_HOME", filepath.Join(t.TempDir(), "codex home")) - root := createLinkedGitRepository(t, uuid.NewString()) - root, err := filepath.EvalSymlinks(root) - if err != nil { - t.Fatal(err) - } executable := filepath.Join(t.TempDir(), "softpractice") if runtime.GOOS == "windows" { executable += ".exe" @@ -127,7 +122,7 @@ func TestCodexDesktopSetupCommandPrintAndWrite(t *testing.T) { if output, err := exec.Command("go", "build", "-o", executable, ".").CombinedOutput(); err != nil { t.Fatalf("build: %s, %v", output, err) } - args := []string{"mcp", "setup", "codex-desktop", "--project", root, "--print"} + args := []string{"mcp", "setup", "codex-desktop", "--print"} output, err := exec.Command(executable, args...).CombinedOutput() if err != nil { t.Fatalf("print: %s, %v", output, err) @@ -140,7 +135,7 @@ func TestCodexDesktopSetupCommandPrintAndWrite(t *testing.T) { t.Fatal("printed an unnecessary parent table") } server := config["mcp_servers"].(map[string]any)[mcpServerName].(map[string]any) - if !filepath.IsAbs(server["command"].(string)) || !reflect.DeepEqual(server["args"], []any{"mcp", "--project", root}) { + if !filepath.IsAbs(server["command"].(string)) || !reflect.DeepEqual(server["args"], []any{"mcp", "connect"}) { t.Fatalf("entry = %#v", server) } path, err := codexConfigPath() @@ -157,6 +152,79 @@ func TestCodexDesktopSetupCommandPrintAndWrite(t *testing.T) { if _, err := os.Stat(path); err != nil || !strings.Contains(string(output), "Codex Desktop") { t.Fatalf("setup: %s, %v", output, err) } + + // Setup and --print work before a lesson project exists. + outside := t.TempDir() + for _, client := range []string{"claude-desktop", "codex-desktop"} { + command := exec.Command(executable, "mcp", "setup", client, "--print") + command.Dir = outside + printed, err := command.CombinedOutput() + if err != nil { + t.Fatalf("%s outside project: %s, %v", client, printed, err) + } + if client == "claude-desktop" { + var config struct { + Servers map[string]struct{ Args []string } `json:"mcpServers"` + } + if err := json.Unmarshal(printed, &config); err != nil || !reflect.DeepEqual(config.Servers[mcpServerName].Args, []string{"mcp", "connect"}) { + t.Fatalf("Claude entry: %s, %v", printed, err) + } + } else { + var config map[string]any + if err := toml.Unmarshal(printed, &config); err != nil { + t.Fatal(err) + } + if args := config["mcp_servers"].(map[string]any)[mcpServerName].(map[string]any)["args"]; !reflect.DeepEqual(args, []any{"mcp", "connect"}) { + t.Fatalf("Codex args: %v", args) + } + } + } + command := exec.Command(executable, "mcp", "setup", "codex-desktop") + command.Dir = outside + if output, err := command.CombinedOutput(); err != nil || !strings.Contains(string(output), "softpractice mcp --project DIR") { + t.Fatalf("global setup: %s, %v", output, err) + } + + // Isolate all platform-specific Claude configuration paths from the user's + // real desktop settings when exercising the writer outside a project. + configBase := t.TempDir() + t.Setenv("HOME", configBase) + t.Setenv("XDG_CONFIG_HOME", configBase) + t.Setenv("APPDATA", configBase) + t.Setenv("LOCALAPPDATA", configBase) + if runtime.GOOS == "darwin" { + configBase = filepath.Join(configBase, "Library", "Application Support") + } + claudeDirectory := filepath.Join(configBase, "Claude") + if err := os.MkdirAll(claudeDirectory, 0o700); err != nil { + t.Fatal(err) + } + command = exec.Command(executable, "mcp", "setup", "claude-desktop") + command.Dir = outside + if output, err := command.CombinedOutput(); err != nil || !strings.Contains(string(output), "softpractice mcp --project DIR") { + t.Fatalf("Claude global setup: %s, %v", output, err) + } + saved, err := os.ReadFile(filepath.Join(claudeDirectory, "claude_desktop_config.json")) + if err != nil || !bytes.Contains(saved, []byte(`"connect"`)) { + t.Fatalf("Claude global config: %s, %v", saved, err) + } + + // Both removal commands use the same isolated desktop settings and need + // no lesson project or running server. + for _, client := range []string{"claude-desktop", "codex-desktop"} { + command := exec.Command(executable, "mcp", "remove", client) + command.Dir = outside + if removed, err := command.CombinedOutput(); err != nil { + t.Fatalf("%s remove: %s, %v", client, removed, err) + } + // Repeat without replacing the saved backup or failing. + command = exec.Command(executable, "mcp", "remove", client) + command.Dir = outside + if removed, err := command.CombinedOutput(); err != nil { + t.Fatalf("%s repeated remove: %s, %v", client, removed, err) + } + } + } // Comments directly above a table belong to it: replacing the server table @@ -168,7 +236,7 @@ func TestCodexSetupKeepsCommentsOfFollowingTable(t *testing.T) { if err := os.WriteFile(path, []byte(original), 0o600); err != nil { t.Fatal(err) } - if _, changed, err := addCodexMCPServerToConfig(context.Background(), path, codexServerEntry("/opt/softpractice", "/work/lesson")); err != nil || !changed { + if _, changed, err := addCodexMCPServerToConfig(context.Background(), path, codexServerEntry("/opt/softpractice")); err != nil || !changed { t.Fatalf("changed=%v err=%v", changed, err) } data, err := os.ReadFile(path) @@ -185,7 +253,7 @@ func TestCodexSetupKeepsCommentsOfFollowingTable(t *testing.T) { func TestCodexSetupNewFileStartsWithServerTable(t *testing.T) { path := filepath.Join(t.TempDir(), "config.toml") - if _, _, err := addCodexMCPServerToConfig(context.Background(), path, codexServerEntry("/opt/softpractice", "/work/lesson")); err != nil { + if _, _, err := addCodexMCPServerToConfig(context.Background(), path, codexServerEntry("/opt/softpractice")); err != nil { t.Fatal(err) } data, err := os.ReadFile(path) diff --git a/cmd/softpractice/mcp_setup_test.go b/cmd/softpractice/mcp_setup_test.go index d7f0fa1..a457e6e 100644 --- a/cmd/softpractice/mcp_setup_test.go +++ b/cmd/softpractice/mcp_setup_test.go @@ -10,7 +10,7 @@ import ( "testing" ) -const testDesktopEntry = `{"command":"/opt/softpractice","args":["mcp","--project","/work/lesson"]}` +const testDesktopEntry = `{"command":"/opt/softpractice","args":["mcp","connect"]}` // Claude Desktop keeps its own preferences in the same file: setup changes // only mcpServers.softpractice and keeps every other member in its place. @@ -55,7 +55,7 @@ func TestAddMCPServerToConfigKeepsOtherSettings(t *testing.T) { } if config.Preferences["sidebarMode"] != "chat" || config.MCPServers["other"].Command != "x" || config.MCPServers["softpractice"].Command != "/opt/softpractice" || - strings.Join(config.MCPServers["softpractice"].Args, " ") != "mcp --project /work/lesson" { + strings.Join(config.MCPServers["softpractice"].Args, " ") != "mcp connect" { t.Fatalf("config = %s", data) } if info, err := os.Stat(path); err != nil || (runtime.GOOS != "windows" && info.Mode().Perm() != 0o600) { diff --git a/cmd/softpractice/mcp_status.go b/cmd/softpractice/mcp_status.go new file mode 100644 index 0000000..8b18a6f --- /dev/null +++ b/cmd/softpractice/mcp_status.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "syscall" +) + +type localMCPStatus struct { + Kind string `json:"kind"` + State string `json:"state"` + Project string `json:"project,omitempty"` + PID int `json:"pid,omitempty"` + Address string `json:"address"` + Version string `json:"version,omitempty"` +} + +func readLocalMCPStatus(ctx context.Context) (localMCPStatus, error) { + stopped := localMCPStatus{Kind: "softpractice.mcp.status", State: "not_running", Address: localMCPAddress} + connection, err := dialLocalMCP(ctx, " status") + // Windows reports Winsock WSAECONNREFUSED (10061), rather than the + // application-level syscall.ECONNREFUSED constant. + refused := errors.Is(err, syscall.ECONNREFUSED) || (runtime.GOOS == "windows" && errors.Is(err, syscall.Errno(10061))) + if errors.Is(err, os.ErrNotExist) || refused { + return stopped, nil + } + if err != nil { + return localMCPStatus{}, err + } + defer connection.Close() + stop := context.AfterFunc(ctx, func() { _ = connection.Close() }) + defer stop() + var status localMCPStatus + if err := json.NewDecoder(io.LimitReader(connection, 64<<10)).Decode(&status); err != nil { + return localMCPStatus{}, err + } + if status.Kind != stopped.Kind || status.State != "running" || !filepath.IsAbs(status.Project) || status.PID <= 0 || status.Address != localMCPAddress || status.Version == "" { + return localMCPStatus{}, errors.New("invalid local MCP status response") + } + return status, nil +} + +func mcpStatusCommand(ctx context.Context, args []string, output, errorOutput io.Writer) (err error) { + defer func() { err = reportJSONError(requestsJSON(args), output, err) }() + flags := flag.NewFlagSet("mcp status", flag.ContinueOnError) + flags.SetOutput(errorOutput) + jsonOutput := jsonFlag(flags) + if err := parseFlags(flags, args); err != nil { + return err + } + if flags.NArg() != 0 { + return usage(ctx, "использование: softpractice mcp status [--json]", "usage: softpractice mcp status [--json]") + } + status, err := readLocalMCPStatus(ctx) + if err != nil { + return fmt.Errorf(text(ctx, "не удалось проверить MCP-сервер: %w", "could not check the MCP server: %w"), err) + } + if *jsonOutput { + return writeMachineJSON(output, status) + } + if status.State == "not_running" { + _, err = fmt.Fprintln(output, text(ctx, + "MCP-сервер не запущен. Запусти softpractice mcp в папке урока или softpractice mcp --project DIR.", + "MCP server is not running. Run softpractice mcp in the lesson folder or softpractice mcp --project DIR.")) + return err + } + _, err = fmt.Fprintf(output, text(ctx, + "MCP-сервер работает.\nПроект: %s\nPID: %d\nПодключение: %s\nВерсия CLI: %s\n", + "MCP server is running.\nProject: %s\nPID: %d\nConnection: %s\nCLI version: %s\n"), status.Project, status.PID, status.Address, status.Version) + return err +} diff --git a/cmd/softpractice/mcp_status_test.go b/cmd/softpractice/mcp_status_test.go new file mode 100644 index 0000000..7571c2e --- /dev/null +++ b/cmd/softpractice/mcp_status_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "strings" + "testing" +) + +func TestMCPStatusOutsideProjectWithoutServer(t *testing.T) { + t.Setenv(configDirectoryEnv, t.TempDir()) + t.Setenv("SOFTPRACTICE_CREDENTIALS_DIR", t.TempDir()) + t.Chdir(t.TempDir()) + var output bytes.Buffer + err := run(context.Background(), []string{"mcp", "status", "--json"}, strings.NewReader(""), &output, io.Discard) + var status localMCPStatus + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(output.Bytes(), &status); err != nil || status.State != "not_running" || status.Project != "" || status.PID != 0 { + t.Fatalf("offline JSON status: %+v, %v", status, err) + } + output.Reset() + if err := mcpStatusCommand(context.Background(), nil, &output, io.Discard); err != nil || !strings.Contains(output.String(), "softpractice mcp --project DIR") { + t.Fatalf("offline status: %v, %s", err, output.String()) + } +} + +func TestMCPStatusCorruptSecretIsAnError(t *testing.T) { + t.Setenv(configDirectoryEnv, t.TempDir()) + path, err := localMCPTokenPath() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("broken"), 0o600); err != nil { + t.Fatal(err) + } + status, err := readLocalMCPStatus(context.Background()) + if err == nil || status.State == "running" { + t.Fatalf("corrupt secret status: %+v, %v", status, err) + } +} diff --git a/cmd/softpractice/mcp_test.go b/cmd/softpractice/mcp_test.go index f9260c8..a77ae84 100644 --- a/cmd/softpractice/mcp_test.go +++ b/cmd/softpractice/mcp_test.go @@ -1,10 +1,8 @@ package main import ( - "bufio" "context" "encoding/json" - "errors" "io" "net/http" "net/http/httptest" @@ -361,73 +359,16 @@ func TestMCPNextActionsNameTools(t *testing.T) { } } -// `softpractice mcp` speaks JSON-RPC on standard output and writes nothing -// else there. -func TestMCPCommandWritesOnlyJSONRPCToStdout(t *testing.T) { - directory := t.TempDir() - t.Setenv(configDirectoryEnv, directory) - t.Setenv("SOFTPRACTICE_CREDENTIALS_DIR", directory) +// Even piped protocol input cannot bypass project validation or select another +// transport: mcp always starts the local lesson server. +func TestMCPCommandRejectsUnlinkedProjectBeforeProtocol(t *testing.T) { + t.Setenv(configDirectoryEnv, t.TempDir()) + t.Setenv("SOFTPRACTICE_CREDENTIALS_DIR", t.TempDir()) t.Setenv("SOFTPRACTICE_API_URL", "http://127.0.0.1:9") - t.Setenv("SOFTPRACTICE_LANGUAGE", "") - t.Setenv("LC_ALL", "") - t.Setenv("LC_MESSAGES", "") - t.Setenv("LANG", "") - - inputReader, inputWriter := io.Pipe() - outputReader, outputWriter := io.Pipe() - var errorOutput lockedBuffer - done := make(chan error, 1) - go func() { - done <- run(context.Background(), []string{"mcp", "--project", t.TempDir()}, inputReader, outputWriter, &errorOutput) - _ = outputWriter.Close() - }() - lines := bufio.NewScanner(outputReader) - lines.Buffer(make([]byte, 1<<20), 1<<20) - send := func(message string) { - if _, err := io.WriteString(inputWriter, message+"\n"); err != nil { - t.Fatal(err) - } - } - receive := func() map[string]any { - if !lines.Scan() { - t.Fatalf("no response: %v; stderr: %s", lines.Err(), errorOutput.String()) - } - var message map[string]any - if err := json.Unmarshal(lines.Bytes(), &message); err != nil || message["jsonrpc"] != "2.0" { - t.Fatalf("stdout line is not JSON-RPC: %q", lines.Text()) - } - return message - } - send(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}`) - receive() - send(`{"jsonrpc":"2.0","method":"notifications/initialized"}`) - send(`{"jsonrpc":"2.0","id":2,"method":"tools/list"}`) - if tools := receive()["result"].(map[string]any)["tools"].([]any); len(tools) != 9 { - t.Fatalf("tools/list returned %d tools", len(tools)) - } - // Outside a Git repository the error is a tool result, in Russian by - // default when no language is configured anywhere. The system locale - // still counts: Windows reports its display language. - wantAction := "Запустите" - if languageFromLocale(systemLocaleName()) == languageEnglish { - wantAction = "Start the MCP server" - } - send(`{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"task","arguments":{}}}`) - response := receive()["result"].(map[string]any) - text := response["content"].([]any)[0].(map[string]any)["text"].(string) - if response["isError"] != true || !strings.Contains(text, "not_git_repository") || !strings.Contains(text, wantAction) { - t.Fatalf("task outside a project = %v", response) - } - _ = inputWriter.Close() - select { - case err := <-done: - if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, mcp.ErrConnectionClosed) { - t.Fatalf("mcp command: %v", err) - } - case <-time.After(10 * time.Second): - t.Fatal("the MCP server did not stop when standard input closed") - } - for lines.Scan() { - t.Fatalf("unexpected stdout after shutdown: %q", lines.Text()) + var output, errorOutput lockedBuffer + input := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`) + err := run(context.Background(), []string{"mcp", "--project", t.TempDir()}, input, &output, &errorOutput) + if err == nil || !strings.Contains(err.Error(), "--project") || output.String() != "" { + t.Fatalf("unlinked project: %v, stdout=%s", err, output.String()) } } diff --git a/cmd/softpractice/ui.go b/cmd/softpractice/ui.go index 8eac4ef..1bbbf98 100644 --- a/cmd/softpractice/ui.go +++ b/cmd/softpractice/ui.go @@ -290,7 +290,7 @@ func printCommandHelp(ctx context.Context, output io.Writer, command string) err case "update": russian, english = "Использование: softpractice update\n\nПосле принятия решения применяет в этом же проекте переход к следующему уроку: добавляет, заменяет или удаляет только явно объявленные файлы.\n\nПереход применяется к принятому решению прошлого урока. Если текущий коммит — другой, команда ничего не меняет и показывает оба коммита и способ продолжить.", "Usage: softpractice update\n\nAfter acceptance, applies the next-lesson transition in the same project, adding, replacing, or removing only explicitly declared files.\n\nThe transition applies to the accepted solution of the previous lesson. When the current commit is a different one, the command changes nothing and shows both commits and how to continue." case "mcp": - russian, english = "Использование: softpractice mcp [--project DIR]\n softpractice mcp setup [--project DIR] [--print]\n\nЗапускает локальный MCP-сервер на stdio для Claude Code, Codex, Claude Desktop и других агентов. Инструменты: status, task, material, hints, check, submit, result, submissions, update. submit и update просят подтверждения человека. Рекомендации рецензента и текст его вопросов сервер не отдаёт. Сначала войдите: softpractice login. --project задаёт папку проекта, если клиент запускает сервер не в ней. Язык сообщений: --lang, SOFTPRACTICE_LANGUAGE или set lang; по умолчанию русский.\n\nmcp setup codex-desktop подключает сервер к Codex Desktop без установки Codex CLI: записывает абсолютные пути и tool_timeout_sec = 180 в ~/.codex/config.toml (или $CODEX_HOME/config.toml), сохраняет остальные настройки и прежний файл в .bak. Перезапусти приложение и открой папку урока. Настройки общие с Codex CLI и IDE; при смене проекта повтори команду. --print только печатает TOML-запись.\n\nmcp setup claude-desktop подключает сервер к Claude Desktop для проекта урока в текущей папке или в --project: добавляет запись softpractice в claude_desktop_config.json, сохраняя остальные настройки и прежний файл в .bak. --print только печатает запись для ручной настройки.", "Usage: softpractice mcp [--project DIR]\n softpractice mcp setup [--project DIR] [--print]\n\nStarts a local stdio MCP server for Claude Code, Codex, Claude Desktop, and other agents. Tools: status, task, material, hints, check, submit, result, submissions, update. submit and update ask a person to confirm. The server never returns the reviewer's directions or the text of the reviewer's questions. Sign in first: softpractice login. --project sets the project folder when the client starts the server elsewhere. Message language: --lang, SOFTPRACTICE_LANGUAGE, or set lang; Russian by default.\n\nmcp setup codex-desktop connects the server to Codex Desktop without installing Codex CLI: it writes absolute paths and tool_timeout_sec = 180 to ~/.codex/config.toml (or $CODEX_HOME/config.toml), keeping other settings and the previous file as .bak. Restart the app and open the lesson folder. Settings are shared with Codex CLI and the IDE; rerun setup when switching projects. --print only prints the TOML entry.\n\nmcp setup claude-desktop connects the server to Claude Desktop for the lesson project in the current folder or in --project: it adds the softpractice entry to claude_desktop_config.json, keeping the other settings and the previous file as .bak. --print only prints the entry for manual setup." + russian, english = "Использование: softpractice mcp [--project DIR]\n softpractice mcp connect\n softpractice mcp status [--json]\n softpractice mcp setup [--print]\n softpractice mcp remove \n\nНастрой клиент один раз из любой папки: softpractice mcp setup claude-desktop или softpractice mcp setup codex-desktop. Проект для настройки не нужен. --print только печатает запись. Остальные настройки сохраняются, прежний файл остаётся в .bak. Codex использует ~/.codex/config.toml (или $CODEX_HOME/config.toml), общий с CLI и IDE, и tool_timeout_sec = 180. Claude Desktop использует claude_desktop_config.json.\n\nСначала войди: softpractice login. Затем запусти softpractice mcp в папке привязанного Git-проекта урока или укажи --project DIR. Оставь терминал открытым. Сервер слушает только 127.0.0.1:39473 и требует ключ текущего запуска. Клиент запускает mcp connect, который передаёт MCP-сообщения серверу. Сервер и клиент работают на одном компьютере под одним пользователем с одинаковой папкой настроек CLI.\n\nЗапусти сервер перед перезапуском клиента. Для смены проекта останови сервер через Ctrl+C, запусти в новой папке и переподключи MCP-клиент; повторять setup не нужно. Одновременно работает один сервер урока.\n\nsoftpractice mcp всегда запускает локальный сервер, независимо от stdin, в том числе при фоновом запуске. Проект выбирается только при запуске сервера; setup не принимает --project.\n\nПроверка сервера: softpractice mcp status [--json] из любой папки. Показывает работающий проект, PID, адрес и версию CLI; не требует входа в аккаунт. Остановка сервера: Ctrl+C в его терминале. Удаление подключения: softpractice mcp remove claude-desktop или softpractice mcp remove codex-desktop. Команда работает из любой папки, удаляет только запись SoftPractice и сохраняет прежний файл в .bak. Перезапусти клиент. Сервер урока продолжает работать до остановки. Вернуть подключение можно через setup.\n\nИнструменты: status, task, material, hints, check, submit, result, submissions, update. submit и update просят подтверждения человека. Рекомендации рецензента и текст его вопросов сервер не отдаёт. Язык сообщений: --lang, SOFTPRACTICE_LANGUAGE или set lang; по умолчанию русский.", "Usage: softpractice mcp [--project DIR]\n softpractice mcp connect\n softpractice mcp status [--json]\n softpractice mcp setup [--print]\n softpractice mcp remove \n\nConfigure the client once from any folder: softpractice mcp setup claude-desktop or softpractice mcp setup codex-desktop. No project is needed for setup. --print only prints the entry. Other settings are preserved; the previous file is saved as .bak. Codex uses ~/.codex/config.toml (or $CODEX_HOME/config.toml), shared with CLI and IDE, and tool_timeout_sec = 180. Claude Desktop uses claude_desktop_config.json.\n\nSign in first: softpractice login. Then run softpractice mcp in a linked lesson Git project or pass --project DIR. Keep the terminal open. The server listens only on 127.0.0.1:39473 and requires the current run's secret. The client starts mcp connect, which forwards MCP messages to the server. Both processes run on the same computer under the same user with the same CLI configuration folder.\n\nStart the server before restarting the client. To switch projects, stop it with Ctrl+C, start it in the new folder, and reconnect the MCP client; setup does not need to be repeated. Only one lesson server can run at a time.\n\nsoftpractice mcp always starts the local server, regardless of stdin, including background launches. Only server startup selects the project; setup does not accept --project.\n\nCheck the server from any folder with softpractice mcp status [--json]. Shows the active project, PID, address, and CLI version; no sign-in is needed. Stop the server with Ctrl+C in its terminal. Remove the connection with softpractice mcp remove claude-desktop or softpractice mcp remove codex-desktop. It works from any folder, removes only the SoftPractice entry, and saves the previous file as .bak. Restart the client. The lesson server keeps running until stopped. Restore the connection with setup.\n\nTools: status, task, material, hints, check, submit, result, submissions, update. submit and update ask a person to confirm. The server never returns the reviewer's directions or the text of the reviewer's questions. Message language: --lang, SOFTPRACTICE_LANGUAGE, or set lang; Russian by default." case "open": russian, english = "Использование: softpractice open [task|material|result] [--web URL] [--no-browser]\n\nОткрывает в браузере задание, материал или последний результат. Без аргумента — результат, а до первой отправки — задание.", "Usage: softpractice open [task|material|result] [--web URL] [--no-browser]\n\nOpens the task, the material, or the latest result in a browser. Without an argument: the result, or the task before the first submission." case "set": diff --git a/docs/mcp.md b/docs/mcp.md index 347aa00..6c67104 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6,6 +6,13 @@ Connect the server using the [README instructions](../README.md#mcp-server). It works with one linked lesson Git project, selected by its startup directory or `--project`. Tool calls cannot switch the project folder. +Setup (`softpractice mcp setup ...`) works from any folder. Then run +`softpractice mcp` in the lesson folder or pass `--project DIR`, and keep the +terminal open. The client connects through `softpractice mcp connect`. To +switch projects, stop the server, start it in the other folder, and reconnect +the client; setup does not need to be repeated. Only one lesson server can +run at a time. Server startup always uses local TCP, regardless of stdin. + ## What to ask the agent Write your request in the chat with Codex, Claude, or another MCP client. The @@ -107,6 +114,7 @@ separate shell tools. | Symptom or code | What to do | | --- | --- | +| Bridge reports that the server is unavailable | Start `softpractice mcp` in the lesson folder or pass `--project DIR`, then reconnect the MCP client. | | Server does not start | Check that the client can find the installed CLI; use its executable's full path if needed. Check `--project` points to an existing folder. | | `not_git_repository` or `project_not_linked` | Select the lesson Git project, not the parent directory. Download or restore the project with the CLI if needed. | | `login_required` | Run `softpractice login` in a terminal on the same computer and retry the tool. | diff --git a/docs/mcp_ru.md b/docs/mcp_ru.md index 50691b9..c40bac1 100644 --- a/docs/mcp_ru.md +++ b/docs/mcp_ru.md @@ -6,6 +6,13 @@ Он работает с одним привязанным Git-проектом урока: папку задаёт место запуска или флаг `--project`. Переключить папку параметром инструмента нельзя. +Настройка `softpractice mcp setup ...` работает из любой папки. Затем запусти +`softpractice mcp` в папке урока или укажи `--project DIR` и оставь терминал +открытым. Клиент подключается через `softpractice mcp connect`. Для смены +проекта останови сервер, запусти его в другой папке и переподключи клиент; +повторять настройку не нужно. Одновременно работает один сервер урока. +Команда запуска всегда использует локальное TCP-соединение, независимо от stdin. + ## Что можно попросить у агента Напиши просьбу в чате Codex, Claude или другого MCP-клиента. Агент превращает @@ -112,6 +119,7 @@ | Симптом или код | Что сделать | | --- | --- | +| Мост сообщает, что сервер недоступен | Запусти `softpractice mcp` в папке урока или с `--project DIR`, затем переподключи MCP-клиент. | | Сервер не запускается | Проверь, что клиент видит установленный CLI; при необходимости укажи полный путь к исполняемому файлу. Проверь, что `--project` указывает на существующую папку. | | `not_git_repository` или `project_not_linked` | Укажи Git-проект урока, а не родительскую папку. При необходимости скачай или восстанови проект через CLI. | | `login_required` | Выполни `softpractice login` в терминале на том же компьютере и повтори вызов инструмента. | diff --git a/go.mod b/go.mod index 4cccb6b..e0e980d 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 github.com/zalando/go-keyring v0.2.8 golang.org/x/sys v0.48.0 - golang.org/x/term v0.46.0 ) require ( diff --git a/go.sum b/go.sum index c3c4a32..e5242a1 100644 --- a/go.sum +++ b/go.sum @@ -36,8 +36,6 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= -golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=