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
26 L3 - (none): this module emits lifecycle only
27 L4 - (none): no deep trace is emitted
31- This module does not import ``click`` or any MCP SDK.
32- Writes only to known, allowlisted config file paths - never to
33 arbitrary locations (design decision D-5B-7).
34- ``dry_run=True`` (the default) never modifies any file.
35- If the host config file already contains an ``oct-mcp`` server entry,
36 the entry is updated in-place without duplicating it.
40- oct.core.diagnostics (_dbg structured logger)
43from __future__
import annotations
47from pathlib
import Path
50from oct.core.diagnostics
import _dbg
58 """Return the oct-mcp server entry for the current Python executable."""
60 "command": sys.executable,
61 "args": [
"-m",
"oct.mcp",
"--project-root", str(project_root)],
71SUPPORTED_HOSTS: tuple[str, ...] = (
"claude-code",
"cursor",
"vscode")
75 """Return candidate config file paths for *host* in preference order."""
76 if host ==
"claude-code":
78 if sys.platform ==
"win32":
81 appdata = Path.home() /
"AppData" /
"Roaming" /
"Claude"
83 appdata /
"claude_desktop_config.json",
84 home /
".claude" /
"settings.json",
89 home /
".config" /
"Claude" /
"claude_desktop_config.json",
90 home /
".claude" /
"settings.json",
92 elif host ==
"cursor":
93 return [Path(
".cursor") /
"mcp.json"]
94 elif host ==
"vscode":
95 return [Path(
".vscode") /
"mcp.json"]
100 """Auto-detect the first installed host whose config file exists."""
101 for host
in SUPPORTED_HOSTS:
103 resolved = p
if p.is_absolute()
else (project_root / p)
104 if resolved.exists():
115 """Read JSON from *path*, returning ``{}`` on any error."""
117 return json.loads(path.read_text(encoding=
"utf-8"))
123 """Merge the oct-mcp server entry into *config*.
125 For Claude Code ``settings.json`` the schema is::
127 {"mcpServers": {"oct-mcp": {...}}}
129 For Cursor / VS Code ``mcp.json`` the schema is::
131 {"servers": {"oct-mcp": {...}}}
133 If the key ``"mcpServers"`` is present (or the file is a Claude Code
134 settings.json) we use the Claude Code schema; otherwise we use the
135 Cursor/VS Code schema.
137 updated = dict(config)
139 if "mcpServers" in config
or "mcpServers" not in config
and "servers" not in config:
141 servers = dict(updated.get(
"mcpServers", {}))
142 servers[
"oct-mcp"] = server_entry
143 updated[
"mcpServers"] = servers
145 servers = dict(updated.get(
"servers", {}))
146 servers[
"oct-mcp"] = server_entry
147 updated[
"servers"] = servers
159 dry_run: bool =
True,
161 """Install or preview the oct-mcp server entry for the given host.
166 One of ``"claude-code"``, ``"cursor"``, ``"vscode"``, or
167 ``"auto"`` (auto-detect).
169 Absolute project root path (used as ``--project-root`` arg in
172 When ``True`` (the default), print what would be written without
177 Exit code: 0 on success, 1 on failure.
179 func =
"run_install_mcp"
180 _dbg(
"OCT-CLI", func, f
"host={host} dry_run={dry_run}", 2)
187 "oct install-mcp: could not auto-detect an MCP host. "
188 "Use --host to specify one of: claude-code, cursor, vscode.",
193 print(f
"Detected host: {host}")
195 if host
not in SUPPORTED_HOSTS:
197 f
"oct install-mcp: unsupported host {host!r}. "
198 f
"Supported: {', '.join(SUPPORTED_HOSTS)}",
205 print(f
"oct install-mcp: no config paths defined for host {host!r}", file=sys.stderr)
209 config_path: Path |
None =
None
211 resolved = c
if c.is_absolute()
else (project_root / c)
212 if resolved.parent.exists():
213 config_path = resolved
215 if config_path
is None:
216 config_path = candidates[0]
if candidates[0].is_absolute()
else (project_root / candidates[0])
222 existing =
_read_json_safe(config_path)
if config_path.exists()
else {}
227 output = json.dumps(updated, indent=2)
230 print(f
"[DRY RUN] Would write to: {config_path}")
234 "\nRe-run without --dry-run to apply, "
235 "or use --no-dry-run to apply immediately."
241 config_path.parent.mkdir(parents=
True, exist_ok=
True)
242 config_path.write_text(output +
"\n", encoding=
"utf-8")
243 print(f
"Wrote oct-mcp server entry to: {config_path}")
244 print(
"Restart your AI host to pick up the new configuration.")
245 _dbg(
"OCT-CLI", func, f
"wrote {config_path}", 2)
247 except OSError
as exc:
248 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)
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)