diff --git a/.llm/mcp/README.md b/.llm/mcp/README.md index be64d8a1..e7b7f86c 100644 --- a/.llm/mcp/README.md +++ b/.llm/mcp/README.md @@ -180,3 +180,6 @@ The previous hand-rolled `UnityMcpBridge.cs` (10 tools) is deprecated. If it was Run `npm run mcp:sync`, restart the host bridge and agent sessions, then run `npm run unity:mcp:doctor` from the container. The doctor checks initialization, tool discovery, and the pinned editor. If `/healthz` times out, check the host URL and remove forwarded Unity bridge ports in VS Code's Ports view: reverse tunnels can intercept host traffic. Connect directly to the host bridge. Match health's `bridgePid` to the listener owner; health bypasses authentication and does not start Unity. + +The universal configurator runs in typed Python through `uv run scripts/mcp_configure.py`. The legacy Node entry remains a launcher with Python 3.9+ fallback. The sync wrapper no +longer requires Node. diff --git a/.llm/mcp/configure.mjs b/.llm/mcp/configure.mjs index 81df73f2..45f58982 100644 --- a/.llm/mcp/configure.mjs +++ b/.llm/mcp/configure.mjs @@ -1,444 +1,18 @@ #!/usr/bin/env node -// Universal MCP configurator: registers the project's MCP servers into every -// supported agentic frontend (Claude Code, OpenAI Codex, OpenCode, Nanocoder, -// VS Code, Cursor) with per-CLI native syntax. Machine-local configs are -// regenerated (idempotently, transactionally, chmod 600) so they survive -// devcontainer rebuilds and fresh clones without committing secrets. -// -// Usage: -// node .llm/mcp/configure.mjs [--check] -// --check Print the plan without writing files. -// -// Credentials come from the environment with .env.local fallback (the -// config files reference env vars; nothing secret is written to disk). -// -// Unity uses the official Unity CLI MCP server (`unity mcp`, ~140 tools on a -// typical project). Container frontends reach it through the host HTTP bridge -// (npm run unity:mcp:host → http://host.docker.internal:/mcp, port from -// UNITY_MCP_HTTP_PORT). Every managed frontend shares the pinned bridge; -// unmanaged host GUI clients can use `unity mcp configure `. - -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { execFileSync } from "node:child_process"; +// Preserve existing Node entry points; configuration is owned by typed Python. +import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; - -const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); -const WORKSPACE_ROOT = process.env.DATAVIZ_MCP_WORKSPACE - ? path.resolve(process.env.DATAVIZ_MCP_WORKSPACE) - : path.resolve(SCRIPT_DIR, "..", ".."); -const CHECK_ONLY = process.argv.includes("--check"); - -// --------------------------------------------------------------------------- -// Environment -// --------------------------------------------------------------------------- - -function readEnvLocal(filePath) { - const vars = {}; - let text; - try { - text = fs.readFileSync(filePath, "utf8"); - } catch { - return vars; - } - // Tolerate UTF-8 BOM, CRLF endings, and `export ` prefixes. - text = text.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - for (const rawLine of text.split("\n")) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) continue; - const eq = line.indexOf("="); - if (eq <= 0) continue; - let key = line.slice(0, eq).trim(); - if (key.startsWith("export ")) key = key.slice(7).trim(); - let value = line.slice(eq + 1).trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - if (key) vars[key] = value; - } - return vars; -} - -const envLocal = readEnvLocal(path.join(WORKSPACE_ROOT, ".env.local")); -function env(name) { - return process.env[name] || envLocal[name] || ""; -} - -function isContainer() { - return ( - fs.existsSync("/.dockerenv") || - fs.existsSync("/run/.containerenv") || - process.env.DATAVIZ_IN_CONTAINER === "1" - ); -} - -function hasCommand(command) { - try { - if (process.platform === "win32") { - // Git Bash is not guaranteed on Windows hosts; `where` resolves - // PATH + PATHEXT the same way cmd does. - execFileSync("where", [command], { stdio: "ignore" }); - } else { - execFileSync( - "/bin/bash", - ["-c", `command -v ${(command)} >/dev/null 2>&1`], - { stdio: "ignore" }, - ); - } - return true; - } catch { - return false; - } -} - -function resolveCommand(command) { - // The absolute twin of hasCommand: same PATH + PATHEXT rules, falling - // back to the bare name when the lookup fails. - try { - if (process.platform === "win32") { - const output = execFileSync("where", [command], { encoding: "utf8" }); - const first = String(output) - .split(/\r?\n/) - .find((line) => line.trim()); - return first ? first.trim() : command; - } - return execFileSync("/bin/bash", ["-c", `command -v ${command}`], { encoding: "utf8" }).trim() || command; - } catch { - return command; - } -} - -function ensureUnityMcpToken() { - // Authentication is a host choice. Sync must not introduce a token after - // the bridge has already started without one, or require an absent env - // variable in clients that reject missing bearer_token_env_var values. - return env("UNITY_MCP_TOKEN"); -} - -function synchronizeAliases() { - let launcher = HAS_UV ? [UV_BIN, "run", "--no-project"] : null; - if (!launcher) { - for (const command of ["python3", "python"]) { - try { - const probe = execFileSync(command, ["-c", "import sys; assert sys.version_info >= (3, 9); print('mcp-alias-python')"], { - encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], - }); - if (probe.trim() === "mcp-alias-python") { launcher = [command]; break; } - } catch { - // Validate an interpreter before any credential-file writes. - } - } - } - if (!launcher) throw new Error("Python 3.9+ or uv is required for MCP credential aliases."); - const [command, ...prefix] = launcher; - const script = path.resolve(SCRIPT_DIR, "..", "..", "scripts", "mcp_aliases.py"); - const args = [...prefix, script, "--workspace", WORKSPACE_ROOT]; - if (CHECK_ONLY) args.push("--check"); - return JSON.parse(execFileSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })); -} - -// --------------------------------------------------------------------------- -// Managed server catalog -// --------------------------------------------------------------------------- - -const ZAI_IMAGE_SCRIPT = path.join(WORKSPACE_ROOT, ".llm", "mcp", "zai_image_mcp.py"); -const NODE_BIN = process.execPath; -const HAS_UVX = hasCommand("uvx"); -const HAS_UV = hasCommand("uv"); -const UV_BIN = HAS_UV ? resolveCommand("uv") : "uv"; -// Container agents reach the host HTTP bridge via host.docker.internal; when -// the configurator runs on the host itself, the bridge is plain loopback. The -// port is configurable (UNITY_MCP_HTTP_PORT, process env or .env.local) and is -// dynamically chosen + persisted by unity-mcp-host.mjs when 9020 is busy. -const UNITY_MCP_PORT = Number(env("UNITY_MCP_HTTP_PORT")) || 9020; -// The bridge host must be reachable from BOTH host-side and container-side -// clients reading the same shared machine-local configs. `host.docker.internal` -// resolves on the host itself under Docker Desktop (verified) and inside -// containers, while 127.0.0.1 only works host-side and silently breaks -// container agents — so it is never the default. Override the whole URL with -// UNITY_MCP_HTTP_URL for exotic topologies. -const UNITY_MCP_URL = - env("UNITY_MCP_HTTP_URL") || - `http://host.docker.internal:${UNITY_MCP_PORT}/mcp`; - -function buildCatalog() { - let launcher = HAS_UV ? [UV_BIN, "run", "--no-project"] : null; - if (!launcher) { - for (const command of ["python3", "python"]) { - try { - const result = execFileSync(command, ["-c", "import sys; assert sys.version_info >= (3, 9); print('mcp-catalog-python')"], { - encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], - }); - if (result.trim() === "mcp-catalog-python") { launcher = [command]; break; } - } catch { /* Try the next interpreter before building definitions. */ } - } - } - if (!launcher) throw new Error("Python 3.9+ or uv is required for the MCP catalog."); - const [command, ...prefix] = launcher; - const script = path.resolve(SCRIPT_DIR, "..", "..", "scripts", "mcp_catalog.py"); - const options = { unity_url: UNITY_MCP_URL, unity_auth: Boolean(env("UNITY_MCP_TOKEN")), - has_uv: HAS_UV, has_uvx: HAS_UVX, uv_bin: UV_BIN, image_script: ZAI_IMAGE_SCRIPT }; - const output = execFileSync(command, [...prefix, script], { - input: Buffer.from(JSON.stringify(options), "utf8"), encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], - }); - return new Map(Object.entries(JSON.parse(output))); -} - -// OpenCode 2.x reads V1 config, but the native V2 shape is canonical: -// servers live under mcp.servers, `enabled: true` inverts to `disabled: false`, -// and the single request timeout splits into mcp.timeout.catalog/execution -// (mapping the old value onto both, per the V1→V2 migration guide). -function opencodeServerV2(opencodeDefinition) { - const { enabled, timeout, ...rest } = opencodeDefinition; - const next = { ...rest, disabled: enabled === false ? true : false }; - if (typeof timeout === "number") { - next.timeout = { catalog: timeout, execution: timeout }; - } - return next; -} - -// --------------------------------------------------------------------------- -// Writers -// --------------------------------------------------------------------------- - -function writeAtomic(filePath, contents, mode = 0o600) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const temp = `${filePath}.tmp-${process.pid}`; - fs.writeFileSync(temp, contents, { mode }); - fs.renameSync(temp, filePath); - try { - fs.chmodSync(filePath, mode); - } catch { - // chmod best-effort (Windows). - } -} - -function writeJsonConfigs(managed) { - let launcher = HAS_UV ? [UV_BIN, "run", "--no-project"] : null; - if (!launcher) { - for (const command of ["python3", "python"]) { - try { - const probe = execFileSync(command, ["-c", "import sys; assert sys.version_info >= (3, 9); print('mcp-json-python')"], { - encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], - }); - if (probe.trim() === "mcp-json-python") { - launcher = [command]; - break; - } - } catch { - // Try the next interpreter before any configuration writes. - } - } - } - if (!launcher) throw new Error("Python 3.9+ or uv is required for MCP JSON configuration."); - const [command, ...prefix] = launcher; - const script = path.resolve(SCRIPT_DIR, "..", "..", "scripts", "mcp_json_config.py"); - const args = [...prefix, script, "--workspace", WORKSPACE_ROOT, - "--home", process.env.HOME || os.homedir(), - "--nano-home", process.env.NANOCODER_CONFIG_DIR || path.join(os.homedir(), ".config", "nanocoder")]; - if (CHECK_ONLY) args.push("--check"); - return JSON.parse(execFileSync(command, args, { input: JSON.stringify(managed), encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] })); -} - -// Codex persistence is typed Python; keep the Node server catalog until its port. -let codexWriterLauncher; -function codexConfig(operation, entries = {}) { - if (!codexWriterLauncher) { - if (HAS_UV) { - codexWriterLauncher = [UV_BIN, "run", "--no-project"]; - } else { - // uv-gated servers remain optional. Probe execution because Windows - // may expose a python3 App Execution Alias without an interpreter. - for (const command of ["python3", "python"]) { - try { - execFileSync(command, ["-c", "import sys; assert sys.version_info >= (3, 9)"], { stdio: "ignore" }); - codexWriterLauncher = [command]; - break; - } catch { - // Try the next interpreter before reporting a missing tool. - } - } - } - } - if (!codexWriterLauncher) throw new Error("Python 3.9+ or uv is required for Codex MCP configuration."); - const [command, ...prefix] = codexWriterLauncher; - const script = path.resolve(SCRIPT_DIR, "..", "..", "scripts", "mcp_codex_config.py"); - const args = [...prefix, script, operation, "--workspace", WORKSPACE_ROOT]; - if (CHECK_ONLY) args.push("--check"); - return JSON.parse(execFileSync(command, args, { - input: JSON.stringify(entries), - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - })); -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -function main() { - const unityToken = ensureUnityMcpToken(); - const aliases = synchronizeAliases(); - const zaiKeyStatus = aliases.zai; - const githubPatStatus = aliases.github; - const catalog = buildCatalog(); - - const managedClaude = {}; - const managedCodex = {}; - const managedOpencode = {}; - const managedNanocoder = {}; - const managedVscode = {}; - const managedCursor = {}; - for (const [name, definition] of catalog) { - managedClaude[name] = definition.claude; - managedCodex[name] = definition.codex; - managedOpencode[name] = opencodeServerV2(definition.opencode); - managedNanocoder[name] = definition.nanocoder; - // VS Code requires a command string plus args. OpenCode's command - // array shape is not a valid VS Code stdio server definition. - const vscode = definition.vscode; - managedVscode[name] = Array.isArray(vscode.command) - ? { ...vscode, command: vscode.command[0], args: vscode.command.slice(1) } - : vscode; - if (definition.cursor) managedCursor[name] = definition.cursor; - } - - let jsonOutcomes; - let jsonFailure; - const writeJson = (frontend) => { - if (jsonFailure) throw jsonFailure; - if (!jsonOutcomes) { - try { - jsonOutcomes = writeJsonConfigs({ - claude: managedClaude, opencode: managedOpencode, nanocoder: managedNanocoder, - vscode: managedVscode, cursor: managedCursor, - }); - } catch (error) { - jsonFailure = error; - throw error; - } - } - const outcome = jsonOutcomes[frontend]; - if (outcome.error) throw new Error(outcome.error); - return outcome.changed; - }; - - const results = []; - let codexWorkspaceOutcome = { changed: false, note: "" }; - const plan = [ - { - label: "Claude Code (.mcp.json + project approval)", - write: () => writeJson("claude"), - }, - { - label: `Codex (${process.env.CODEX_HOME || "~/.codex"}/config.toml managed block)`, - write: () => codexConfig("write", managedCodex).changed, - }, - { - label: "Codex workspace (.codex/config.toml pruned — Codex 0.153+ merges it)", - write: () => { - codexWorkspaceOutcome = codexConfig("prune"); - return codexWorkspaceOutcome.changed; - }, - after: () => { - if (codexWorkspaceOutcome.note) { - results.push( - `${codexWorkspaceOutcome.changed ? "✅" : "·"} ↳ ${codexWorkspaceOutcome.note}`, - ); - } - }, - }, - { - label: "OpenCode (opencode.json mcp.servers block)", - write: () => writeJson("opencode"), - }, - { - label: "Nanocoder (.nanocoder/mcp.json + trust seed + providers)", - write: () => writeJson("nanocoder"), - }, - { - label: "VS Code (.vscode/mcp.json)", - write: () => writeJson("vscode"), - }, - { - // Project scope: Cursor on the host reads this shared file. It - // launches commands on the host, so stdio servers must reference - // host-visible binaries (`unity`, npx, uvx) — container-absolute - // paths (zai-image) are intentionally omitted here. - label: "Cursor (.cursor/mcp.json, project scope)", - write: () => writeJson("cursor"), - }, - ]; - - let failed = false; - for (const entry of plan) { - try { - const changed = entry.write(); - results.push(`${changed ? "✅" : "·"} ${entry.label}${changed && !CHECK_ONLY ? " updated" : ""}`); - if (entry.after) entry.after(); - } catch (error) { - failed = true; - results.push(`❌ ${entry.label}: ${error.message}`); - } - } - if (failed) process.exitCode = 1; - - const container = isContainer(); - console.log(""); - console.log("╔════════════════════════════════════════════════════════════════╗"); - console.log("║ MCP Server Configuration ║"); - console.log("╚════════════════════════════════════════════════════════════════╝"); - for (const line of results) console.log(` ${line}`); - console.log(""); - console.log(` Servers: ${[...catalog.keys()].join(", ")}`); - if (!HAS_UVX) { - console.log(" (fetch/git skipped: uvx not found on PATH)"); - } - if (!HAS_UV) { - console.log(" (zai-image skipped: uv not found on PATH)"); - } - console.log( - ` UNITY_MCP_TOKEN: ${unityToken ? "configured" : "OPEN (no token)"}`, - ); - const describeZai = (status) => { - if (status === "missing") return "MISSING (zai-* servers will fail auth)"; - if (status === "divergent") { - return ( - "DIVERGENT (ZAI_API_KEY != Z_AI_API_KEY — consumers read different " + - "values; run `npm run mcp:doctor --repair`)" - ); - } - return status; - }; - const describeGithub = (status) => { - if (status === "missing") return "MISSING (github MCP will fail auth with 401)"; - if (status === "divergent") { - return ( - "DIVERGENT (GITHUB_PERSONAL_ACCESS_TOKEN != GITHUB_MCP_PAT — configs use " + - "GITHUB_PERSONAL_ACCESS_TOKEN, so a stale value there makes github MCP " + - "return 401; run `npm run mcp:doctor --repair`)" - ); - } - return status; - }; - console.log(` ZAI_API_KEY: ${describeZai(zaiKeyStatus)}`); - console.log(` GITHUB_PERSONAL_ACCESS_TOKEN: ${describeGithub(githubPatStatus)}`); - console.log( - ` Environment: ${container ? "devcontainer" : "host"} (shared configs use environment-agnostic URLs; bridge: ${UNITY_MCP_URL})`, - ); - console.log( - ` Unity MCP: official Unity CLI (~140 tools) via ${UNITY_MCP_URL} — start the bridge with \`npm run unity:mcp:host\` on the host.`, - ); - console.log( - " Managed Cursor shares the bridge; unmanaged host clients can run `unity mcp configure ` for native stdio.", - ); - console.log(""); - console.log(" Restart your agent CLIs (or run /mcp) to pick up changes."); - console.log(""); -} - -main(); +const script = fileURLToPath(new URL("../../scripts/mcp_configure.py", import.meta.url)); +for (const [command, prefix, probe] of [ + ["uv", ["run", "--no-project"], ["--version"]], + ["python3", [], ["-c", "import sys; assert sys.version_info >= (3, 9)"]], + ["python", [], ["-c", "import sys; assert sys.version_info >= (3, 9)"]], +]) { + const available = spawnSync(command, probe, { stdio: "ignore" }); + if (available.status !== 0) continue; + const result = spawnSync(command, [...prefix, script, ...process.argv.slice(2)], { stdio: "inherit" }); + if (result.error) console.error("Cannot start MCP configuration: " + result.error.message); + process.exit(result.status ?? 1); +} +console.error("Python 3.9+ or uv is required for MCP configuration."); +process.exit(1); diff --git a/.llm/mcp/sync_mcp.py b/.llm/mcp/sync_mcp.py index a2dc3d8b..8926f7df 100644 --- a/.llm/mcp/sync_mcp.py +++ b/.llm/mcp/sync_mcp.py @@ -1,14 +1,13 @@ #!/usr/bin/env python3 """Sync frontend MCP configs from an optional workspace (issue #171). -Usage: uv run .llm/mcp/sync_mcp.py [workspace_dir] [configure.mjs flags...] +Usage: uv run .llm/mcp/sync_mcp.py [workspace_dir] [configuration flags...] A leading-dash argument belongs to the configurator, not the workspace. """ from __future__ import annotations import os -import shutil import subprocess import sys from pathlib import Path @@ -16,7 +15,7 @@ def main(arguments: Sequence[str]) -> int: - """Run the adjacent Node configurator with the wrapper's workspace contract.""" + """Run the typed Python configurator with the wrapper's workspace contract.""" args = list(arguments) # Trust entries use the workspace spelling opened by the agent, so retain # symlinks in both an explicit workspace and a script-relative default. @@ -24,21 +23,17 @@ def main(arguments: Sequence[str]) -> int: workspace = script_dir.parent.parent if args and args[0] and not args[0].startswith("-"): workspace = Path(os.path.abspath(args.pop(0))) - node = shutil.which("node") - if node is None: - print("❌ node is required to sync MCP configuration.", file=sys.stderr) - return 1 env = dict(os.environ) env["DATAVIZ_MCP_WORKSPACE"] = str(workspace) try: return subprocess.run( - [node, str(script_dir / "configure.mjs"), *args], + [sys.executable, str(script_dir.parent.parent / "scripts" / "mcp_configure.py"), *args], cwd=workspace, env=env, check=False, ).returncode except OSError as error: - print(f"❌ Cannot sync MCP configuration: {error}", file=sys.stderr) + sys.stderr.buffer.write(f"❌ Cannot sync MCP configuration: {error}\n".encode("utf-8", errors="replace")) return 1 diff --git a/scripts/mcp_configure.py b/scripts/mcp_configure.py new file mode 100644 index 00000000..fc97bb93 --- /dev/null +++ b/scripts/mcp_configure.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Configure every managed MCP frontend through typed Python (issue #171).""" +from __future__ import annotations + +import json +import math +import os +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence, cast + +sys.dont_write_bytecode = True +from mcp_aliases import read_values + +ROOT = Path(__file__).resolve().parent.parent +JsonMap = Dict[str, Any] + + +def run_json(script: str, arguments: Sequence[str], payload: Optional[object] = None) -> JsonMap: + result = subprocess.run([sys.executable, str(ROOT / 'scripts' / script), *arguments], + input=None if payload is None else json.dumps(payload, ensure_ascii=False).encode('utf-8'), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if result.returncode: + raise ValueError(result.stderr.decode('utf-8', errors='replace').strip() or f'{script} failed') + value: object = json.loads(result.stdout.decode('utf-8')) + if not isinstance(value, dict): + raise ValueError(f'{script} returned no configuration object') + return cast(JsonMap, value) + + +def opencode_v2(definition: Mapping[str, Any]) -> JsonMap: + value = {key: item for key, item in definition.items() if key not in ('enabled', 'timeout')} + value['disabled'] = definition.get('enabled') is False + timeout = definition.get('timeout') + if isinstance(timeout, (int, float)) and not isinstance(timeout, bool): + value['timeout'] = {'catalog': timeout, 'execution': timeout} + return value + + +def configure(workspace: Path, check_only: bool) -> int: + try: + local = read_values((workspace / '.env.local').read_bytes().decode('utf-8', errors='replace')) + except OSError: + local = {} + + def env(name: str, default: str = '') -> str: + return os.environ.get(name) or local.get(name) or default + + flags = ['--check'] if check_only else [] + workspace_args = ['--workspace', str(workspace)] + aliases = run_json('mcp_aliases.py', [*workspace_args, *flags]) + uv = shutil.which('uv') + uvx = shutil.which('uvx') + try: + port = float(env('UNITY_MCP_HTTP_PORT')) + port_text = (str(int(port)) if port.is_integer() else str(port)) if math.isfinite(port) else '9020' + except ValueError: + port_text = '9020' + if port_text == '0': + port_text = '9020' + unity_url = env('UNITY_MCP_HTTP_URL', f'http://host.docker.internal:{port_text}/mcp') + token = env('UNITY_MCP_TOKEN') + catalog = run_json('mcp_catalog.py', [], { + 'unity_url': unity_url, 'unity_auth': bool(token), 'has_uv': uv is not None, + 'has_uvx': uvx is not None, 'uv_bin': uv or 'uv', + 'image_script': str(workspace / '.llm' / 'mcp' / 'zai_image_mcp.py'), + }) + managed: Dict[str, JsonMap] = {name: {} for name in ('claude', 'codex', 'opencode', 'nanocoder', 'vscode', 'cursor')} + for name, definition in catalog.items(): + for frontend in ('claude', 'codex', 'nanocoder'): + managed[frontend][name] = definition[frontend] + managed['opencode'][name] = opencode_v2(definition['opencode']) + vscode = dict(definition['vscode']) + command = vscode.get('command') + if isinstance(command, list): + vscode.update(command=command[0], args=command[1:]) + managed['vscode'][name] = vscode + if 'cursor' in definition: + managed['cursor'][name] = definition['cursor'] + + json_outcomes: JsonMap = {} + json_failure = '' + home = Path(os.environ.get('HOME') or Path.home()) + nano_home = Path(os.environ.get('NANOCODER_CONFIG_DIR') or Path.home() / '.config' / 'nanocoder') + try: + json_outcomes = run_json('mcp_json_config.py', [*workspace_args, '--home', str(home), '--nano-home', str(nano_home), *flags], + {key: value for key, value in managed.items() if key != 'codex'}) + except (OSError, ValueError) as error: + json_failure = str(error) + + plan = [ + ('Claude Code (.mcp.json + project approval)', 'claude'), + (f"Codex ({os.environ.get('CODEX_HOME') or '~/.codex'}/config.toml managed block)", 'codex'), + ('Codex workspace (.codex/config.toml pruned — Codex 0.153+ merges it)', 'prune'), + ('OpenCode (opencode.json mcp.servers block)', 'opencode'), + ('Nanocoder (.nanocoder/mcp.json + trust seed + providers)', 'nanocoder'), + ('VS Code (.vscode/mcp.json)', 'vscode'), + ('Cursor (.cursor/mcp.json, project scope)', 'cursor'), + ] + failed = False + results: List[str] = [] + for label, frontend in plan: + try: + if frontend == 'codex': + outcome = run_json('mcp_codex_config.py', ['write', *workspace_args, *flags], managed['codex']) + elif frontend == 'prune': + outcome = run_json('mcp_codex_config.py', ['prune', *workspace_args, *flags], {}) + else: + if json_failure: + raise ValueError(json_failure) + outcome = json_outcomes[frontend] + if outcome['error']: + raise ValueError(outcome['error']) + changed = outcome['changed'] + results.append(f"{'✅' if changed else '·'} {label}{' updated' if changed and not check_only else ''}") + if frontend == 'prune' and outcome.get('note'): + results.append(f"{'✅' if changed else '·'} ↳ {outcome['note']}") + except (OSError, ValueError, KeyError, TypeError) as error: + failed = True + results.append(f'❌ {label}: {error}') + + lines = ['', '╔════════════════════════════════════════════════════════════════╗', + '║ MCP Server Configuration ║', + '╚════════════════════════════════════════════════════════════════╝', + *(' ' + result for result in results), '', ' Servers: ' + ', '.join(catalog)] + if uvx is None: + lines.append(' (fetch/git skipped: uvx not found on PATH)') + if uv is None: + lines.append(' (zai-image skipped: uv not found on PATH)') + lines.append(' UNITY_MCP_TOKEN: ' + ('configured' if token else 'OPEN (no token)')) + for label, key, primary, alternate in ( + ('ZAI_API_KEY', 'zai', 'ZAI_API_KEY', 'Z_AI_API_KEY'), + ('GITHUB_PERSONAL_ACCESS_TOKEN', 'github', 'GITHUB_PERSONAL_ACCESS_TOKEN', 'GITHUB_MCP_PAT'), + ): + status = aliases[key] + if status == 'missing': + status = 'MISSING (zai-* servers will fail auth)' if key == 'zai' else 'MISSING (github MCP will fail auth with 401)' + elif status == 'divergent': + status = (f'DIVERGENT ({primary} != {alternate} — consumers read different values; ' + 'run `npm run mcp:doctor --repair`)') + lines.append(f' {label}: {status}') + container = Path('/.dockerenv').exists() or Path('/run/.containerenv').exists() or os.environ.get('DATAVIZ_IN_CONTAINER') == '1' + lines.extend([ + f" Environment: {'devcontainer' if container else 'host'} (shared configs use environment-agnostic URLs; bridge: {unity_url})", + f' Unity MCP: official Unity CLI (~140 tools) via {unity_url} — start the bridge with `npm run unity:mcp:host` on the host.', + ' Managed Cursor shares the bridge; unmanaged host clients can run `unity mcp configure ` for native stdio.', + '', ' Restart your agent CLIs (or run /mcp) to pick up changes.', '', + ]) + sys.stdout.buffer.write(('\n'.join(lines) + '\n').encode('utf-8')) + return 1 if failed else 0 + + +def main(arguments: Optional[Sequence[str]] = None) -> int: + workspace = Path(os.path.abspath(os.environ.get('DATAVIZ_MCP_WORKSPACE') or ROOT)) + try: + return configure(workspace, '--check' in (sys.argv[1:] if arguments is None else arguments)) + except (OSError, ValueError, KeyError, TypeError) as error: + sys.stderr.buffer.write(f'Cannot sync MCP configuration: {error}\n'.encode('utf-8', errors='replace')) + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/mcp_configure.py.meta b/scripts/mcp_configure.py.meta new file mode 100644 index 00000000..e8353b1e --- /dev/null +++ b/scripts/mcp_configure.py.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 76f9be23fe5b4a2480dbb65d819075dc diff --git a/scripts/tests/test_mcp_credentials.py b/scripts/tests/test_mcp_credentials.py index 0b100b59..97a434a1 100644 --- a/scripts/tests/test_mcp_credentials.py +++ b/scripts/tests/test_mcp_credentials.py @@ -15,7 +15,7 @@ Everything runs in a throwaway workspace with throwaway HOME and synthetic placeholder credentials. No real secret is read, written, or printed. -Configurator cases skip when Node is unavailable; doctor cases use Python. +Configurator and doctor cases run through Python. """ from __future__ import annotations @@ -25,7 +25,6 @@ import json import os import re -import shutil import socket import subprocess import sys @@ -43,7 +42,7 @@ REPO_ROOT = TESTS_DIR.parent.parent SYNC = REPO_ROOT / ".llm" / "mcp" / "sync_mcp.py" DOCTOR = REPO_ROOT / "scripts" / "mcp_doctor.py" -CONFIGURE = REPO_ROOT / ".llm" / "mcp" / "configure.mjs" +CONFIGURE = REPO_ROOT / "scripts" / "mcp_configure.py" # Synthetic, obviously-fake placeholders. These are NOT credentials. STALE = "ghp_0000000000000000000000000000000000" @@ -495,7 +494,7 @@ def main() -> int: assert_true(SYNC.is_file(), f"sync script not found at {SYNC}") assert_true(DOCTOR.is_file(), f"doctor not found at {DOCTOR}") print("== mcp-credentials contract tests ==") - cases = CASES if shutil.which("node") else tuple(case for case in CASES if not case.__name__.startswith("case_sync_")) + cases = CASES failures = run_cases([(case.__name__, case) for case in cases]) print(f"== mcp-credentials: {failures} failure(s) ==") return 1 if failures else 0 diff --git a/scripts/tests/test_mcp_sync.py b/scripts/tests/test_mcp_sync.py index b47be511..93c42a0e 100644 --- a/scripts/tests/test_mcp_sync.py +++ b/scripts/tests/test_mcp_sync.py @@ -1,16 +1,9 @@ #!/usr/bin/env python3 """mcp-sync contract tests (typed Python port of ``test-mcp-sync.sh``, issue #171). -Every case from the bash original is kept with the same assertions. -The Python sync entry drives the unchanged Node configurator -(``sync_mcp.py`` driving ``configure.mjs``), so each case still syncs into -a throwaway workspace with throwaway HOME and CODEX_HOME, and asserts -that every supported agent frontend receives the same server fleet with -placeholder-only credentials (no secrets on disk). The bash suite's ``jq`` -probes become stdlib ``json`` reads, which also removes the jq dependency -the old suite silently required. - -Integration cases require node; the wrapper itself requires only Python. +The Python sync entry drives the typed configurator in throwaway workspaces. +Each case verifies frontend formats, placeholder-only credentials, and failures. +The legacy Node entry remains covered separately by its fallback contracts. """ from __future__ import annotations @@ -330,9 +323,11 @@ def case_wrapper_forwards_workspace_flags_and_exit_status() -> None: script_dir.mkdir(parents=True) wrapper = script_dir / SYNC.name shutil.copyfile(SYNC, wrapper) - (script_dir / "configure.mjs").write_text( - "console.log(JSON.stringify({args:process.argv.slice(2),cwd:process.cwd()," - "workspace:process.env.DATAVIZ_MCP_WORKSPACE}));process.exit(7);\n", + subject = root / "scripts" / "mcp_configure.py" + subject.parent.mkdir() + subject.write_text( + "import json, os, sys\nprint(json.dumps(dict(args=sys.argv[1:], cwd=os.getcwd(), " + "workspace=os.environ['DATAVIZ_MCP_WORKSPACE'])))\nsys.exit(7)\n", encoding="utf-8", ) other = root / "other workspace" @@ -381,17 +376,16 @@ def case_wrapper_forwards_workspace_flags_and_exit_status() -> None: remove_temp_root(root) -def case_wrapper_fails_without_node() -> None: - completed = subprocess.run( - [sys.executable, str(SYNC), "--check"], - env={**os.environ, "PATH": ""}, - capture_output=True, - text=True, - encoding="utf-8", - check=False, - ) - assert_eq("missing node exits one", 1, completed.returncode) - assert_true("node is required to sync MCP configuration." in completed.stderr, completed.stderr) +def case_wrapper_succeeds_without_node() -> None: + sandbox = Sandbox() + try: + with patch.dict(os.environ, {'PATH': ''}): + sandbox.run_sync('--check') + assert_eq("Python sync succeeds without Node", 0, sandbox.last_exit, sandbox.run_failure_context()) + assert_true("Servers: unity, github, context7, zai-vision, zai-web-search" in sandbox.stdout, sandbox.stdout) + assert_true(not sandbox.path('.mcp.json').exists(), 'A direct Python dry run must not write configuration.') + finally: + sandbox.destroy() def case_codex_persistence_uses_python_when_uv_is_absent() -> None: @@ -402,7 +396,6 @@ def case_codex_persistence_uses_python_when_uv_is_absent() -> None: try: binary = sandbox.path('bin') binary.mkdir() - (binary / 'node').symlink_to(shutil.which('node') or 'node') (binary / 'python').symlink_to(sys.executable) with patch.dict(os.environ, {'PATH': str(binary)}): sandbox.run_sync() @@ -467,11 +460,7 @@ def case_successful_and_unchanged_syncs_exit_zero() -> None: def main() -> int: assert_true(SYNC.is_file(), f"sync script not found at {SYNC}") print("== mcp-sync contract tests ==") - run_case("case_wrapper_fails_without_node", case_wrapper_fails_without_node) - missing = shutil.which("node") is None - if missing: - print(" SKIP: node is required to drive the configurator.") - return 1 if failure_count() else 0 + run_case("case_wrapper_succeeds_without_node", case_wrapper_succeeds_without_node) run_case('case_unity_auth_and_url_are_consistent', case_unity_auth_and_url_are_consistent) run_case("case_wrapper_forwards_workspace_flags_and_exit_status", case_wrapper_forwards_workspace_flags_and_exit_status) run_case('case_codex_persistence_uses_python_when_uv_is_absent', case_codex_persistence_uses_python_when_uv_is_absent)