Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- OpenCode targets write native OpenCode 2 MCP entries under `mcp.servers` when `opencode.json` already declares that map, translating `enabled` to `disabled` and keeping `codemode` and other extra fields; files without it keep the OpenCode 1 layout. -- by @RamsesJM (#3414)

## [0.35.0] - 2026-10-22

### Added
Expand Down
44 changes: 42 additions & 2 deletions docs/src/content/docs/consumer/install-mcp-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ unresolved required entries fail closed.
| Gemini CLI | `.gemini/settings.json` (project, only if `.gemini/` exists) or `~/.gemini/settings.json` (`-g`) | both | JSON `mcpServers` |
| Antigravity CLI | `.agents/mcp_config.json` (project, only if `.agents/` exists) or `~/.gemini/config/mcp_config.json` (`-g`) | both | JSON `mcpServers` |
| Hermes Agent | `$HERMES_HOME/config.yaml` (unset/blank: `~/.hermes/config.yaml`; explicit `--target hermes` only) | home-scoped | YAML `mcp_servers` |
| OpenCode | `opencode.json` (project, only if `.opencode/` exists) or `~/.config/opencode/opencode.json` (`-g`) | both | JSON `mcp` |
| OpenCode | `opencode.json` (project, only if `.opencode/` exists) or `~/.config/opencode/opencode.json` (`-g`) | both | JSON `mcp` (v1) or `mcp.servers` (v2) |
| Windsurf | `~/.codeium/windsurf/mcp_config.json` | global | JSON `mcpServers` |
| Kiro IDE | `.kiro/settings/mcp.json` (project, only if `.kiro/` exists) or `~/.kiro/settings/mcp.json` (`-g`) | both | JSON `mcpServers` |
| JetBrains Copilot | `%LOCALAPPDATA%\github-copilot\intellij\mcp.json` (Windows) or `$XDG_CONFIG_HOME/github-copilot/intellij/mcp.json` (macOS/Linux; defaults to `~/.config/github-copilot/intellij/mcp.json`) | global | JSON `servers` |
Expand All @@ -182,7 +182,8 @@ value unchanged into that server's `opencode.json` entry. If omitted, APM keeps
the existing `true` default. APM does not validate or coerce explicit values;
OpenCode interprets them. Other targets ignore this OpenCode-only field.
Reinstall applies changes to `enabled`, including its JSON type; removing
the field restores `true`.
the field restores `true`. For the OpenCode 2 layout, see
[OpenCode v2 configuration](#opencode-v2-configuration).

Global Cursor and OpenCode installs preserve your personal servers, including
ones that share a name with a package-declared server. Reinstalling and
Expand All @@ -197,6 +198,45 @@ it, even on a no-op reinstall. Fix the reported config and rerun the original
command with `--global` and the same `--target` selection. A home-directory
alias itself remains supported.

### OpenCode v2 configuration

OpenCode 1 and OpenCode 2 read the same `opencode.json` files, but only
OpenCode 2 understands its native layout, which nests servers under
`mcp.servers`. APM therefore chooses the layout from the target file
itself:

- If the file declares an `mcp.servers` object, APM writes each server
there in the OpenCode 2 format.
- Otherwise APM keeps writing the OpenCode 1 layout, which OpenCode 2 also
reads.

To opt in, add an empty map to the target file and reinstall:

```json
{
"mcp": {
"servers": {}
}
}
```

In the OpenCode 2 layout, `enabled: false` is written as `disabled: true`,
and `enabled: true` or an omitted value writes no field, the OpenCode 2
default. Other values have no OpenCode 2 equivalent, so APM warns and
leaves the file unchanged. A `disabled` value in the `extra` block is
ignored, so `enabled` alone decides. To expose a server's tools directly instead of
through Code Mode, set `codemode: false` in the dependency's `extra` block;
APM writes extra fields verbatim, so use the OpenCode 2 spelling for any
other native setting, such as snake_case `oauth` fields.

APM does not convert configuration it did not write. When it switches to
`mcp.servers`, it removes an OpenCode 1 entry of the same name only if the
entry is unchanged from what APM wrote; an edited entry stays in place with
a warning, and OpenCode 2 uses the `mcp.servers` entry. Your own servers,
`mcp.timeout` and other settings are preserved, and pruning removes only
entries APM wrote. Remove `mcp.servers` before running OpenCode 1 against
the same file, since OpenCode 1 cannot read it.

## Resolve a connection name conflict

For self-defined Claude and Codex connections, a normal package update or root
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/reference/manifest-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -681,7 +681,7 @@ A plain registry reference: `io.github.github/github-mcp-server`.
| `tools` | `list<string>` | OPTIONAL | Default: `["*"]` | Restrict which tools are exposed. |
| `url` | `string` | Conditional | | Endpoint URL. REQUIRED when `registry: false` and `transport` is `http`, `sse`, or `streamable-http`. |
| `command` | `string` | Conditional | Single binary path; no embedded whitespace unless `args` is also present | Binary path. REQUIRED when `registry: false` and `transport` is `stdio`. |
| `enabled` | any JSON value | OPTIONAL | OpenCode only | Passed unchanged to OpenCode's `opencode.json` entry. When omitted, APM keeps its existing `true` default. APM does not validate the value; OpenCode interprets it. Other targets ignore this field. |
| `enabled` | any JSON value | OPTIONAL | OpenCode only | Passed unchanged to OpenCode's `opencode.json` entry. When omitted, APM keeps its existing `true` default. APM does not validate the value; OpenCode interprets it. In the OpenCode 2 `mcp.servers` layout, a boolean is written as `disabled` and any other value is rejected; see [OpenCode v2 configuration](../../consumer/install-mcp-servers/#opencode-v2-configuration). Other targets ignore this field. |

Any additional keys not listed above are preserved as **extra passthrough fields** and round-tripped verbatim into the generated target manifests. This allows harness-specific configuration (e.g. Claude Code's `oauth` block for remote-MCP OAuth client config) to be declared in `apm.yml` and appear in the generated config without modification. A warning is emitted at parse time naming each non-standard key.

Expand Down
126 changes: 119 additions & 7 deletions src/apm_cli/adapters/client/opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
- Command format: single array ``command`` (not ``command`` + ``args``)
- Env key: ``environment`` (not ``env``)

OpenCode v2 nests servers under ``mcp.servers`` and replaces ``enabled``
with its inverse, ``disabled``. Both versions read the same files, but
v1 cannot read the v2 layout, so APM writes v2 entries only when the
target file already declares an ``mcp.servers`` map. Otherwise it keeps
the v1 layout above, which v2 also accepts.

At project scope APM only writes to ``opencode.json`` when the
``.opencode/`` directory already exists -- OpenCode support is opt-in.
"""
Expand All @@ -33,12 +39,21 @@
from pathlib import Path
from typing import Any

from ...models.dependency.mcp import _EXTRA_DENYLIST, opencode_enabled_value
from ...models.dependency.mcp import (
_EXTRA_DENYLIST,
opencode_enabled_matches,
opencode_enabled_value,
)
from ...utils.console import _rich_warning
from .copilot import CopilotClientAdapter

_ENV_REF_TEMPLATE = "{env:%s}"
_ENV_REF_RE = re.compile(re.escape(_ENV_REF_TEMPLATE) % r"([A-Za-z_][A-Za-z0-9_]*)")

_V2_SERVERS_KEY = "servers"
# Settings that share the ``mcp`` object with v1 server entries in v2 files.
V2_MCP_SETTINGS = frozenset({_V2_SERVERS_KEY, "timeout"})


class OpenCodeClientAdapter(CopilotClientAdapter):
"""OpenCode MCP client adapter.
Expand Down Expand Up @@ -127,12 +142,72 @@ def update_config(self, config_updates, enabled: Any = True):
if "mcp" not in current_config:
current_config["mcp"] = {}

if self.uses_native_v2(current_config):
return self._update_v2_config(config_path, current_config, config_updates, enabled)

for name, copilot_entry in config_updates.items():
current_config["mcp"][name] = self._to_opencode_format(copilot_entry, enabled=enabled)

self._write_json_config(config_path, current_config)
return True

def _update_v2_config(
self,
config_path: Path,
current_config: dict,
config_updates: dict,
enabled: Any,
) -> bool:
"""Write *config_updates* under ``mcp.servers`` in a v2 config.

A v1-layout entry with the same name is removed only when it is
exactly what APM would have written there, so user edits survive.
Nothing is written if any entry cannot be expressed in v2.
"""
mcp = current_config["mcp"]
servers = mcp[_V2_SERVERS_KEY]
try:
rendered = {
name: self._to_opencode_v2_format(entry, enabled=enabled)
for name, entry in config_updates.items()
}
except ValueError as exc:
_rich_warning(f"{exc} -- skipping {config_path}", symbol="warning")
return False

for name, entry in rendered.items():
legacy = mcp.get(name) if name not in V2_MCP_SETTINGS else None
if legacy is not None:
written = self._to_opencode_format(config_updates[name], enabled=enabled)
if legacy == written and opencode_enabled_matches(legacy, written):
del mcp[name]
else:
_rich_warning(
f"Kept the edited v1 entry 'mcp.{name}' in {config_path}; "
f"OpenCode v2 uses 'mcp.servers.{name}' instead",
symbol="warning",
)
servers[name] = entry

self._write_json_config(config_path, current_config)
return True

@staticmethod
def uses_native_v2(config: Any) -> bool:
"""Return whether *config* declares the OpenCode v2 server layout.

A v1 server that happens to be named ``servers`` carries a string
``type`` and is not treated as a v2 server map.
"""
mcp = config.get("mcp") if isinstance(config, dict) else None
servers = mcp.get(_V2_SERVERS_KEY) if isinstance(mcp, dict) else None
return isinstance(servers, dict) and not isinstance(servers.get("type"), str)

@classmethod
def native_v2_servers(cls, config: dict) -> dict | None:
"""Return the mutable ``mcp.servers`` map of a v2 config, else ``None``."""
return config["mcp"][_V2_SERVERS_KEY] if cls.uses_native_v2(config) else None

def validate_config_for_install(self) -> bool:
"""Refuse unsafe user configs even when no server write would be needed."""
return (
Expand All @@ -141,7 +216,19 @@ def validate_config_for_install(self) -> bool:
)

def get_current_config(self):
"""Read the current ``opencode.json`` contents."""
"""Read the current ``opencode.json`` contents.

For a v2 config, ``mcp`` lists only the ``mcp.servers`` entries.
A server still in the v1 layout then counts as not installed, so a
reinstall writes its native entry and retires the v1 one.
"""
config = self._read_config_file()
if not self.uses_native_v2(config):
return config
return {**config, "mcp": dict(config["mcp"][_V2_SERVERS_KEY])}

def _read_config_file(self) -> dict:
"""Return the parsed config file, or ``{}`` when it is absent or unreadable."""
config_path = self.get_config_path()
if not os.path.exists(config_path):
return {}
Expand All @@ -152,11 +239,12 @@ def get_current_config(self):
return {}

def render_server_config(self, server_info: dict) -> dict:
"""Render the ``mcp`` entry APM writes, for exact baseline comparisons."""
return self._to_opencode_format(
super().render_server_config(server_info),
enabled=opencode_enabled_value(server_info),
)
"""Render the entry APM writes, for exact baseline comparisons."""
copilot_entry = super().render_server_config(server_info)
enabled = opencode_enabled_value(server_info)
if self.uses_native_v2(self._read_config_file()):
return self._to_opencode_v2_format(copilot_entry, enabled=enabled)
return self._to_opencode_format(copilot_entry, enabled=enabled)

def configure_mcp_server(
self,
Expand Down Expand Up @@ -233,3 +321,27 @@ def _to_opencode_format(copilot_entry: dict, enabled: Any = True) -> dict:
entry[key] = value

return entry

@classmethod
def _to_opencode_v2_format(cls, copilot_entry: dict, enabled: Any = True) -> dict:
"""Convert a Copilot-format server config to the OpenCode v2 layout.

Identical to the v1 entry except that ``enabled`` becomes
``disabled: true`` when false and is omitted when true, the v2
default. ``disabled`` always follows ``enabled``, even when an
extra field supplies it; other passthrough fields such as
``codemode`` are kept as is.

Raises:
ValueError: *enabled* is not a boolean, so it has no v2 equivalent.
"""
if not isinstance(enabled, bool):
raise ValueError(
f"OpenCode v2 needs a boolean 'enabled' value, got {json.dumps(enabled)}"
)
entry = cls._to_opencode_format(copilot_entry, enabled=enabled)
del entry["enabled"]
entry.pop("disabled", None)
if not enabled:
entry["disabled"] = True
Comment on lines +342 to +346

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6117faf, but locally rather than in _HARNESS_EXTRA_ALIASES: Kiro forwards disabled as a legitimate extension field (KiroClientAdapter._copy_kiro_extensions), so reserving it globally would change another target, which #3112 excludes. The OpenCode v2 conversion now drops any passthrough disabled and derives it solely from enabled. Covered by test_modeled_enabled_overrides_disabled_passthrough.

return entry
33 changes: 30 additions & 3 deletions src/apm_cli/integration/mcp_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import re
import shutil
import warnings
from collections.abc import MutableMapping
from collections.abc import Callable, MutableMapping
from pathlib import Path
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -131,6 +131,8 @@ def _clean_json_mcp_config(
trailing_newline: bool = False,
use_rich: bool = False,
fail_on_write_error: bool = False,
nested_servers: Callable[[dict], dict | None] | None = None,
nested_settings: frozenset[str] = frozenset(),
) -> int:
"""Remove stale entries from a JSON-based MCP config file.

Expand All @@ -143,6 +145,13 @@ def _clean_json_mcp_config(
trailing_newline: When True, append a trailing newline after JSON serialisation.
use_rich: When True, emit removal notices via ``_rich_success``; otherwise use
``logger.progress``.
nested_servers: Returns a second server map inside the parsed config,
or ``None``. Stale names are removed from it as well, for layouts
such as OpenCode v2 that keep servers below the top-level key.
A top-level entry is then removed only when the nested map does
not declare the same name, since that entry is not APM's.
nested_settings: Top-level keys that hold settings rather than
servers whenever ``nested_servers`` returns a map.

Returns:
Number of entries removed.
Expand All @@ -164,9 +173,22 @@ def _clean_json_mcp_config(
servers = config.get(servers_key, {})
if not isinstance(servers, dict):
raise ValueError(f"{label} {servers_key} must be a mapping")
removed = [n for n in stale_names if n in servers]
nested = nested_servers(config) if nested_servers else None
if nested is None:
removed = [n for n in stale_names if n in servers]
else:
removed = [
n
for n in stale_names
if n in servers and n not in nested and n not in nested_settings
]
for name in removed:
del servers[name]
if nested:
nested_removed = [n for n in stale_names if n in nested]
for name in nested_removed:
del nested[name]
removed = sorted(builtins.set(removed) | builtins.set(nested_removed))
if removed:
text = json.dumps(config, indent=2)
if trailing_newline:
Expand Down Expand Up @@ -862,7 +884,10 @@ def remove_stale(
# Project scope is opt-in; user scope uses ~/.config/opencode/.
if "opencode" in target_runtimes:
if clean_user_scope or (project_root_path / ".opencode").is_dir():
from apm_cli.adapters.client.opencode import OpenCodeClientAdapter
from apm_cli.adapters.client.opencode import (
V2_MCP_SETTINGS,
OpenCodeClientAdapter,
)

_clean_json_mcp_config(
OpenCodeClientAdapter.config_path_for(project_root_path, clean_user_scope),
Expand All @@ -871,6 +896,8 @@ def remove_stale(
"OpenCode MCP config",
servers_key="mcp",
fail_on_write_error=fail_on_write_error,
nested_servers=OpenCodeClientAdapter.native_v2_servers,
nested_settings=V2_MCP_SETTINGS,
)

if "windsurf" in target_runtimes:
Expand Down
Loading