Skip to content

Commit f4f74c8

Browse files
committed
feat(clients): add OpenCode client adapter
Adds OpenCodeManager (~70 LOC) for OpenCode (https://github.com/sst/opencode), a popular open-source AI coding agent. Mirrors the structure of CodexCliManager / QwenCliManager. Distinct config shape: OpenCode reads ~/.config/opencode/opencode.json with the top-level key `mcp` (not the standard `mcpServers`). The new manager overrides `configure_key_name = 'mcp'` so the JSONClientManager base class routes correctly. Tests: tests/test_clients/test_opencode.py with 10 tests covering initialization, the mcp/mcpServers key distinction, install detection (PATH-based, OS-agnostic via shutil.which), get_client_info, and the load/add lifecycle. All 65 client tests pass; ruff clean.
1 parent bdd5ed9 commit f4f74c8

4 files changed

Lines changed: 215 additions & 0 deletions

File tree

src/mcpm/clients/client_registry.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from mcpm.clients.managers.fiveire import FiveireManager
2020
from mcpm.clients.managers.gemini_cli import GeminiCliManager
2121
from mcpm.clients.managers.goose import GooseClientManager
22+
from mcpm.clients.managers.opencode import OpenCodeManager
2223
from mcpm.clients.managers.qwen_cli import QwenCliManager
2324
from mcpm.clients.managers.trae import TraeManager
2425
from mcpm.clients.managers.vscode import VSCodeManager
@@ -52,6 +53,7 @@ class ClientRegistry:
5253
"gemini-cli": GeminiCliManager,
5354
"codex-cli": CodexCliManager,
5455
"qwen-cli": QwenCliManager,
56+
"opencode": OpenCodeManager,
5557
}
5658

5759
@classmethod

