From a5ae7d2641a0ac10291232719bcfdf5479819808 Mon Sep 17 00:00:00 2001 From: Eli Pinkerton Date: Sun, 18 Oct 2026 20:44:31 +0000 Subject: [PATCH] Run MCP configuration through typed Python The universal configurator still owns orchestration in Node after its persistence adapters moved to Python. Move orchestration to typed Python and let the Python sync wrapper work without Node. Retain the old Node command as a launcher with validated Python 3.9 fallback. All ten generated files match the former Node implementation byte for byte for open and authenticated bridge cases, including quoted URLs and non-ASCII workspaces. Dry runs, independent writer failures, logical paths, missing uv, and credential aliases pass their contracts. The sync suite passes on actual Python 3.9.25; strict pyright reports zero errors. The 17-step gate passes the full harness suite. The unchanged C# state passed 395 EditMode and 15 PlayMode tests in the preceding stack layer. Refs #171. The Unity HTTP host remains for a separate migration layer. --- .llm/mcp/README.md | 3 + .llm/mcp/configure.mjs | 458 +------------------------- .llm/mcp/sync_mcp.py | 13 +- scripts/mcp_configure.py | 165 ++++++++++ scripts/mcp_configure.py.meta | 2 + scripts/tests/test_mcp_credentials.py | 7 +- scripts/tests/test_mcp_sync.py | 49 ++- 7 files changed, 212 insertions(+), 485 deletions(-) create mode 100644 scripts/mcp_configure.py create mode 100644 scripts/mcp_configure.py.meta 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)