oct
Loading...
Searching...
No Matches
install_mcp.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oct/tools/install_mcp.py
4
5"""
6Purpose
7-------
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.
11
12Responsibilities
13----------------
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.
20
21Diagnostics
22-----------
23Domain: OCT-CLI
24Levels:
25 L2 - lifecycle: file read, file written
26 L3 - (none): this module emits lifecycle only
27 L4 - (none): no deep trace is emitted
28
29Contracts
30---------
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.
37
38Dependencies
39------------
40- oct.core.diagnostics (_dbg structured logger)
41"""
42
43from __future__ import annotations
44
45import json
46import sys
47from pathlib import Path
48from typing import Any
49
50from oct.core.diagnostics import _dbg
51
52
53# ---------------------------------------------------------------------
54# MCP server entry for oct-mcp
55# ---------------------------------------------------------------------
56
57def _make_oct_mcp_entry(project_root: Path) -> dict:
58 """Return the oct-mcp server entry for the current Python executable."""
59 return {
60 "command": sys.executable,
61 "args": ["-m", "oct.mcp", "--project-root", str(project_root)],
62 "type": "stdio",
63 }
64
65
66# ---------------------------------------------------------------------
67# Host definitions
68# ---------------------------------------------------------------------
69
70#: Supported MCP host names.
71SUPPORTED_HOSTS: tuple[str, ...] = ("claude-code", "cursor", "vscode")
72
73
74def _get_host_config_paths(host: str) -> list[Path]:
75 """Return candidate config file paths for *host* in preference order."""
76 if host == "claude-code":
77 home = Path.home()
78 if sys.platform == "win32":
79 # Claude Code on Windows: %APPDATA%\Claude\claude_desktop_config.json
80 # and the local settings.json
81 appdata = Path.home() / "AppData" / "Roaming" / "Claude"
82 return [
83 appdata / "claude_desktop_config.json",
84 home / ".claude" / "settings.json",
85 ]
86 else:
87 # macOS / Linux
88 return [
89 home / ".config" / "Claude" / "claude_desktop_config.json",
90 home / ".claude" / "settings.json",
91 ]
92 elif host == "cursor":
93 return [Path(".cursor") / "mcp.json"]
94 elif host == "vscode":
95 return [Path(".vscode") / "mcp.json"]
96 return []
97
98
99def _detect_host(project_root: Path) -> str | None:
100 """Auto-detect the first installed host whose config file exists."""
101 for host in SUPPORTED_HOSTS:
102 for p in _get_host_config_paths(host):
103 resolved = p if p.is_absolute() else (project_root / p)
104 if resolved.exists():
105 return host
106 return None
107
108
109# ---------------------------------------------------------------------
110# Config file merge
111# ---------------------------------------------------------------------
112
113
114def _read_json_safe(path: Path) -> dict:
115 """Read JSON from *path*, returning ``{}`` on any error."""
116 try:
117 return json.loads(path.read_text(encoding="utf-8"))
118 except Exception:
119 return {}
120
121
122def _merge_server_entry(config: dict, server_entry: dict) -> dict:
123 """Merge the oct-mcp server entry into *config*.
124
125 For Claude Code ``settings.json`` the schema is::
126
127 {"mcpServers": {"oct-mcp": {...}}}
128
129 For Cursor / VS Code ``mcp.json`` the schema is::
130
131 {"servers": {"oct-mcp": {...}}}
132
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.
136 """
137 updated = dict(config)
138 # Detect schema type
139 if "mcpServers" in config or "mcpServers" not in config and "servers" not in config:
140 # Default to Claude Code schema
141 servers = dict(updated.get("mcpServers", {}))
142 servers["oct-mcp"] = server_entry
143 updated["mcpServers"] = servers
144 else:
145 servers = dict(updated.get("servers", {}))
146 servers["oct-mcp"] = server_entry
147 updated["servers"] = servers
148 return updated
149
150
151# ---------------------------------------------------------------------
152# Main entry point
153# ---------------------------------------------------------------------
154
155
157 host: str,
158 project_root: Path,
159 dry_run: bool = True,
160) -> int:
161 """Install or preview the oct-mcp server entry for the given host.
162
163 Parameters
164 ----------
165 host
166 One of ``"claude-code"``, ``"cursor"``, ``"vscode"``, or
167 ``"auto"`` (auto-detect).
168 project_root
169 Absolute project root path (used as ``--project-root`` arg in
170 the server entry).
171 dry_run
172 When ``True`` (the default), print what would be written without
173 modifying any files.
174
175 Returns
176 -------
177 Exit code: 0 on success, 1 on failure.
178 """
179 func = "run_install_mcp"
180 _dbg("OCT-CLI", func, f"host={host} dry_run={dry_run}", 2)
181
182 # Resolve host
183 if host == "auto":
184 detected = _detect_host(project_root)
185 if detected is None:
186 print(
187 "oct install-mcp: could not auto-detect an MCP host. "
188 "Use --host to specify one of: claude-code, cursor, vscode.",
189 file=sys.stderr,
190 )
191 return 1
192 host = detected
193 print(f"Detected host: {host}")
194
195 if host not in SUPPORTED_HOSTS:
196 print(
197 f"oct install-mcp: unsupported host {host!r}. "
198 f"Supported: {', '.join(SUPPORTED_HOSTS)}",
199 file=sys.stderr,
200 )
201 return 1
202
203 candidates = _get_host_config_paths(host)
204 if not candidates:
205 print(f"oct install-mcp: no config paths defined for host {host!r}", file=sys.stderr)
206 return 1
207
208 # Pick the first candidate whose parent exists, or the first one overall.
209 config_path: Path | None = None
210 for c in candidates:
211 resolved = c if c.is_absolute() else (project_root / c)
212 if resolved.parent.exists():
213 config_path = resolved
214 break
215 if config_path is None:
216 config_path = candidates[0] if candidates[0].is_absolute() else (project_root / candidates[0])
217
218 # Build the server entry
219 server_entry = _make_oct_mcp_entry(project_root)
220
221 # Read existing config (empty dict if file not found)
222 existing = _read_json_safe(config_path) if config_path.exists() else {}
223
224 # Merge
225 updated = _merge_server_entry(existing, server_entry)
226
227 output = json.dumps(updated, indent=2)
228
229 if dry_run:
230 print(f"[DRY RUN] Would write to: {config_path}")
231 print("Content:")
232 print(output)
233 print(
234 "\nRe-run without --dry-run to apply, "
235 "or use --no-dry-run to apply immediately."
236 )
237 return 0
238
239 # Write
240 try:
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)
246 return 0
247 except OSError as exc:
248 print(f"oct install-mcp: failed to write {config_path}: {exc}", file=sys.stderr)
249 return 1
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)