From 79e1b3c3f00d960c0625e04f02166d91ed96c57f Mon Sep 17 00:00:00 2001 From: Arthur Sultanbekov Date: Sat, 3 Oct 2026 14:17:16 +0500 Subject: [PATCH 1/3] Decouple MCP client setup from the active lesson project --- README.md | 150 ++++++++---------- README_RU.md | 151 ++++++++---------- cmd/softpractice/mcp.go | 37 ++++- cmd/softpractice/mcp_local.go | 188 +++++++++++++++++++++++ cmd/softpractice/mcp_local_test.go | 163 ++++++++++++++++++++ cmd/softpractice/mcp_setup.go | 26 ++-- cmd/softpractice/mcp_setup_codex.go | 8 +- cmd/softpractice/mcp_setup_codex_test.go | 64 +++++++- cmd/softpractice/ui.go | 2 +- docs/mcp.md | 8 + docs/mcp_ru.md | 8 + 11 files changed, 600 insertions(+), 205 deletions(-) create mode 100644 cmd/softpractice/mcp_local.go create mode 100644 cmd/softpractice/mcp_local_test.go diff --git a/README.md b/README.md index 6a56b40..f7982a0 100644 --- a/README.md +++ b/README.md @@ -149,128 +149,98 @@ 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 - -From the lesson project folder: +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. -```bash -claude mcp add --transport stdio --scope local softpractice -- softpractice mcp -claude mcp list -``` +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. -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). +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. -### Codex Desktop +### Claude Code and Codex CLI -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). -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`. +### Direct stdio mode and existing configurations -To configure the app manually, print the TOML entry: +Launches with piped stdin retain direct stdio behavior, so existing client +configurations keep working. You can select that mode explicitly with `--stdio`: ```bash -softpractice mcp setup codex-desktop --print +claude mcp add --transport stdio --scope local softpractice -- softpractice mcp --stdio +codex mcp add softpractice -- softpractice mcp --stdio --project /path/to/your/lesson-project ``` -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). - -### Codex CLI - -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. +In this mode, the client communicates directly over stdin/stdout and no +separate terminal server is needed. The current directory selects the project +unless `--project` is given. Desktop setup with `--project DIR` also registers +this direct mode and pins that project. -To configure using Codex CLI, replace the project path: - -```bash -codex mcp add softpractice -- softpractice mcp --project /path/to/your/lesson-project -codex mcp list -``` - -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. +After updating from the previous CLI version, rerun desktop setup without +`--project` to use manual server startup. For background or redirected terminal +launches, use `softpractice mcp --listen [--project DIR]` to force local listening. +`--stdio` and `--listen` cannot be used together. ### Using the server diff --git a/README_RU.md b/README_RU.md index f54e256..5ae3f52 100644 --- a/README_RU.md +++ b/README_RU.md @@ -149,127 +149,100 @@ 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 и сам не запускает авторизацию. -Из папки проекта урока: +Запусти сервер перед перезапуском Desktop-клиента. Клиент запускает мост +`softpractice mcp connect`, который передаёт MCP-сообщения работающему серверу +на `127.0.0.1:39473`. Подключение требует случайный ключ, создаваемый заново +при каждом запуске и сохранённый в пользовательской папке настроек CLI. +Оба процесса должны работать на одном компьютере под одним пользователем +и с одинаковым `SOFTPRACTICE_CONFIG_DIR`, если эта переменная задана. +Этот адрес не является HTTP URL. -```bash -claude mcp add --transport stdio --scope local softpractice -- softpractice mcp -claude mcp list -``` - -Подключение будет личным и только для этого проекта. Запусти 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`. +### Прямой stdio-режим и прежние настройки -Чтобы настроить приложение вручную, выведи TOML-запись: +При запуске с stdin через pipe сохраняется прямой stdio-режим, поэтому прежние +настройки клиентов продолжают работать. Его можно выбрать явно через `--stdio`: ```bash -softpractice mcp setup codex-desktop --print +claude mcp add --transport stdio --scope local softpractice -- softpractice mcp --stdio +codex mcp add softpractice -- softpractice mcp --stdio --project /path/to/your/lesson-project ``` -Добавь её в `config.toml`, сохранив уже настроенные серверы. Если таблица -`[mcp_servers.softpractice]` уже есть, замени её вместо добавления второй. -Настройки общие для Codex Desktop, CLI и расширения IDE на одном компьютере. -Настройка закрепляет один проект за сервером `softpractice` во всех этих -клиентах. Проектная `.codex/config.toml` в доверенном проекте может -переопределить пользовательские настройки. Подробнее — в -[официальной документации Codex по MCP](https://developers.openai.com/codex/mcp). - -### Codex CLI - -Если уже настроил Codex Desktop командой выше, повторная регистрация -не нужна. Запусти `codex` в папке проекта урока; `/mcp` покажет активные серверы. - -Для настройки через Codex CLI подставь путь к проекту: - -```bash -codex mcp add softpractice -- softpractice mcp --project /path/to/your/lesson-project -codex mcp list -``` +В этом режиме клиент общается с сервером через stdin/stdout; отдельный +терминал с сервером не нужен. Проект определяется рабочей папкой клиента +или флагом `--project`. Desktop-настройка через `setup --project DIR` также +сохраняет прямой режим с привязкой к указанному проекту. -Для локальных проверок и ожидания результата добавь -`tool_timeout_sec = 180` в созданную таблицу `[mcp_servers.softpractice]` -в `~/.codex/config.toml`. Если клиент не находит `softpractice`, укажи полный -путь к исполняемому файлу. Можно также использовать -`softpractice mcp setup codex-desktop`: она настраивает тот же файл. +После обновления прежней версии CLI повтори Desktop-настройку без +`--project`, чтобы перейти на ручной запуск сервера. Для фонового запуска +или перенаправленного stdin укажи `softpractice mcp --listen [--project DIR]`. +Флаги `--stdio` и `--listen` нельзя использовать вместе. ### Использование сервера diff --git a/cmd/softpractice/mcp.go b/cmd/softpractice/mcp.go index 3030533..ae1bcc3 100644 --- a/cmd/softpractice/mcp.go +++ b/cmd/softpractice/mcp.go @@ -70,15 +70,32 @@ 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") + stdio := flags.Bool("stdio", false, "serve directly over stdin/stdout instead of listening locally") + listen := flags.Bool("listen", false, "listen locally even when stdin is not a terminal") if err := parseFlags(flags, args); err != nil { return err } if flags.NArg() != 0 { - return usage(ctx, "использование: softpractice mcp [--project DIR]", "usage: softpractice mcp [--project DIR]") + return usage(ctx, "использование: softpractice mcp [--project DIR] [--stdio|--listen]", "usage: softpractice mcp [--project DIR] [--stdio|--listen]") + } + if *stdio && *listen { + return usage(ctx, "--stdio и --listen нельзя использовать вместе", "--stdio and --listen cannot be used together") } + // Existing MCP configurations launch us with a pipe on stdin. Keep their + // direct stdio behavior while terminal launches use the local server. + if !*listen && !isTerminal(input) { + *stdio = true + } + directory := strings.TrimSpace(*project) if directory != "" { absolute, err := filepath.Abs(directory) @@ -90,10 +107,20 @@ func mcpCommand( } directory = absolute } + if !*stdio { + root, err := mcpProjectRoot(ctx, directory) + if err != nil { + return err + } + directory = root + } server, err := newMCPServer(ctx, newLearnerUseCases(client, directory), errorOutput) if err != nil { return err } + if !*stdio { + return serveLocalMCP(ctx, server, directory, errorOutput) + } interactive := isTerminal(input) if interactive { printMCPBanner(ctx, client, directory, errorOutput) @@ -124,9 +151,9 @@ func isTerminal(input io.Reader) bool { 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", + "Эту команду запускает агент, а не человек. Подключи сервер к агенту из любой папки:\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", + "An agent starts this command, not a person. Register the server from any folder:\n\n", ), cliVersion) repository, projectErr := inspectGitRepository(ctx, directory, false) if projectErr == nil { @@ -138,8 +165,8 @@ func printMCPBanner(ctx context.Context, client *learnercli.Client, directory st } 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) + fmt.Fprintln(output, " Claude Code: claude mcp add --transport stdio --scope local softpractice -- softpractice mcp --stdio") + fmt.Fprintf(output, " Codex CLI: codex mcp add softpractice -- softpractice mcp --stdio --project %s\n\n", projectPath) if projectErr == nil { fmt.Fprintf(output, text(ctx, "Проект: %s\n", "Project: %s\n"), repository.Root) } else { diff --git a/cmd/softpractice/mcp_local.go b/cmd/softpractice/mcp_local.go new file mode 100644 index 0000000..780fded --- /dev/null +++ b/cmd/softpractice/mcp_local.go @@ -0,0 +1,188 @@ +package main + +import ( + "bufio" + "context" + "crypto/hmac" + "crypto/rand" + "encoding/hex" + "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) + if ctx.Err() != nil { + return nil + } + return err +} + +func acceptLocalMCP(ctx context.Context, listener net.Listener, server *mcp.Server, token string) 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 || !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) + } + path, err := localMCPTokenPath() + if err != nil { + return unavailable(err) + } + token, err := os.ReadFile(path) + if err != nil { + return unavailable(err) + } + if len(token) != 64 { + return unavailable(errors.New("invalid local MCP token")) + } + connection, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, "tcp4", localMCPAddress) + if err != nil { + return unavailable(err) + } + defer connection.Close() + stop := context.AfterFunc(ctx, func() { _ = connection.Close() }) + defer stop() + _ = connection.SetDeadline(time.Now().Add(5 * time.Second)) + if _, err := fmt.Fprintf(connection, "%s\n", token); err != nil { + return unavailable(err) + } + 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)) + } +} + +func mcpSetupArgs(root string) []string { + if root == "" { + return []string{"mcp", "connect"} + } + return []string{"mcp", "--stdio", "--project", root} +} + +func printMCPSetupTarget(ctx context.Context, executable, root string, output io.Writer) { + fmt.Fprintf(output, text(ctx, " Программа: %s\n", " Program: %s\n"), executable) + if root != "" { + fmt.Fprintf(output, text(ctx, " Проект: %s\n", " Project: %s\n"), root) + } +} + +func printMCPSetupNextStep(ctx context.Context, root string, output io.Writer) { + if root != "" { + fmt.Fprintln(output, text(ctx, "Клиент сам запускает сервер для указанного проекта. Чтобы перейти на ручной запуск, повтори setup без --project.", "The client starts the server for the specified project. To switch to manual startup, rerun setup without --project.")) + return + } + 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_local_test.go b/cmd/softpractice/mcp_local_test.go new file mode 100644 index 0000000..542cea2 --- /dev/null +++ b/cmd/softpractice/mcp_local_test.go @@ -0,0 +1,163 @@ +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{"--listen", "--project", root} + if attempt == 0 { + t.Chdir(root) + args = []string{"--listen"} + } 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()) + } + // Starting another project cannot replace the active server's secret. + if err := mcpCommand(ctx, nil, []string{"--listen", "--project", root}, 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\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) + } + } +} + +func TestLocalMCPRejectsMissingProjectAndExplainsOfflineServer(t *testing.T) { + t.Setenv(configDirectoryEnv, t.TempDir()) + var output bytes.Buffer + err := mcpCommand(context.Background(), nil, []string{"--listen", "--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()) + } +} diff --git a/cmd/softpractice/mcp_setup.go b/cmd/softpractice/mcp_setup.go index 9bd3843..f53045a 100644 --- a/cmd/softpractice/mcp_setup.go +++ b/cmd/softpractice/mcp_setup.go @@ -31,7 +31,7 @@ func mcpSetupCommand(ctx context.Context, args []string, output, errorOutput io. } flags := flag.NewFlagSet("mcp setup "+args[0], flag.ContinueOnError) flags.SetOutput(errorOutput) - project := flags.String("project", "", "lesson project folder; defaults to the current directory") + project := flags.String("project", "", "optional lesson project folder for direct stdio mode") printOnly := flags.Bool("print", false, "print the configuration entry instead of writing it") if err := parseFlags(flags, args[1:]); err != nil { return err @@ -39,9 +39,13 @@ 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 + root := "" + if strings.TrimSpace(*project) != "" { + var err error + root, err = mcpProjectRoot(ctx, strings.TrimSpace(*project)) + if err != nil { + return err + } } executable, err := mcpSetupExecutable(ctx) if err != nil { @@ -53,7 +57,7 @@ func mcpSetupCommand(ctx context.Context, args []string, output, errorOutput io. entry, err := json.Marshal(struct { Command string `json:"command"` Args []string `json:"args"` - }{executable, []string{"mcp", "--project", root}}) + }{executable, mcpSetupArgs(root)}) if err != nil { return err } @@ -78,7 +82,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, root, output) if backup != "" { fmt.Fprintf(output, text(ctx, "Прежний файл сохранён: %s\n", "The previous file is saved as %s\n"), backup) } @@ -87,13 +91,11 @@ 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, root, output) return nil } -func mcpSetupProjectRoot(ctx context.Context, directory string) (string, error) { +func mcpProjectRoot(ctx context.Context, directory string) (string, error) { if directory != "" { absolute, err := filepath.Abs(directory) if err != nil { @@ -107,8 +109,8 @@ func mcpSetupProjectRoot(ctx context.Context, directory string) (string, error) } if err != nil { return "", fmt.Errorf(text(ctx, - "запусти команду в папке проекта урока или укажи --project: %w", - "run the command in the lesson project folder or pass --project: %w"), err) + "запусти сервер в папке проекта урока или укажи --project: %w", + "start the server in the lesson project folder or pass --project: %w"), err) } return repository.Root, nil } diff --git a/cmd/softpractice/mcp_setup_codex.go b/cmd/softpractice/mcp_setup_codex.go index 934d979..34a7bf5 100644 --- a/cmd/softpractice/mcp_setup_codex.go +++ b/cmd/softpractice/mcp_setup_codex.go @@ -31,7 +31,7 @@ func codexConfigPath() (string, error) { func codexServerEntry(executable, root string) map[string]any { return map[string]any{ "command": executable, - "args": []string{"mcp", "--project", root}, + "args": mcpSetupArgs(root), "tool_timeout_sec": int64(180), "enabled": true, } @@ -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, root, 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, root, output) return nil } diff --git a/cmd/softpractice/mcp_setup_codex_test.go b/cmd/softpractice/mcp_setup_codex_test.go index eff2a15..59074d2 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" @@ -44,7 +45,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", "--stdio", "--project", `/work/lesson "one"`}) || server["url"] != nil || server["env"].(map[string]any)["SOFTPRACTICE_LANGUAGE"] != "en" || !reflect.DeepEqual(server["disabled_tools"], []any{"submit"}) { t.Fatalf("server = %#v", server) @@ -66,7 +67,7 @@ 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"}) { + if !reflect.DeepEqual(server["args"], []any{"mcp", "--stdio", "--project", "/work/second"}) { t.Fatalf("switched args = %#v", server["args"]) } info, err := os.Stat(path) @@ -140,7 +141,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", "--stdio", "--project", root}) { t.Fatalf("entry = %#v", server) } path, err := codexConfigPath() @@ -157,6 +158,63 @@ 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) + } + } // Comments directly above a table belong to it: replacing the server table diff --git a/cmd/softpractice/ui.go b/cmd/softpractice/ui.go index 8eac4ef..d7e2d26 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] [--stdio|--listen]\n softpractice mcp connect\n softpractice mcp setup [--project DIR] [--print]\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\n--stdio сохраняет прямой режим: сервер запускает сам клиент, общение идёт через stdin/stdout. setup --project DIR настраивает этот режим для указанного проекта. При stdin через pipe прямой режим сохраняется автоматически, поэтому прежние настройки клиентов работают. --listen включает локальное прослушивание даже при перенаправленном stdin. --stdio и --listen нельзя использовать вместе. После обновления повтори setup без --project для ручного запуска.\n\nИнструменты: status, task, material, hints, check, submit, result, submissions, update. submit и update просят подтверждения человека. Рекомендации рецензента и текст его вопросов сервер не отдаёт. Язык сообщений: --lang, SOFTPRACTICE_LANGUAGE или set lang; по умолчанию русский.", "Usage: softpractice mcp [--project DIR] [--stdio|--listen]\n softpractice mcp connect\n softpractice mcp setup [--project DIR] [--print]\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\n--stdio keeps direct mode: the client starts the server and communicates over stdin/stdout. setup --project DIR registers this mode for the specified project. Piped stdin automatically retains direct mode, so existing client configurations work. --listen forces local listening with redirected stdin. --stdio and --listen cannot be used together. After updating, rerun setup without --project for manual startup.\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..d24e11f 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. Clients can also start a +direct server with `--stdio`. + ## 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..559cc1b 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`. Для смены +проекта останови сервер, запусти его в другой папке и переподключи клиент; +повторять настройку не нужно. Прямой запуск сервером клиента доступен через +`--stdio`. + ## Что можно попросить у агента Напиши просьбу в чате 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` в терминале на том же компьютере и повтори вызов инструмента. | From 7097d733b64bf17f92ec6c0270703735371a1a3f Mon Sep 17 00:00:00 2001 From: Arthur Sultanbekov Date: Sat, 3 Oct 2026 14:26:04 +0500 Subject: [PATCH 2/3] Make MCP startup use only the local single-lesson server --- README.md | 22 +---- README_RU.md | 23 +---- cmd/softpractice/mcp.go | 112 +++++------------------ cmd/softpractice/mcp_local.go | 24 ----- cmd/softpractice/mcp_local_test.go | 29 +++++- cmd/softpractice/mcp_setup.go | 53 ++++------- cmd/softpractice/mcp_setup_codex.go | 12 +-- cmd/softpractice/mcp_setup_codex_test.go | 30 +++--- cmd/softpractice/mcp_setup_test.go | 4 +- cmd/softpractice/mcp_test.go | 79 ++-------------- cmd/softpractice/ui.go | 2 +- docs/mcp.md | 4 +- docs/mcp_ru.md | 4 +- go.mod | 1 - go.sum | 2 - 15 files changed, 107 insertions(+), 294 deletions(-) diff --git a/README.md b/README.md index f7982a0..df91360 100644 --- a/README.md +++ b/README.md @@ -222,25 +222,9 @@ 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). -### Direct stdio mode and existing configurations - -Launches with piped stdin retain direct stdio behavior, so existing client -configurations keep working. You can select that mode explicitly with `--stdio`: - -```bash -claude mcp add --transport stdio --scope local softpractice -- softpractice mcp --stdio -codex mcp add softpractice -- softpractice mcp --stdio --project /path/to/your/lesson-project -``` - -In this mode, the client communicates directly over stdin/stdout and no -separate terminal server is needed. The current directory selects the project -unless `--project` is given. Desktop setup with `--project DIR` also registers -this direct mode and pins that project. - -After updating from the previous CLI version, rerun desktop setup without -`--project` to use manual server startup. For background or redirected terminal -launches, use `softpractice mcp --listen [--project DIR]` to force local listening. -`--stdio` and `--listen` cannot be used together. +`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`. ### Using the server diff --git a/README_RU.md b/README_RU.md index 5ae3f52..efb349c 100644 --- a/README_RU.md +++ b/README_RU.md @@ -224,25 +224,10 @@ codex mcp add softpractice -- softpractice mcp connect Подробнее — в [документации Claude Code](https://code.claude.com/docs/en/mcp) и [документации Codex](https://developers.openai.com/codex/mcp). -### Прямой stdio-режим и прежние настройки - -При запуске с stdin через pipe сохраняется прямой stdio-режим, поэтому прежние -настройки клиентов продолжают работать. Его можно выбрать явно через `--stdio`: - -```bash -claude mcp add --transport stdio --scope local softpractice -- softpractice mcp --stdio -codex mcp add softpractice -- softpractice mcp --stdio --project /path/to/your/lesson-project -``` - -В этом режиме клиент общается с сервером через stdin/stdout; отдельный -терминал с сервером не нужен. Проект определяется рабочей папкой клиента -или флагом `--project`. Desktop-настройка через `setup --project DIR` также -сохраняет прямой режим с привязкой к указанному проекту. - -После обновления прежней версии CLI повтори Desktop-настройку без -`--project`, чтобы перейти на ручной запуск сервера. Для фонового запуска -или перенаправленного stdin укажи `softpractice mcp --listen [--project DIR]`. -Флаги `--stdio` и `--listen` нельзя использовать вместе. +`softpractice mcp` всегда запускает локальный сервер, в том числе при +перенаправленном stdin и фоновом запуске. Проект выбирается только при запуске +сервера. `setup` настраивает подключение к единственному работающему серверу +и не принимает `--project`. ### Использование сервера diff --git a/cmd/softpractice/mcp.go b/cmd/softpractice/mcp.go index ae1bcc3..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" ) @@ -79,23 +78,12 @@ func mcpCommand( flags := flag.NewFlagSet("mcp", flag.ContinueOnError) flags.SetOutput(errorOutput) project := flags.String("project", "", "project folder; defaults to the current directory") - stdio := flags.Bool("stdio", false, "serve directly over stdin/stdout instead of listening locally") - listen := flags.Bool("listen", false, "listen locally even when stdin is not a terminal") if err := parseFlags(flags, args); err != nil { return err } if flags.NArg() != 0 { - return usage(ctx, "использование: softpractice mcp [--project DIR] [--stdio|--listen]", "usage: softpractice mcp [--project DIR] [--stdio|--listen]") + return usage(ctx, "использование: softpractice mcp [--project DIR]", "usage: softpractice mcp [--project DIR]") } - if *stdio && *listen { - return usage(ctx, "--stdio и --listen нельзя использовать вместе", "--stdio and --listen cannot be used together") - } - // Existing MCP configurations launch us with a pipe on stdin. Keep their - // direct stdio behavior while terminal launches use the local server. - if !*listen && !isTerminal(input) { - *stdio = true - } - directory := strings.TrimSpace(*project) if directory != "" { absolute, err := filepath.Abs(directory) @@ -107,90 +95,38 @@ func mcpCommand( } directory = absolute } - if !*stdio { - root, err := mcpProjectRoot(ctx, directory) - if err != nil { - return err - } - directory = root + root, err := mcpProjectRoot(ctx, directory) + if err != nil { + return err } - server, err := newMCPServer(ctx, newLearnerUseCases(client, directory), errorOutput) + + server, err := newMCPServer(ctx, newLearnerUseCases(client, root), errorOutput) if err != nil { return err } - if !*stdio { - return serveLocalMCP(ctx, server, directory, errorOutput) - } - 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 any 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 --stdio") - fmt.Fprintf(output, " Codex CLI: codex mcp add softpractice -- softpractice mcp --stdio --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() + 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 + } + repository, err := inspectGitRepository(ctx, directory, false) + if err == nil { + _, err = learnercli.LoadProjectLink(repository.Root) } - 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) + if err != nil { + return "", fmt.Errorf(text(ctx, + "запусти сервер в папке проекта урока или укажи --project: %w", + "start the server in the lesson project folder or pass --project: %w"), err) } - fmt.Fprintln(output, text(ctx, "Подробнее: softpractice help mcp. Остановить: Ctrl+C.", "More: softpractice help mcp. Stop: Ctrl+C.")) + 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 index 780fded..3194fe9 100644 --- a/cmd/softpractice/mcp_local.go +++ b/cmd/softpractice/mcp_local.go @@ -162,27 +162,3 @@ func connectLocalMCP(ctx context.Context, input io.Reader, output io.Writer) err return unavailable(errors.Join(err, io.EOF)) } } - -func mcpSetupArgs(root string) []string { - if root == "" { - return []string{"mcp", "connect"} - } - return []string{"mcp", "--stdio", "--project", root} -} - -func printMCPSetupTarget(ctx context.Context, executable, root string, output io.Writer) { - fmt.Fprintf(output, text(ctx, " Программа: %s\n", " Program: %s\n"), executable) - if root != "" { - fmt.Fprintf(output, text(ctx, " Проект: %s\n", " Project: %s\n"), root) - } -} - -func printMCPSetupNextStep(ctx context.Context, root string, output io.Writer) { - if root != "" { - fmt.Fprintln(output, text(ctx, "Клиент сам запускает сервер для указанного проекта. Чтобы перейти на ручной запуск, повтори setup без --project.", "The client starts the server for the specified project. To switch to manual startup, rerun setup without --project.")) - return - } - 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_local_test.go b/cmd/softpractice/mcp_local_test.go index 542cea2..3de0952 100644 --- a/cmd/softpractice/mcp_local_test.go +++ b/cmd/softpractice/mcp_local_test.go @@ -29,10 +29,10 @@ func TestLocalMCPBridgeAndProjectSwitch(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) var banner lockedBuffer done := make(chan error, 1) - args := []string{"--listen", "--project", root} + args := []string{"--project", root} if attempt == 0 { t.Chdir(root) - args = []string{"--listen"} + args = nil } else { t.Chdir(t.TempDir()) } @@ -69,7 +69,7 @@ func TestLocalMCPBridgeAndProjectSwitch(t *testing.T) { t.Fatalf("project missing: %s", banner.String()) } // Starting another project cannot replace the active server's secret. - if err := mcpCommand(ctx, nil, []string{"--listen", "--project", root}, strings.NewReader(""), io.Discard, io.Discard); err == nil { + 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") } @@ -152,7 +152,7 @@ func TestLocalMCPBridgeAndProjectSwitch(t *testing.T) { func TestLocalMCPRejectsMissingProjectAndExplainsOfflineServer(t *testing.T) { t.Setenv(configDirectoryEnv, t.TempDir()) var output bytes.Buffer - err := mcpCommand(context.Background(), nil, []string{"--listen", "--project", t.TempDir()}, strings.NewReader(""), &output, io.Discard) + 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()) } @@ -161,3 +161,24 @@ func TestLocalMCPRejectsMissingProjectAndExplainsOfflineServer(t *testing.T) { 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_setup.go b/cmd/softpractice/mcp_setup.go index f53045a..2546469 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", "", "optional lesson project folder for direct stdio mode") printOnly := flags.Bool("print", false, "print the configuration entry instead of writing it") if err := parseFlags(flags, args[1:]); err != nil { return err @@ -39,25 +36,17 @@ func mcpSetupCommand(ctx context.Context, args []string, output, errorOutput io. if flags.NArg() != 0 { return usageText() } - root := "" - if strings.TrimSpace(*project) != "" { - var err error - root, err = mcpProjectRoot(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, mcpSetupArgs(root)}) + }{executable, []string{"mcp", "connect"}}) if err != nil { return err } @@ -82,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) } - printMCPSetupTarget(ctx, executable, root, output) + printMCPSetupTarget(ctx, executable, output) if backup != "" { fmt.Fprintf(output, text(ctx, "Прежний файл сохранён: %s\n", "The previous file is saved as %s\n"), backup) } @@ -91,30 +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.")) } - printMCPSetupNextStep(ctx, root, output) + printMCPSetupNextStep(ctx, output) return nil } -func mcpProjectRoot(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", - "start the server 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) { @@ -317,3 +286,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 34a7bf5..77b750b 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": mcpSetupArgs(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) } - printMCPSetupTarget(ctx, executable, root, output) + printMCPSetupTarget(ctx, executable, output) if backup != "" { fmt.Fprintf(output, text(ctx, "Прежний файл сохранён: %s\n", "The previous file is saved as %s\n"), backup) } @@ -69,7 +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.")) } - printMCPSetupNextStep(ctx, root, output) + printMCPSetupNextStep(ctx, output) return nil } diff --git a/cmd/softpractice/mcp_setup_codex_test.go b/cmd/softpractice/mcp_setup_codex_test.go index 59074d2..4d489a5 100644 --- a/cmd/softpractice/mcp_setup_codex_test.go +++ b/cmd/softpractice/mcp_setup_codex_test.go @@ -12,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) @@ -45,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", "--stdio", "--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) @@ -58,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) } @@ -67,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", "--stdio", "--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) { @@ -78,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) } @@ -100,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) @@ -116,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" @@ -128,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) @@ -141,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", "--stdio", "--project", root}) { + if !filepath.IsAbs(server["command"].(string)) || !reflect.DeepEqual(server["args"], []any{"mcp", "connect"}) { t.Fatalf("entry = %#v", server) } path, err := codexConfigPath() @@ -226,7 +220,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) @@ -243,7 +237,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_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 d7e2d26..02c18ee 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] [--stdio|--listen]\n softpractice mcp connect\n softpractice mcp setup [--project DIR] [--print]\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\n--stdio сохраняет прямой режим: сервер запускает сам клиент, общение идёт через stdin/stdout. setup --project DIR настраивает этот режим для указанного проекта. При stdin через pipe прямой режим сохраняется автоматически, поэтому прежние настройки клиентов работают. --listen включает локальное прослушивание даже при перенаправленном stdin. --stdio и --listen нельзя использовать вместе. После обновления повтори setup без --project для ручного запуска.\n\nИнструменты: status, task, material, hints, check, submit, result, submissions, update. submit и update просят подтверждения человека. Рекомендации рецензента и текст его вопросов сервер не отдаёт. Язык сообщений: --lang, SOFTPRACTICE_LANGUAGE или set lang; по умолчанию русский.", "Usage: softpractice mcp [--project DIR] [--stdio|--listen]\n softpractice mcp connect\n softpractice mcp setup [--project DIR] [--print]\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\n--stdio keeps direct mode: the client starts the server and communicates over stdin/stdout. setup --project DIR registers this mode for the specified project. Piped stdin automatically retains direct mode, so existing client configurations work. --listen forces local listening with redirected stdin. --stdio and --listen cannot be used together. After updating, rerun setup without --project for manual startup.\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." + russian, english = "Использование: softpractice mcp [--project DIR]\n softpractice mcp connect\n softpractice mcp setup [--print]\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Инструменты: 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 setup [--print]\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\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 d24e11f..6c67104 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -10,8 +10,8 @@ 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. Clients can also start a -direct server with `--stdio`. +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 diff --git a/docs/mcp_ru.md b/docs/mcp_ru.md index 559cc1b..c40bac1 100644 --- a/docs/mcp_ru.md +++ b/docs/mcp_ru.md @@ -10,8 +10,8 @@ `softpractice mcp` в папке урока или укажи `--project DIR` и оставь терминал открытым. Клиент подключается через `softpractice mcp connect`. Для смены проекта останови сервер, запусти его в другой папке и переподключи клиент; -повторять настройку не нужно. Прямой запуск сервером клиента доступен через -`--stdio`. +повторять настройку не нужно. Одновременно работает один сервер урока. +Команда запуска всегда использует локальное TCP-соединение, независимо от stdin. ## Что можно попросить у агента 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= From df0b3e9de14ada945ebd81e424197661bcea7a33 Mon Sep 17 00:00:00 2001 From: Arthur Sultanbekov Date: Sat, 3 Oct 2026 14:40:42 +0500 Subject: [PATCH 3/3] Add MCP server status and client connection removal --- README.md | 40 +++++ README_RU.md | 40 +++++ cmd/softpractice/main.go | 6 + cmd/softpractice/mcp_local.go | 61 +++++--- cmd/softpractice/mcp_local_test.go | 27 +++- cmd/softpractice/mcp_remove.go | 47 ++++++ cmd/softpractice/mcp_remove_test.go | 184 +++++++++++++++++++++++ cmd/softpractice/mcp_setup.go | 34 ++++- cmd/softpractice/mcp_setup_codex.go | 45 +++++- cmd/softpractice/mcp_setup_codex_test.go | 16 ++ cmd/softpractice/mcp_status.go | 78 ++++++++++ cmd/softpractice/mcp_status_test.go | 45 ++++++ cmd/softpractice/ui.go | 2 +- 13 files changed, 591 insertions(+), 34 deletions(-) create mode 100644 cmd/softpractice/mcp_remove.go create mode 100644 cmd/softpractice/mcp_remove_test.go create mode 100644 cmd/softpractice/mcp_status.go create mode 100644 cmd/softpractice/mcp_status_test.go diff --git a/README.md b/README.md index df91360..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. @@ -226,6 +228,44 @@ and [Codex MCP documentation](https://developers.openai.com/codex/mcp). 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`. +### Checking the server + +From any folder, check which server is running: + +```bash +softpractice mcp status +softpractice mcp status --json +``` + +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. + +### Stopping the server and removing the connection + +Press Ctrl+C in the server's terminal to stop the lesson server. The client +configuration remains available for the next startup. + +To remove the client connection, run from any folder: + +```bash +softpractice mcp remove claude-desktop +# or +softpractice mcp remove codex-desktop +``` + +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 The server exposes nine tools. Ask the agent in ordinary language; it chooses diff --git a/README_RU.md b/README_RU.md index efb349c..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 <команда>`. @@ -229,6 +231,44 @@ codex mcp add softpractice -- softpractice mcp connect сервера. `setup` настраивает подключение к единственному работающему серверу и не принимает `--project`. +### Проверка сервера + +Из любой папки проверь, какой сервер сейчас работает: + +```bash +softpractice mcp status +softpractice mcp status --json +``` + +Команда проверяет соединение с сервером и показывает папку урока, PID, +локальный адрес и версию CLI. Остановленный сервер возвращает состояние +`not_running`; оставшийся после сбоя файл ключа не считается признаком работы. +Для этой проверки не нужны проект урока и вход в аккаунт. Это статус +локального процесса; задание и результаты урока по-прежнему доступны через +`softpractice status` и инструмент MCP `status`. + +### Остановка и удаление подключения + +Чтобы остановить сервер урока, нажми Ctrl+C в терминале, где он запущен. +Настройка клиента сохраняется для следующего запуска. + +Чтобы убрать подключение из настроек клиента, выполни из любой папки: + +```bash +softpractice mcp remove claude-desktop +# или +softpractice mcp remove 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_local.go b/cmd/softpractice/mcp_local.go index 3194fe9..973e806 100644 --- a/cmd/softpractice/mcp_local.go +++ b/cmd/softpractice/mcp_local.go @@ -6,6 +6,7 @@ import ( "crypto/hmac" "crypto/rand" "encoding/hex" + "encoding/json" "errors" "fmt" "io" @@ -66,14 +67,14 @@ func serveLocalMCP(ctx context.Context, server *mcp.Server, project string, outp 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) + 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) error { +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() }) @@ -95,9 +96,18 @@ func acceptLocalMCP(ctx context.Context, listener net.Listener, server *mcp.Serv _ = connection.SetDeadline(time.Now().Add(5 * time.Second)) reader := bufio.NewReaderSize(connection, 4096) line, err := reader.ReadSlice('\n') - if err != nil || !hmac.Equal(line, []byte(token+"\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 } @@ -113,28 +123,13 @@ func connectLocalMCP(ctx context.Context, input io.Reader, output io.Writer) err "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) } - path, err := localMCPTokenPath() - if err != nil { - return unavailable(err) - } - token, err := os.ReadFile(path) - if err != nil { - return unavailable(err) - } - if len(token) != 64 { - return unavailable(errors.New("invalid local MCP token")) - } - connection, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, "tcp4", localMCPAddress) + connection, err := dialLocalMCP(ctx, "") if err != nil { return unavailable(err) } defer connection.Close() stop := context.AfterFunc(ctx, func() { _ = connection.Close() }) defer stop() - _ = connection.SetDeadline(time.Now().Add(5 * time.Second)) - if _, err := fmt.Fprintf(connection, "%s\n", token); err != nil { - return unavailable(err) - } reader := bufio.NewReader(connection) if ack, err := reader.ReadString('\n'); err != nil || ack != "OK\n" { if err == nil { @@ -162,3 +157,31 @@ func connectLocalMCP(ctx context.Context, input io.Reader, output io.Writer) err 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 index 3de0952..5002fdf 100644 --- a/cmd/softpractice/mcp_local_test.go +++ b/cmd/softpractice/mcp_local_test.go @@ -68,6 +68,17 @@ func TestLocalMCPBridgeAndProjectSwitch(t *testing.T) { 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() @@ -85,7 +96,7 @@ func TestLocalMCPBridgeAndProjectSwitch(t *testing.T) { t.Fatal(err) } _ = connection.SetDeadline(time.Now().Add(time.Second)) - _, _ = io.WriteString(connection, "wrong-secret\n") + _, _ = io.WriteString(connection, "wrong-secret status\n") response, _ := io.ReadAll(connection) _ = connection.Close() if len(response) != 0 { @@ -146,6 +157,20 @@ func TestLocalMCPBridgeAndProjectSwitch(t *testing.T) { 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) + } } 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 2546469..0b0aab9 100644 --- a/cmd/softpractice/mcp_setup.go +++ b/cmd/softpractice/mcp_setup.go @@ -58,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 } @@ -105,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 != "" { @@ -122,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) @@ -133,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() @@ -165,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 diff --git a/cmd/softpractice/mcp_setup_codex.go b/cmd/softpractice/mcp_setup_codex.go index 77b750b..7e152e0 100644 --- a/cmd/softpractice/mcp_setup_codex.go +++ b/cmd/softpractice/mcp_setup_codex.go @@ -114,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) } @@ -141,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) @@ -155,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) @@ -168,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 } @@ -179,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"} { @@ -191,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) @@ -216,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 4d489a5..1995e5b 100644 --- a/cmd/softpractice/mcp_setup_codex_test.go +++ b/cmd/softpractice/mcp_setup_codex_test.go @@ -209,6 +209,22 @@ func TestCodexDesktopSetupCommandPrintAndWrite(t *testing.T) { 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 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/ui.go b/cmd/softpractice/ui.go index 02c18ee..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 connect\n softpractice mcp setup [--print]\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Инструменты: 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 setup [--print]\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\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." + 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":