src/mcpm/clients/managers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from mcpm.clients.managers.fiveire import FiveireManager
1414
from mcpm.clients.managers.gemini_cli import GeminiCliManager
1515
from mcpm.clients.managers.goose import GooseClientManager
16+
from mcpm.clients.managers.opencode import OpenCodeManager
1617
from mcpm.clients.managers.qwen_cli import QwenCliManager
1718
from mcpm.clients.managers.trae import TraeManager
1819
from mcpm.clients.managers.vscode import VSCodeManager
@@ -32,4 +33,5 @@
3233
"VSCodeManager",
3334
"GeminiCliManager",
3435
"CodexCliManager",
36+
"OpenCodeManager",
3537
]
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""
2+
OpenCode integration utilities for MCP
3+
"""
4+
5+
import logging
6+
import os
7+
import shutil
8+
from typing import Any, Dict
9+
10+
from mcpm.clients.base import JSONClientManager
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
class OpenCodeManager(JSONClientManager):
16+
"""Manages OpenCode MCP server configurations.
17+
18+
OpenCode is an open-source AI coding agent that runs as a CLI/TUI
19+
(https://github.com/sst/opencode). Its config file is JSON at
20+
`~/.config/opencode/opencode.json` and uses the top-level key `mcp`
21+
(not the typical `mcpServers`).
22+
"""
23+
24+
# Client information
25+
client_key = "opencode"
26+
display_name = "OpenCode"
27+
download_url = "https://github.com/sst/opencode"
28+
configure_key_name = "mcp" # OpenCode uses `mcp` instead of `mcpServers`
29+
30+
def __init__(self, config_path_override: str | None = None):
31+
"""Initialize the OpenCode client manager
32+
33+
Args:
34+
config_path_override: Optional path to override the default config file location
35+
"""
36+
super().__init__(config_path_override=config_path_override)
37+
38+
if config_path_override:
39+
self.config_path = config_path_override
40+
else:
41+
# OpenCode stores its settings in ~/.config/opencode/opencode.json
42+
self.config_path = os.path.expanduser("~/.config/opencode/opencode.json")
43+
44+
def _get_empty_config(self) -> Dict[str, Any]:
45+
"""Get empty config structure for OpenCode"""
46+
return {self.configure_key_name: {}}
47+
48+
def is_client_installed(self) -> bool:
49+
"""Check if OpenCode is installed.
50+
51+
Returns:
52+
bool: True if `opencode` binary is on PATH, False otherwise.
53+
"""
54+
# shutil.which() handles Windows PATHEXT automatically (.cmd, .bat, .exe, etc.)
55+
return shutil.which("opencode") is not None
56+
57+
def get_client_info(self) -> Dict[str, str]:
58+
"""Get information about this client
59+
60+
Returns:
61+
Dict: Information about the client including display name, download URL, and config path
62+
"""
63+
return {
64+
"name": self.display_name,
65+
"download_url": self.download_url,
66+
"config_file": self.config_path,
67+
"description": "Open-source AI coding agent (CLI / TUI)",
68+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""
2+
Test for OpenCode manager
3+
"""
4+
5+
import json
6+
import os
7+
import tempfile
8+
from pathlib import Path
9+
from unittest.mock import patch
10+
11+
from mcpm.clients.managers.opencode import OpenCodeManager
12+
13+
14+
def test_opencode_manager_initialization():
15+
"""Test OpenCodeManager initialization with default and override paths."""
16+
manager = OpenCodeManager()
17+
assert manager.client_key == "opencode"
18+
assert manager.display_name == "OpenCode"
19+
assert manager.download_url == "https://github.com/sst/opencode"
20+
assert manager.config_path == str(Path.home() / ".config" / "opencode" / "opencode.json")
21+
22+
custom_path = "/tmp/custom_opencode.json"
23+
manager = OpenCodeManager(config_path_override=custom_path)
24+
assert manager.config_path == custom_path
25+
26+
27+
def test_opencode_manager_uses_mcp_key_not_mcpservers():
28+
"""Test that OpenCode uses the `mcp` key (not `mcpServers`).
29+
30+
This is the OpenCode-specific configuration shape — the JSON file
31+
has a top-level `mcp` object instead of the `mcpServers` standard
32+
used by Claude Code, Cursor, Cline, etc.
33+
"""
34+
manager = OpenCodeManager()
35+
assert manager.configure_key_name == "mcp"
36+
37+
38+
def test_opencode_manager_get_empty_config():
39+
"""Test OpenCodeManager _get_empty_config method returns the right shape."""
40+
manager = OpenCodeManager()
41+
config = manager._get_empty_config()
42+
assert "mcp" in config
43+
assert config["mcp"] == {}
44+
# Sanity: no mcpServers key (the standard one).
45+
assert "mcpServers" not in config
46+
47+
48+
def test_opencode_manager_is_client_installed_true():
49+
"""Test is_client_installed returns True when `opencode` binary is on PATH."""
50+
manager = OpenCodeManager()
51+
with patch("shutil.which", return_value="/usr/local/bin/opencode") as mock_which:
52+
assert manager.is_client_installed() is True
53+
mock_which.assert_called_with("opencode")
54+
55+
56+
def test_opencode_manager_is_client_installed_false():
57+
"""Test is_client_installed returns False when `opencode` is not on PATH."""
58+
manager = OpenCodeManager()
59+
with patch("shutil.which", return_value=None) as mock_which:
60+
assert manager.is_client_installed() is False
61+
mock_which.assert_called_with("opencode")
62+
63+
64+
def test_opencode_manager_is_client_installed_windows():
65+
"""Test that is_client_installed handles Windows PATHEXT via shutil.which."""
66+
manager = OpenCodeManager()
67+
# shutil.which() handles Windows PATHEXT automatically, so the manager
68+
# always searches for "opencode" (no .exe / .cmd suffix). This keeps
69+
# the manager OS-agnostic and matches the convention used by
70+
# CodexCliManager, QwenCliManager, etc.
71+
with patch("shutil.which", return_value="C:\\Users\\user\\AppData\\Roaming\\npm\\opencode.cmd") as mock_which:
72+
assert manager.is_client_installed() is True
73+
mock_which.assert_called_with("opencode")
74+
75+
76+
def test_opencode_manager_get_client_info():
77+
"""Test OpenCodeManager get_client_info method returns expected metadata."""
78+
manager = OpenCodeManager()
79+
info = manager.get_client_info()
80+
assert info["name"] == "OpenCode"
81+
assert info["download_url"] == "https://github.com/sst/opencode"
82+
assert info["config_file"] == str(Path.home() / ".config" / "opencode" / "opencode.json")
83+
assert "Open-source AI coding agent" in info["description"]
84+
85+
86+
def test_opencode_manager_loads_existing_mcp_section():
87+
"""Test that loading an existing config preserves the `mcp` section content."""
88+
with tempfile.NamedTemporaryFile(delete=False, suffix=".json", mode="w") as f:
89+
json.dump(
90+
{
91+
"mcp": {
92+
"memory": {
93+
"command": "npx",
94+
"args": ["-y", "@modelcontextprotocol/server-memory"],
95+
}
96+
}
97+
},
98+
f,
99+
)
100+
temp_path = f.name
101+
102+
try:
103+
manager = OpenCodeManager(config_path_override=temp_path)
104+
config = manager._load_config()
105+
assert "mcp" in config
106+
assert "memory" in config["mcp"]
107+
assert config["mcp"]["memory"]["command"] == "npx"
108+
finally:
109+
os.unlink(temp_path)
110+
111+
112+
def test_opencode_manager_creates_empty_mcp_section_for_missing_file():
113+
"""Test that loading a nonexistent file returns an empty `mcp` section."""
114+
nonexistent_path = "/tmp/nonexistent-opencode-config-test.json"
115+
manager = OpenCodeManager(config_path_override=nonexistent_path)
116+
config = manager._load_config()
117+
assert "mcp" in config
118+
assert config["mcp"] == {}
119+
120+
121+
def test_opencode_manager_adds_server_under_mcp_key():
122+
"""Test that adding a server writes it under the `mcp` key (not `mcpServers`)."""
123+
from mcpm.core.schema import STDIOServerConfig
124+
125+
with tempfile.NamedTemporaryFile(delete=False, suffix=".json", mode="w") as f:
126+
json.dump({"mcp": {}}, f)
127+
temp_path = f.name
128+
129+
try:
130+
manager = OpenCodeManager(config_path_override=temp_path)
131+
server_config = STDIOServerConfig(name="test-server", command="echo", args=["hello"])
132+
success = manager.add_server(server_config)
133+
assert success is True
134+
135+
# Verify the server was written under `mcp`, not `mcpServers`.
136+
with open(temp_path) as f:
137+
saved = json.load(f)
138+
assert "mcp" in saved
139+
assert "test-server" in saved["mcp"]
140+
assert saved["mcp"]["test-server"]["command"] == "echo"
141+
assert "mcpServers" not in saved
142+
finally:
143+
os.unlink(temp_path)

0 commit comments

Comments
 (0)