8Implements the ``oct install-mcp`` CLI subcommand. Detects the target
9AI host (Claude Code, Cursor, VS Code, or ``auto``) and writes (or
10previews) the ``oct-mcp`` server entry into the host's MCP config file.
14- Define :data:`MCP_HOSTS` mapping each supported host to its config
15 file location(s) and the JSON schema for the ``oct-mcp`` entry.
16- Implement :func:`run_install_mcp` which detects the host config path,
17 merges the ``oct-mcp`` server entry, and writes the result (or
18 previews it in dry-run mode).
19- Print a human-readable summary of what was / would be changed.
25 L2 — lifecycle: file read, file written
29- This module does not import ``click`` or any MCP SDK.
30- Writes only to known, allowlisted config file paths — never to
31 arbitrary locations (design decision D-5B-7).
32- ``dry_run=True`` (the default) never modifies any file.
33- If the host config file already contains an ``oct-mcp`` server entry,
34 the entry is updated in-place without duplicating it.
37from __future__
import annotations
41from pathlib
import Path
45 from oc_diagnostics
import _dbg
as _real_dbg
47 def _dbg(*args, **kwargs) -> None:
48 _real_dbg(*args, **kwargs)
50 def _dbg(*args, **kwargs) -> None:
59 """Return the oct-mcp server entry for the current Python executable."""
61 "command": sys.executable,
62 "args": [
"-m",
"oct.mcp",
"--project-root", str(project_root)],
72SUPPORTED_HOSTS: tuple[str, ...] = (
"claude-code",
"cursor",
"vscode")
76 """Return candidate config file paths for *host* in preference order."""
77 if host ==
"claude-code":
79 if sys.platform ==
"win32":
82 appdata = Path.home() /
"AppData" /
"Roaming" /
"Claude"
84 appdata /
"claude_desktop_config.json",
85 home /
".claude" /
"settings.json",
90 home /
".config" /
"Claude" /
"claude_desktop_config.json",
91 home /
".claude" /
"settings.json",
93 elif host ==
"cursor":
94 return [Path(
".cursor") /
"mcp.json"]
95 elif host ==
"vscode":
96 return [Path(
".vscode") /
"mcp.json"]
101 """Auto-detect the first installed host whose config file exists."""
102 for host
in SUPPORTED_HOSTS:
104 resolved = p
if p.is_absolute()
else (project_root / p)
105 if resolved.exists():
116 """Read JSON from *path*, returning ``{}`` on any error."""
118 return json.loads(path.read_text(encoding=
"utf-8"))
124 """Merge the oct-mcp server entry into *config*.
126 For Claude Code ``settings.json`` the schema is::
128 {"mcpServers": {"oct-mcp": {...}}}
130 For Cursor / VS Code ``mcp.json`` the schema is::
132 {"servers": {"oct-mcp": {...}}}
134 If the key ``"mcpServers"`` is present (or the file is a Claude Code
135 settings.json) we use the Claude Code schema; otherwise we use the
136 Cursor/VS Code schema.
138 updated = dict(config)
140 if "mcpServers" in config
or "mcpServers" not in config
and "servers" not in config:
142 servers = dict(updated.get(
"mcpServers", {}))
143 servers[
"oct-mcp"] = server_entry
144 updated[
"mcpServers"] = servers
146 servers = dict(updated.get(
"servers", {}))
147 servers[
"oct-mcp"] = server_entry
148 updated[
"servers"] = servers
160 dry_run: bool =
True,
162 """Install or preview the oct-mcp server entry for the given host.
167 One of ``"claude-code"``, ``"cursor"``, ``"vscode"``, or
168 ``"auto"`` (auto-detect).
170 Absolute project root path (used as ``--project-root`` arg in
173 When ``True`` (the default), print what would be written without
178 Exit code: 0 on success, 1 on failure.
180 func =
"run_install_mcp"
181 _dbg(
"OCT-CLI", func, f
"host={host} dry_run={dry_run}", 2)
188 "oct install-mcp: could not auto-detect an MCP host. "
189 "Use --host to specify one of: claude-code, cursor, vscode.",
194 print(f
"Detected host: {host}")
196 if host
not in SUPPORTED_HOSTS:
198 f
"oct install-mcp: unsupported host {host!r}. "
199 f
"Supported: {', '.join(SUPPORTED_HOSTS)}",
206 print(f
"oct install-mcp: no config paths defined for host {host!r}", file=sys.stderr)
210 config_path: Path |
None =
None
212 resolved = c
if c.is_absolute()
else (project_root / c)
213 if resolved.parent.exists():
214 config_path = resolved
216 if config_path
is None:
217 config_path = candidates[0]
if candidates[0].is_absolute()
else (project_root / candidates[0])
223 existing =
_read_json_safe(config_path)
if config_path.exists()
else {}
228 output = json.dumps(updated, indent=2)
231 print(f
"[DRY RUN] Would write to: {config_path}")
235 "\nRe-run without --dry-run to apply, "
236 "or use --no-dry-run to apply immediately."
242 config_path.parent.mkdir(parents=
True, exist_ok=
True)
243 config_path.write_text(output +
"\n", encoding=
"utf-8")
244 print(f
"Wrote oct-mcp server entry to: {config_path}")
245 print(
"Restart your AI host to pick up the new configuration.")
246 _dbg(
"OCT-CLI", func, f
"wrote {config_path}", 2)
248 except OSError
as exc:
249 print(f
"oct install-mcp: failed to write {config_path}: {exc}", file=sys.stderr)
int run_install_mcp(str host, Path project_root, bool dry_run=True)
None _dbg(*args, **kwargs)
str|None _detect_host(Path project_root)
dict _read_json_safe(Path path)
dict _make_oct_mcp_entry(Path project_root)
list[Path] _get_host_config_paths(str host)
dict _merge_server_entry(dict config, dict server_entry)