Skip to content

feat(mcp): write native OpenCode 2 entries under mcp.servers - #3414

Open
RamsesJ.Dev (RamsesJM) wants to merge 3 commits into
microsoft:mainfrom
RamsesJM:feat/opencode-v2-mcp-3112
Open

RamsesJ.Dev (RamsesJM) wants to merge 3 commits into
microsoft:mainfrom
RamsesJM:feat/opencode-v2-mcp-3112

Conversation

@RamsesJM

Copy link
Copy Markdown

Description

Adds OpenCode 2 MCP support to the existing opencode target. When the target opencode.json already declares an mcp.servers map, APM writes native OpenCode 2 entries there; otherwise it keeps the current OpenCode 1 layout. No new target, flag or manifest field is introduced.

Why the format is selected from the file

  • OpenCode 1 and 2 read the same opencode.json locations, and both ship as opencode, so there is no version signal to detect (Migrate from V1).
  • OpenCode 2 reads the v1 layout, but OpenCode 1 cannot read mcp.servers. Emitting v2 by default would break existing v1 users.
  • An mcp.servers map is therefore an explicit, user-owned opt-in that never converts an installation silently, as the scope requires.

Behavior in the v2 layout

Manifest input Written to mcp.servers.<name>
enabled omitted or true no field (v2 default)
enabled: false disabled: true
any non-boolean enabled nothing; warning, file unchanged
extra: {codemode: false} codemode: false (existing passthrough)
  • A v1 entry with the same name is removed only when it is byte-for-byte what APM would have written; an edited entry is kept with a warning, and OpenCode 2 uses the mcp.servers entry.
  • mcp.timeout, user-owned servers and unrelated settings are preserved.
  • Stale cleanup prunes managed names from both mcp and mcp.servers, and never removes the servers map itself.
  • get_current_config() exposes the effective server set (v1 entries overlaid by mcp.servers), so conflict detection and installed-server checks no longer see servers or timeout as server names.

Implementation

File Change
src/apm_cli/adapters/client/opencode.py uses_native_v2() selection rule; _to_opencode_v2_format(); v2 write path in update_config(); v2-aware get_current_config() and render_server_config()
src/apm_cli/integration/mcp_integrator.py _clean_json_mcp_config() accepts an optional nested_servers accessor; OpenCode passes OpenCodeClientAdapter.native_v2_servers
docs/.../consumer/install-mcp-servers.md New "OpenCode v2 configuration" section: selection, opt-in, field mapping, migration limits
docs/.../reference/manifest-schema.md enabled row notes the v2 mapping
tests/unit/test_opencode_mcp_v2.py Selection rule, rendering, write path, legacy handling, effective view, stale cleanup
tests/integration/test_opencode_v2_lifecycle.py CLI install, stable reinstall, prune, non-boolean enabled

Trade-offs

  • File-based selection over a new option. Avoids new manifest or CLI surface and cannot misconfigure v1 users. The cost is that v2 users opt in once by adding "mcp": {"servers": {}}; this is documented.
  • Reject non-boolean enabled in v2. v1 passes any JSON value through unchanged, but v2 has no enabled field and inverting a non-boolean would be a guess. v1 behavior is unchanged.
  • No conversion of user-authored v1 entries. Only exact APM-written entries move. Edited entries stay, which can leave a harmless duplicate that OpenCode 2 resolves in favor of mcp.servers.
  • Passthrough stays verbatim. Fields in extra are not translated (for example camelCase v1 oauth keys); the docs tell authors to use v2 spelling. Translating them would extend passthrough semantics, which is out of scope.

Issue and approved scope

Issue: #3112

Human scope-approval comment: #3112 (comment)

This PR completes the recorded scope: native v2 stdio and HTTP entries with disabled and codemode, v1 preserved, and a documented non-destructive selection. It adds no opencode2 target, no new trust semantics, and no changes to other targets.

Fixes #3112

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Maintenance / refactor

Testing

  • Tested locally
  • All existing tests pass
  • Added tests for new functionality (if applicable)

Full CI on the fork (Lint, both Build & Test shards, both Lifecycle Smoke shards, Spec conformance, Windows Compatibility, Architecture ratchets): RamsesJM#1.

How to test

  1. In a project with .opencode/, create opencode.json containing {"mcp": {"servers": {}}}.
  2. Declare a self-defined stdio server with enabled: false and an HTTP server with extra: {codemode: false} in apm.yml, targeting opencode.
  3. Run apm install: both entries appear under mcp.servers, the first with disabled: true, the second with codemode: false, and neither with enabled.
  4. Run apm install again: the file is byte-identical.
  5. Remove one server from apm.yml and reinstall: only that entry is removed. Without mcp.servers, output is the unchanged v1 layout.

