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