From 06157a5cd2ac580690fb36863c0d3034ef8f8997 Mon Sep 17 00:00:00 2001 From: Antoni Sobkowicz Date: Tue, 8 Sep 2026 08:59:25 +0200 Subject: [PATCH 1/3] feat(installer): add Claude Desktop and OpenCode client support - Detects Claude Desktop via its app bundle (not a CLI command) and writes ~/Library/Application Support/Claude/claude_desktop_config.json. - Detects and configures OpenCode via ~/.config/opencode/opencode.json, which uses a distinct entry shape (type: "local", command as a single array combining executable and args). - Extracted the read-merge-write JSON logic shared by Cursor, Claude Desktop, and OpenCode into one write_mcp_json_config helper parameterized by config path, servers key, and entry shape, rather than duplicating it a third time. Cursor's own output is unchanged. - Extended legacy-package reference scanning to include both new config files. - Documented that ChatGPT Desktop shares Codex CLI's config.toml on the same host, so --codex already covers it. Also changes the default (no-flag) and --all behavior: auto-detect and configure every client found, silently skipping ones that aren't, without prompting. Explicit single-client flags (--claude, etc.) keep today's behavior of dying if that specific client isn't installed. Retires the interactive per-client Y/n prompt this replaces. --- install.sh | 176 +++++++++++++++++++++++++++++----------- tests/installer.test.ts | 104 ++++++++++++++++++++++-- 2 files changed, 223 insertions(+), 57 deletions(-) diff --git a/install.sh b/install.sh index 1a0726e..c153a16 100755 --- a/install.sh +++ b/install.sh @@ -18,45 +18,72 @@ info() { usage() { cat <<'EOF' -Usage: install.sh [--all | --claude | --codex | --cursor | --no-config] +Usage: install.sh [--all | --claude | --claude-desktop | --codex | --cursor | --opencode | --no-config] [--delete-old-packages | --keep-old-packages] Installs or updates Asana Command MCP and configures detected MCP clients. -With no flags, the installer prompts for each detected client. +With no flags (same as --all), every detected client is configured +automatically and undetected ones are skipped without prompting. Passing an +individual client flag instead requires exactly that client to be installed. + +ChatGPT Desktop shares Codex CLI's configuration (~/.codex/config.toml) on +the same host, so --codex also covers it when the codex command is installed. EOF } want_claude=false +want_claude_desktop=false want_codex=false want_cursor=false +want_opencode=false selection_explicit=false +auto_select=true old_package_action=prompt while [ "$#" -gt 0 ]; do case "$1" in --all) want_claude=true + want_claude_desktop=true want_codex=true want_cursor=true + want_opencode=true selection_explicit=true + auto_select=true ;; --claude) want_claude=true selection_explicit=true + auto_select=false + ;; + --claude-desktop) + want_claude_desktop=true + selection_explicit=true + auto_select=false ;; --codex) want_codex=true selection_explicit=true + auto_select=false ;; --cursor) want_cursor=true selection_explicit=true + auto_select=false + ;; + --opencode) + want_opencode=true + selection_explicit=true + auto_select=false ;; --no-config) want_claude=false + want_claude_desktop=false want_codex=false want_cursor=false + want_opencode=false selection_explicit=true + auto_select=false ;; --delete-old-packages) old_package_action=delete @@ -183,13 +210,19 @@ fi mv "$archive_path" "$install_dir/$ARCHIVE_NAME" has_claude=false +has_claude_desktop=false has_codex=false has_cursor=false +has_opencode=false command -v claude >/dev/null 2>&1 && has_claude=true +claude_desktop_config="$HOME/Library/Application Support/Claude/claude_desktop_config.json" +claude_desktop_app_path="${ASANA_COMMAND_MCP_CLAUDE_DESKTOP_APP_PATH:-/Applications/Claude.app}" +[ -d "$claude_desktop_app_path" ] && has_claude_desktop=true command -v codex >/dev/null 2>&1 && has_codex=true if command -v cursor >/dev/null 2>&1 || command -v agent >/dev/null 2>&1; then has_cursor=true fi +command -v opencode >/dev/null 2>&1 && has_opencode=true snapshot_codex_config() { output_path="$1" @@ -220,10 +253,10 @@ if (!outputPath || !codexConfigPath || !installDir || !serverName) { } const entries = []; -function readJsonEntry(configPath) { +function readJsonEntry(configPath, serversKey = "mcpServers") { try { const config = JSON.parse(fs.readFileSync(configPath, "utf8")); - const entry = config?.mcpServers?.[serverName]; + const entry = config?.[serversKey]?.[serverName]; if (entry !== undefined) { entries.push(entry); } @@ -234,6 +267,10 @@ function readJsonEntry(configPath) { readJsonEntry(path.join(process.env.HOME, ".claude.json")); readJsonEntry(path.join(process.env.HOME, ".cursor", "mcp.json")); +readJsonEntry( + path.join(process.env.HOME, "Library", "Application Support", "Claude", "claude_desktop_config.json"), +); +readJsonEntry(path.join(process.env.HOME, ".config", "opencode", "opencode.json"), "mcp"); try { const contents = fs.readFileSync(codexConfigPath, "utf8").trim(); if (contents !== "") { @@ -297,55 +334,75 @@ legacy_packages="$work_dir/legacy-packages" snapshot_codex_config "$codex_config_before" collect_package_references "$legacy_packages" "$codex_config_before" -prompt_target() { - client_name="$1" - if [ ! -t 1 ] || [ ! -r /dev/tty ]; then - return 0 - fi - printf 'Configure %s? [Y/n] ' "$client_name" >/dev/tty - answer='' - IFS= read -r answer /dev/null 2>&1 || true - claude mcp add --transport stdio --scope user "$SERVER_NAME" -- "$executable" - configured_clients="${configured_clients} Claude Code" + require_client "$has_claude" "Claude Code was selected but the claude command is not installed" + if [ "$has_claude" = true ]; then + claude mcp remove "$SERVER_NAME" --scope user >/dev/null 2>&1 || true + claude mcp add --transport stdio --scope user "$SERVER_NAME" -- "$executable" + configured_clients="${configured_clients} Claude Code" + fi fi if [ "$want_codex" = true ]; then - [ "$has_codex" = true ] || die "Codex was selected but the codex command is not installed" - codex mcp remove "$SERVER_NAME" >/dev/null 2>&1 || true - codex mcp add "$SERVER_NAME" -- "$executable" - configured_clients="${configured_clients} Codex" + require_client "$has_codex" "Codex was selected but the codex command is not installed" + if [ "$has_codex" = true ]; then + codex mcp remove "$SERVER_NAME" >/dev/null 2>&1 || true + codex mcp add "$SERVER_NAME" -- "$executable" + configured_clients="${configured_clients} Codex" + fi fi -if [ "$want_cursor" = true ]; then - [ "$has_cursor" = true ] || die "Cursor was selected but neither cursor nor agent is installed" - cursor_config="$HOME/.cursor/mcp.json" - MCP_CONFIG_PATH="$cursor_config" MCP_EXECUTABLE="$executable" MCP_SERVER_NAME="$SERVER_NAME" \ +write_mcp_json_config() { + config_path="$1" + servers_key="$2" + entry_style="$3" + MCP_CONFIG_PATH="$config_path" \ + MCP_SERVERS_KEY="$servers_key" \ + MCP_ENTRY_STYLE="$entry_style" \ + MCP_EXECUTABLE="$executable" \ + MCP_SERVER_NAME="$SERVER_NAME" \ node <<'NODE' const fs = require("node:fs"); const path = require("node:path"); const configPath = process.env.MCP_CONFIG_PATH; +const serversKey = process.env.MCP_SERVERS_KEY; +const entryStyle = process.env.MCP_ENTRY_STYLE; const executable = process.env.MCP_EXECUTABLE; const serverName = process.env.MCP_SERVER_NAME; -if (!configPath || !executable || !serverName) { - throw new Error("missing Cursor configuration input"); +if (!configPath || !serversKey || !entryStyle || !executable || !serverName) { + throw new Error("missing MCP configuration input"); +} + +const entriesByStyle = { + // Cursor documents an explicit "type" field on each server entry. + stdio: { type: "stdio", command: executable, args: [] }, + // Claude Desktop infers stdio from the presence of "command". + plain: { command: executable, args: [] }, + // OpenCode combines the executable and its arguments into one "command" array. + "opencode-local": { type: "local", command: [executable], enabled: true }, +}; +const entry = entriesByStyle[entryStyle]; +if (entry === undefined) { + throw new Error(`unknown MCP entry style: ${entryStyle}`); } let config = {}; @@ -359,20 +416,16 @@ if (config === null || Array.isArray(config) || typeof config !== "object") { throw new Error(`${configPath} must contain a JSON object`); } if ( - config.mcpServers !== undefined && - (config.mcpServers === null || - Array.isArray(config.mcpServers) || - typeof config.mcpServers !== "object") + config[serversKey] !== undefined && + (config[serversKey] === null || + Array.isArray(config[serversKey]) || + typeof config[serversKey] !== "object") ) { - throw new Error(`${configPath}.mcpServers must be a JSON object`); + throw new Error(`${configPath}.${serversKey} must be a JSON object`); } -config.mcpServers ??= {}; -config.mcpServers[serverName] = { - type: "stdio", - command: executable, - args: [], -}; +config[serversKey] ??= {}; +config[serversKey][serverName] = entry; fs.mkdirSync(path.dirname(configPath), { recursive: true }); const temporaryPath = `${configPath}.tmp-${process.pid}-${Date.now()}`; @@ -383,7 +436,31 @@ fs.writeFileSync(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, { }); fs.renameSync(temporaryPath, configPath); NODE - configured_clients="${configured_clients} Cursor" +} + +if [ "$want_cursor" = true ]; then + require_client "$has_cursor" "Cursor was selected but neither cursor nor agent is installed" + if [ "$has_cursor" = true ]; then + write_mcp_json_config "$HOME/.cursor/mcp.json" "mcpServers" "stdio" + configured_clients="${configured_clients} Cursor" + fi +fi + +if [ "$want_claude_desktop" = true ]; then + require_client "$has_claude_desktop" \ + "Claude Desktop was selected but the application is not installed" + if [ "$has_claude_desktop" = true ]; then + write_mcp_json_config "$claude_desktop_config" "mcpServers" "plain" + configured_clients="${configured_clients} Claude Desktop" + fi +fi + +if [ "$want_opencode" = true ]; then + require_client "$has_opencode" "OpenCode was selected but the opencode command is not installed" + if [ "$has_opencode" = true ]; then + write_mcp_json_config "$HOME/.config/opencode/opencode.json" "mcp" "opencode-local" + configured_clients="${configured_clients} OpenCode" + fi fi codex_config_after="$work_dir/codex-after.json" @@ -429,8 +506,9 @@ info "Asana Command MCP is installed at:" info " $executable" if [ -n "$configured_clients" ]; then info "Configured:${configured_clients}" -elif [ "$has_claude" = false ] && [ "$has_codex" = false ] && [ "$has_cursor" = false ]; then - info "No supported MCP client commands were detected; the server was installed without client configuration." +elif [ "$has_claude" = false ] && [ "$has_claude_desktop" = false ] && [ "$has_codex" = false ] && + [ "$has_cursor" = false ] && [ "$has_opencode" = false ]; then + info "No supported MCP clients were detected; the server was installed without client configuration." else info "No MCP clients were configured." fi diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 1845897..84fcf8d 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -17,7 +17,7 @@ import { afterEach, describe, expect, it } from "vitest"; const INSTALLER_PATH = resolve(import.meta.dirname, "../install.sh"); const temporaryDirectories: string[] = []; -type Client = "claude" | "codex" | "cursor" | "agent"; +type Client = "claude" | "codex" | "cursor" | "agent" | "opencode"; type Downloader = "curl" | "wget"; function temporaryDirectory(name: string): string { @@ -218,6 +218,7 @@ function runInstaller(options: { downloader?: Downloader; clients?: Client[]; includeNpm?: boolean; + claudeDesktopInstalled?: boolean; }) { const home = join(options.root, "home with spaces"); const assets = join(options.root, "assets"); @@ -234,6 +235,13 @@ function runInstaller(options: { ...(options.includeNpm === undefined ? {} : { includeNpm: options.includeNpm }), }); + // Claude Desktop is detected by an app-bundle directory, not a PATH-resolvable command, and + // must never fall back to the real /Applications/Claude.app on the machine running these tests. + const claudeDesktopAppPath = join(options.root, "fake-claude-desktop-app"); + if (options.claudeDesktopInstalled === true) { + mkdirSync(claudeDesktopAppPath, { recursive: true }); + } + const result = spawnSync("/bin/sh", [INSTALLER_PATH, ...(options.args ?? [])], { cwd: options.root, encoding: "utf8", @@ -243,6 +251,7 @@ function runInstaller(options: { ASSET_DIR: assets, TEST_LOG: log, ASANA_COMMAND_MCP_RELEASE_BASE_URL: "https://release.invalid", + ASANA_COMMAND_MCP_CLAUDE_DESKTOP_APP_PATH: claudeDesktopAppPath, }, }); return { assets, home, log, result }; @@ -330,21 +339,65 @@ describe("install.sh", () => { expect(result.status, result.stderr).toBe(0); expect(existsSync(join(home, ".asana/mcp/bin/asana-command-mcp"))).toBe(true); - expect(result.stdout).toContain("No supported MCP client commands were detected"); + expect(result.stdout).toContain("No supported MCP clients were detected"); }); - it("configures all detected clients by default when non-interactive", () => { + it("configures every detected client by default and skips undetected ones without prompting", () => { const root = temporaryDirectory("command-installer-defaults"); - const { log, result } = runInstaller({ + const { home, log, result } = runInstaller({ root, clients: ["claude", "codex", "agent"], + claudeDesktopInstalled: true, }); expect(result.status, result.stderr).toBe(0); const clientCalls = readFileSync(join(log, "clients"), "utf8"); expect(clientCalls).toContain("claude "); expect(clientCalls).toContain("codex "); - expect(result.stdout).toContain("Configured: Claude Code Codex Cursor"); + expect(result.stdout).toContain("Configured: Claude Code Codex Cursor Claude Desktop"); + // opencode was never in the fake PATH, so it must be skipped silently, not fail the install. + expect(result.stdout).not.toContain("OpenCode"); + const claudeDesktopConfig = JSON.parse( + readFileSync( + join(home, "Library/Application Support/Claude/claude_desktop_config.json"), + "utf8", + ), + ); + expect(claudeDesktopConfig.mcpServers["asana-command"]).toEqual({ + command: join(home, ".asana/mcp/bin/asana-command-mcp"), + args: [], + }); + }); + + it("configures OpenCode when detected by default", () => { + const root = temporaryDirectory("command-installer-opencode"); + const { home, result } = runInstaller({ + root, + clients: ["opencode"], + }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("Configured: OpenCode"); + const opencodeConfig = JSON.parse( + readFileSync(join(home, ".config/opencode/opencode.json"), "utf8"), + ); + expect(opencodeConfig.mcp["asana-command"]).toEqual({ + type: "local", + command: [join(home, ".asana/mcp/bin/asana-command-mcp")], + enabled: true, + }); + }); + + it("rejects an explicitly selected Claude Desktop or OpenCode that is not installed", () => { + const root = temporaryDirectory("command-installer-missing-desktop-opencode"); + + const claudeDesktopResult = runInstaller({ root, args: ["--claude-desktop"] }); + expect(claudeDesktopResult.result.status).toBe(1); + expect(claudeDesktopResult.result.stderr).toContain("Claude Desktop was selected"); + + const opencodeResult = runInstaller({ root, args: ["--opencode"] }); + expect(opencodeResult.result.status).toBe(1); + expect(opencodeResult.result.stderr).toContain("OpenCode was selected"); }); it("deletes unreferenced manual packages outside the scripted install path", () => { @@ -358,7 +411,15 @@ describe("install.sh", () => { const claudePackage = join(downloads, "asana-command-mcp-0.1.0.tgz"); const codexPackage = join(downloads, "asana-command-mcp-0.1.1.tgz"); const cursorPackage = join(downloads, "asana-command-mcp-0.1.2.tgz"); - for (const packagePath of [claudePackage, codexPackage, cursorPackage]) { + const claudeDesktopPackage = join(downloads, "asana-command-mcp-0.1.3.tgz"); + const opencodePackage = join(downloads, "asana-command-mcp-0.1.4.tgz"); + for (const packagePath of [ + claudePackage, + codexPackage, + cursorPackage, + claudeDesktopPackage, + opencodePackage, + ]) { writeFileSync(packagePath, "old release"); } writeFileSync( @@ -393,18 +454,45 @@ describe("install.sh", () => { }, }), ); + mkdirSync(join(home, "Library/Application Support/Claude"), { recursive: true }); + writeFileSync( + join(home, "Library/Application Support/Claude/claude_desktop_config.json"), + JSON.stringify({ + mcpServers: { + "asana-command": { + command: "npx", + args: ["--yes", "--package", claudeDesktopPackage, "asana-command-mcp"], + }, + }, + }), + ); + mkdirSync(join(home, ".config/opencode"), { recursive: true }); + writeFileSync( + join(home, ".config/opencode/opencode.json"), + JSON.stringify({ + mcp: { + "asana-command": { + type: "local", + command: ["npx", "--yes", "--package", opencodePackage, "asana-command-mcp"], + }, + }, + }), + ); const { result } = runInstaller({ root, args: ["--all", "--delete-old-packages"], - clients: ["claude", "codex", "cursor"], + clients: ["claude", "codex", "cursor", "opencode"], + claudeDesktopInstalled: true, }); expect(result.status, result.stderr).toBe(0); expect(existsSync(claudePackage)).toBe(false); expect(existsSync(codexPackage)).toBe(false); expect(existsSync(cursorPackage)).toBe(false); - expect(result.stdout.match(/Deleted old package:/g)).toHaveLength(3); + expect(existsSync(claudeDesktopPackage)).toBe(false); + expect(existsSync(opencodePackage)).toBe(false); + expect(result.stdout.match(/Deleted old package:/g)).toHaveLength(5); }); it("keeps an old package while an unselected client still references it", () => { From 5a8bbe2298112a643ef9e8ac86ed591fece66c52 Mon Sep 17 00:00:00 2001 From: Antoni Sobkowicz Date: Tue, 8 Sep 2026 09:02:00 +0200 Subject: [PATCH 2/3] docs: document Claude Desktop, OpenCode, and the new auto-select default Covers install/uninstall/manual-configuration for both new clients, the ChatGPT-Desktop-via-Codex relationship, and the installer's new default behavior (auto-detect and configure everything found, no prompting, explicit single-client flags still fail loudly if missing). --- CONTRIBUTING.md | 7 +++-- README.md | 68 +++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 59 insertions(+), 16 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f6004b1..969fad7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,8 +38,11 @@ Include the command's complete, unfiltered output in the pull-request descriptio `npm run check` proves type checking, linting, non-integration tests, and the production build. It does not prove behavior against the live Asana API. Installer tests execute `install.sh` with an isolated home directory, local release fixtures, and -fake client commands. They must not read or modify the developer's real Claude, Codex, or Cursor -configuration. Also check the POSIX shell syntax directly when changing the installer: +fake client commands. They must not read or modify the developer's real Claude, Claude Desktop, +Codex, Cursor, or OpenCode configuration — Claude Desktop detection in particular must always go +through the `ASANA_COMMAND_MCP_CLAUDE_DESKTOP_APP_PATH` override rather than the real +`/Applications/Claude.app`, since that path may genuinely exist on the machine running the tests. +Also check the POSIX shell syntax directly when changing the installer: ```sh sh -n install.sh diff --git a/README.md b/README.md index 2f8aecf..13b171e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Asana Command MCP -`@asana/command-mcp` is a local [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for working with Asana Command tickets from Claude Code, Codex, or Cursor. +`@asana/command-mcp` is a local [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for working with Asana Command tickets from Claude Code, Claude Desktop, Codex, Cursor, or OpenCode. The server runs on your machine over stdio. By default, it authenticates with an Asana personal access token (PAT) stored in your operating system keychain. OAuth is also supported as a fallback. @@ -17,7 +17,7 @@ The server runs on your machine over stdio. By default, it authenticates with an - npm - macOS or Linux with `curl` or `wget` - An Asana account -- Claude Code, Codex, or Cursor +- Claude Code, Claude Desktop, Codex, Cursor, or OpenCode ## Install or update @@ -33,25 +33,31 @@ wget -qO- https://github.com/Asana/command-mcp/releases/latest/download/install. The installer: -- downloads the latest release and verifies its SHA-256 checksum; +- downloads the latest release and verifies its SHA-256 checksum, skipping reinstallation when + already up to date; - installs it under `~/.asana/mcp`; -- detects the `claude`, `codex`, `cursor`, and Cursor Agent (`agent`) commands; -- offers to configure each detected client as a user-level stdio MCP server. +- detects Claude Code (`claude`), Claude Desktop (its installed application), Codex (`codex`), + Cursor (`cursor` or Cursor Agent's `agent`), and OpenCode (`opencode`); +- automatically configures every detected client as a user-level stdio MCP server. Run the same command again to update the existing installation. The executable path remains `~/.asana/mcp/bin/asana-command-mcp`, so configured clients do not need a version-specific path. -The prompt defaults to configuring every detected client. For non-interactive use, select clients -explicitly: +ChatGPT Desktop shares Codex CLI's configuration (`~/.codex/config.toml`) on the same host, so +having the `codex` command installed also covers ChatGPT Desktop; there is no separate flag for it. + +With no flags (equivalent to `--all`), the installer configures every client it detects and +silently skips the rest — nothing is installed or configured for a client that isn't present, and +nothing prompts. To require one specific client and fail loudly if it's missing, select it +explicitly instead: ```sh curl -fsSL https://github.com/Asana/command-mcp/releases/latest/download/install.sh \ | sh -s -- --claude --codex --cursor ``` -Use `--all` to require all three clients, or `--no-config` to install without changing client -configuration. Selecting a client whose command is not installed causes the installer to stop with -an error. +Or use `--no-config` to install without changing any client configuration. Explicitly selecting a +client that isn't installed causes the installer to stop with an error; auto-detection never does. When replacing an existing `asana-command` client entry, the installer detects versioned `.tgz` packages referenced by the old configuration. If an old package is outside `~/.asana/mcp` and no @@ -164,9 +170,12 @@ claude mcp get asana-command codex mcp list ``` -For Cursor, open **Settings → Tools & MCP** or inspect `~/.cursor/mcp.json`. The installer -preserves unrelated entries in that file. If authentication stops working, run `auth login` again -for a PAT or `auth login --oauth` for OAuth, then restart the client. +For Cursor, open **Settings → Tools & MCP** or inspect `~/.cursor/mcp.json`. For Claude Desktop, +open **Settings → Developer** or inspect +`~/Library/Application Support/Claude/claude_desktop_config.json`. For OpenCode, run +`opencode mcp list` or inspect `~/.config/opencode/opencode.json`. The installer preserves +unrelated entries in every config file it touches. If authentication stops working, run +`auth login` again for a PAT or `auth login --oauth` for OAuth, then restart the client. ## Manual client configuration @@ -193,6 +202,35 @@ For Cursor, add a user-level stdio entry to `~/.cursor/mcp.json`: } ``` +For Claude Desktop, add the same shape (without `"type"`) to +`~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "asana-command": { + "command": "/absolute/path/to/.asana/mcp/bin/asana-command-mcp", + "args": [] + } + } +} +``` + +For OpenCode, add an entry under `mcp` in `~/.config/opencode/opencode.json`, combining the +executable and its arguments into one `command` array: + +```json +{ + "mcp": { + "asana-command": { + "type": "local", + "command": ["/absolute/path/to/.asana/mcp/bin/asana-command-mcp"], + "enabled": true + } + } +} +``` + MCP clients do not reliably expand `~` in configuration files; use an absolute path. ## Uninstall @@ -205,7 +243,9 @@ codex mcp remove asana-command rm -rf "$HOME/.asana/mcp" ``` -For Cursor, remove only the `asana-command` entry from `~/.cursor/mcp.json`. +For Cursor, remove only the `asana-command` entry from `~/.cursor/mcp.json`. For Claude Desktop, +remove it from `~/Library/Application Support/Claude/claude_desktop_config.json`. For OpenCode, +remove it from `~/.config/opencode/opencode.json`. ## Configuration From a287bf89df0b8126a91ccfd479e9a4b656a30881 Mon Sep 17 00:00:00 2001 From: Antoni Sobkowicz Date: Tue, 8 Sep 2026 09:15:21 +0200 Subject: [PATCH 3/3] fix(installer): merge into existing MCP entries instead of replacing them write_mcp_json_config fully replaced each client's server entry on every run, so re-running the installer would silently re-enable an OpenCode server the user had explicitly disabled via enabled: false (the constructed entry always set enabled: true, and always won because it overwrote the whole object rather than merging into it). Merge onto the existing entry instead, and stop forcing an explicit enabled: true on the entries this installer constructs itself, so an absent field just falls through to OpenCode's own default instead of overriding whatever the user (or another tool) had set. Found by code review. --- install.sh | 16 +++++++++++++--- tests/installer.test.ts | 31 ++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/install.sh b/install.sh index c153a16..2df298c 100755 --- a/install.sh +++ b/install.sh @@ -397,8 +397,10 @@ const entriesByStyle = { stdio: { type: "stdio", command: executable, args: [] }, // Claude Desktop infers stdio from the presence of "command". plain: { command: executable, args: [] }, - // OpenCode combines the executable and its arguments into one "command" array. - "opencode-local": { type: "local", command: [executable], enabled: true }, + // OpenCode combines the executable and its arguments into one "command" array. No "enabled" + // field here: omitting it lets OpenCode's own default apply, and merging below (rather than + // replacing the entry outright) preserves a user's own "enabled": false untouched. + "opencode-local": { type: "local", command: [executable] }, }; const entry = entriesByStyle[entryStyle]; if (entry === undefined) { @@ -425,7 +427,15 @@ if ( } config[serversKey] ??= {}; -config[serversKey][serverName] = entry; +const existingEntry = config[serversKey][serverName]; +const preservedFields = + existingEntry !== null && typeof existingEntry === "object" && !Array.isArray(existingEntry) + ? existingEntry + : {}; +// Merge onto the existing entry (when there is one) instead of replacing it outright, so a +// client- or user-managed field the constructed entry doesn't know about — OpenCode's +// "enabled": false, for example — survives a rerun of this installer. +config[serversKey][serverName] = { ...preservedFields, ...entry }; fs.mkdirSync(path.dirname(configPath), { recursive: true }); const temporaryPath = `${configPath}.tmp-${process.pid}-${Date.now()}`; diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 84fcf8d..321dcb5 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -384,7 +384,36 @@ describe("install.sh", () => { expect(opencodeConfig.mcp["asana-command"]).toEqual({ type: "local", command: [join(home, ".asana/mcp/bin/asana-command-mcp")], - enabled: true, + }); + }); + + it("preserves a user-disabled OpenCode entry's enabled: false across a rerun", () => { + const root = temporaryDirectory("command-installer-opencode-disabled"); + const home = join(root, "home with spaces"); + mkdirSync(join(home, ".config/opencode"), { recursive: true }); + writeFileSync( + join(home, ".config/opencode/opencode.json"), + JSON.stringify({ + mcp: { + "asana-command": { + type: "local", + command: ["/old/path/asana-command-mcp"], + enabled: false, + }, + }, + }), + ); + + const { result } = runInstaller({ root, args: ["--opencode"], clients: ["opencode"] }); + + expect(result.status, result.stderr).toBe(0); + const opencodeConfig = JSON.parse( + readFileSync(join(home, ".config/opencode/opencode.json"), "utf8"), + ); + expect(opencodeConfig.mcp["asana-command"]).toEqual({ + type: "local", + command: [join(home, ".asana/mcp/bin/asana-command-mcp")], + enabled: false, }); });