Spec conformance (OpenAPM v0.1)

  • N/A -- this PR does not change OpenAPM-observable behaviour.

OpenCode 1 and 2 share opencode.json locations, and OpenCode 1 cannot
read the v2 mcp.servers layout. Select the layout from the target file:
when it declares an mcp.servers map, write native v2 entries there;
otherwise keep the v1 layout, which OpenCode 2 also reads.

In the v2 layout, boolean enabled is written as disabled and other values
are rejected without touching the file. Extra fields such as codemode pass
through unchanged. A v1 entry of the same name is removed only when it is
unchanged from what APM wrote, and stale cleanup also prunes mcp.servers.

Refs microsoft#3112
@RamsesJM

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Pruning managed v2 servers can delete user-owned MCP settings and edited legacy entries, risking loss of configuration that should be preserved.

5 open issues
What changed in this PR

Adds native OpenCode 2 MCP configuration support to the existing opencode target for #3112, while retaining the v1 layout unless the configuration explicitly opts in.

Changes:

  • Selects native v2 output from an existing mcp.servers map and translates boolean enabled values.
  • Extends configuration reads and stale-server cleanup to handle both layouts.
  • Adds component and CLI lifecycle coverage, documentation, and a changelog entry.
File Description
tests/​unit/​test_opencode_mcp_v2.py Tests layout selection, rendering, legacy handling, and cleanup.
tests/​integration/​test_opencode_v2_lifecycle.py Covers CLI install, reinstall, pruning, and non-boolean rejection.
src/​apm_cli/​integration/​mcp_integrator.py Adds nested server-map cleanup for OpenCode.
src/​apm_cli/​adapters/​client/​opencode.py Implements v2 selection, rendering, writes, and effective configuration reads.
docs/​src/​content/​docs/​reference/​manifest-schema.md Documents v2 handling of enabled.
docs/​src/​content/​docs/​consumer/​install-mcp-servers.md Explains opt-in, field mapping, and migration behavior.
CHANGELOG.md Records native OpenCode 2 MCP support.

Review effort: Balanced

Comment on lines +172 to +174
removed = [
n for n in stale_names if n in servers and (nested is None or servers[n] is not nested)
]

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. In a v2 config, top-level pruning now skips mcp.servers and mcp.timeout (nested_settings), while a server with either name is still removed from mcp.servers. Covered by test_stale_name_never_removes_v2_settings.

Comment on lines +173 to +174
n for n in stale_names if n in servers and (nested is None or servers[n] is not nested)
]

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. A v1 entry is pruned only when mcp.servers does not declare the same name. After migration, a remaining same-named v1 entry is by construction one APM kept because it was edited, so it is left alone. Legacy-only entries are still pruned. Covered by test_kept_legacy_entry_survives_pruning_of_native_entry and test_legacy_only_entry_is_pruned_in_v2_file.

Comment thread src/apm_cli/adapters/client/opencode.py Outdated
for name, entry in rendered.items():
legacy = mcp.get(name) if name not in _V2_MCP_SETTINGS else None
if legacy is not None:
if legacy == self._to_opencode_format(config_updates[name], enabled=enabled):

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 as suggested: the v1 entry is retired only when it equals the APM rendering and opencode_enabled_matches() holds. Covered by test_keeps_legacy_entry_whose_enabled_type_changed.

Comment thread src/apm_cli/adapters/client/opencode.py Outdated
Comment on lines +224 to +226
effective = {name: entry for name, entry in mcp.items() if name not in _V2_MCP_SETTINGS}
effective.update(mcp[_V2_SERVERS_KEY])
return {**config, "mcp": effective}

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. In a v2 config, get_current_config() now reports only mcp.servers entries, so a server still in the v1 layout counts as not installed and the next install writes its native entry and retires the matching v1 one. Covered by test_legacy_only_server_is_reported_as_needing_installation and the CLI scenario test_opencode_v1_install_moves_to_v2_after_opt_in.

Comment on lines +338 to +341
entry = cls._to_opencode_format(copilot_entry, enabled=enabled)
del entry["enabled"]
if not enabled:
entry["disabled"] = True

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.

- Keep mcp.timeout and other v2 settings out of top-level pruning.
- Prune a v1 entry only when mcp.servers does not declare the same name,
  so an edited entry kept during migration survives removal.
- Compare enabled by JSON type before retiring a v1 entry.
- Report only mcp.servers entries in a v2 config, so adding an empty map
  migrates existing servers on the next install.
- Derive disabled from enabled even when an extra field supplies it.
@RamsesJM

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Support Opencode 2 MCP server configuration

2 participants