From b5db6de5ddbb6000783f4ebccc3a14710a82761b Mon Sep 17 00:00:00 2001 From: wallstop Date: Thu, 3 Sep 2026 20:23:12 +0000 Subject: [PATCH] Install shared MCP servers in devcontainer --- .devcontainer/Dockerfile | 15 +- .devcontainer/devcontainer.json | 9 +- .devcontainer/install-agent-clis.sh | 39 +++-- .devcontainer/post-create.sh | 49 ++++-- .devcontainer/post-start.sh | 63 +++++--- cspell.json | 4 +- docs/guides/mcp-local-setup.md | 53 ++++++- package.json | 3 + scripts/mcp/README.md | 66 ++++++-- scripts/mcp/github-mcp.mjs | 109 +++++++++++++ scripts/mcp/github-mcp.mjs.meta | 7 + scripts/mcp/github-mcp.sh | 23 +-- scripts/mcp/unity-mcp.mjs | 176 +++++++++++++++++---- scripts/mcp/zai-mcp.mjs | 142 +++++++++++++++++ scripts/mcp/zai-mcp.mjs.meta | 7 + scripts/run-contract-tests.js | 2 + scripts/tests/test-github-mcp.mjs | 103 ++++++++++++ scripts/tests/test-github-mcp.mjs.meta | 7 + scripts/tests/test-post-create.sh | 52 ++++++ scripts/tests/test-unity-mcp.mjs | 97 ++++++++++++ scripts/tests/test-validate-mcp-config.ps1 | 21 ++- scripts/tests/test-zai-mcp.mjs | 118 ++++++++++++++ scripts/tests/test-zai-mcp.mjs.meta | 7 + scripts/validate-mcp-config.ps1 | 35 ++-- 24 files changed, 1055 insertions(+), 152 deletions(-) create mode 100644 scripts/mcp/github-mcp.mjs create mode 100644 scripts/mcp/github-mcp.mjs.meta create mode 100644 scripts/mcp/zai-mcp.mjs create mode 100644 scripts/mcp/zai-mcp.mjs.meta create mode 100644 scripts/tests/test-github-mcp.mjs create mode 100644 scripts/tests/test-github-mcp.mjs.meta create mode 100644 scripts/tests/test-zai-mcp.mjs create mode 100644 scripts/tests/test-zai-mcp.mjs.meta diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 1fc50783..99bb610f 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -287,6 +287,7 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x -o /tmp/nodesource_setup.sh RUN mkdir -p /home/vscode/.npm \ && mkdir -p /home/vscode/.nuget/packages \ && mkdir -p /home/vscode/.cache/pip \ + && mkdir -p /home/vscode/.cache/unity-helpers-devcontainer \ && mkdir -p /home/vscode/.unity-test-project \ && chown -R vscode:vscode /home/vscode/.npm /home/vscode/.nuget /home/vscode/.cache /home/vscode/.unity-test-project @@ -301,17 +302,21 @@ RUN dotnet tool install -g csharpier --version 1.2.6 # added below (ENV for processes, profile.d for login shells). RUN npm config set prefix "${HOME}/.local" -# Pre-install the AI coding agent CLIs so a freshly built/rebuilt image has them -# even before post-create runs. post-create.sh/post-start.sh refresh these to the -# latest versions (via .devcontainer/install-agent-clis.sh), so this layer is a -# fast offline-safe baseline rather than the source of truth. +# Pre-install the AI coding agent CLIs and local MCP runtime packages so a freshly +# built/rebuilt image has them even before post-create runs. post-create.sh and +# post-start.sh refresh these to the latest versions, so this layer is a fast +# offline-safe baseline rather than the source of truth. RUN npm install -g --no-fund --no-audit \ "@openai/codex@latest" \ "opencode-ai@latest" \ "@nanocollective/nanocoder@latest" \ + "@z_ai/mcp-server@latest" \ + "mcp-remote@latest" \ && "$(npm prefix -g)/bin/codex" --version \ && "$(npm prefix -g)/bin/opencode" --version \ - && "$(npm prefix -g)/bin/nanocoder" --version + && "$(npm prefix -g)/bin/nanocoder" --version \ + && test -x "$(npm prefix -g)/bin/zai-mcp-server" \ + && "$(npm prefix -g)/bin/mcp-remote" --version # Switch back to root for remaining setup USER root diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5601716d..bab5dd83 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -47,6 +47,8 @@ "remoteEnv": { "GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}", "GH_TOKEN": "${localEnv:GH_TOKEN}", + "GITHUB_PERSONAL_ACCESS_TOKEN": "${localEnv:GITHUB_PERSONAL_ACCESS_TOKEN}", + "Z_AI_API_KEY": "${localEnv:Z_AI_API_KEY}", // Credentials resolve from a 0600 cache; an EMPTY cache used to fall through to the editor's // askpass, which raises a dialog on the owner's desktop that no credential helper can suppress // (#450). This makes the fallback an error naming the fix instead. The Git extension sets its @@ -65,12 +67,12 @@ // 1. Fix volume mount permissions (Docker named volumes are root-owned) // 2. Restore .NET tools (CSharpier, etc.) // 3. Install npm dependencies (markdownlint-cli, cspell, prettier) - // 4. Install/update AI coding agent CLIs (codex, opencode, nanocoder — latest via npm) + // 4. Install/update AI coding agent CLIs and MCP runtimes (latest via npm) // 5. Check Codex authentication state (advisory) - // 6. Sync Unity MCP client configs from .env.local (all agents) + // 6. Sync Unity and shared MCP client configs (all agents) // 7. Install git hooks // 8. Mark workspace as safe directory - // 9. Pre-pull Unity Docker image (background, non-blocking) + // 9. Pre-pull Unity and GitHub MCP Docker images (background, non-blocking) "postCreateCommand": "bash .devcontainer/post-create.sh", // Runs on every container start to verify/repair agent CLI availability and // re-assert volume ownership Docker may have reset (keeps `npm install` sudo-free). @@ -467,6 +469,7 @@ "source=${localWorkspaceFolder}/.git,target=${containerWorkspaceFolder}/.git,type=bind,consistency=cached", "source=unity-helpers-npm-cache,target=/home/vscode/.npm,type=volume", "source=unity-helpers-pip-cache,target=/home/vscode/.cache/pip,type=volume", + "source=unity-helpers-agent-state,target=/home/vscode/.cache/unity-helpers-devcontainer,type=volume", "source=unity-helpers-nuget-cache,target=/home/vscode/.nuget/packages,type=volume", "source=unity-helpers-docker-cache,target=/var/lib/docker,type=volume", "source=unity-helpers-unity-project,target=/home/vscode/.unity-test-project,type=volume" diff --git a/.devcontainer/install-agent-clis.sh b/.devcontainer/install-agent-clis.sh index 7f5a67a3..9c0c5efa 100755 --- a/.devcontainer/install-agent-clis.sh +++ b/.devcontainer/install-agent-clis.sh @@ -3,13 +3,16 @@ # ============================================================================= # install-agent-clis.sh # ----------------------------------------------------------------------------- -# Idempotently install the latest AI coding agent CLIs as user-global npm -# packages. This script is safe to run on every container start. +# Idempotently install the latest AI coding agent CLIs and MCP runtime packages +# as user-global npm packages. This script is safe to run on every container +# start. # -# Agents installed: +# Packages installed: # * @openai/codex (bin: codex) # * opencode-ai (bin: opencode) # * @nanocollective/nanocoder (bin: nanocoder) +# * @z_ai/mcp-server (bin: zai-mcp-server) +# * mcp-remote (bin: mcp-remote) # # Behavior: # * Resolves each package's npm latest dist-tag with a bounded timeout. @@ -17,15 +20,17 @@ # * Installs into NPM_CONFIG_PREFIX (default: $HOME/.local) without sudo. # * Retries transient failures with small backoff. # * Never fails callers; offline and registry errors are non-fatal. -# * One missing agent never blocks installing the others. +# * One missing package never blocks installing the others. # ============================================================================= set -euo pipefail -AGENTS=( +PACKAGES=( "@openai/codex|codex" "opencode-ai|opencode" "@nanocollective/nanocoder|nanocoder" + "@z_ai/mcp-server|zai-mcp-server" + "mcp-remote|mcp-remote" ) NPM_PREFIX="${NPM_CONFIG_PREFIX:-${HOME}/.local}" VIEW_TIMEOUT_SECONDS="${CODEX_NPM_VIEW_TIMEOUT_SECONDS:-20}" @@ -65,7 +70,7 @@ while [ "$#" -gt 0 ]; do done if ! command -v npm >/dev/null 2>&1; then - warn "npm not found; skipping agent CLI installs." + warn "npm not found; skipping agent CLI and MCP runtime installs." exit 0 fi @@ -101,19 +106,19 @@ latest_version() { timeout "${VIEW_TIMEOUT_SECONDS}" npm view "${pkg}" version 2>/dev/null | tr -d '[:space:]' || true } -# Returns 0 when the agent is installed and its binary resolves. -verify_agent() { +# Returns 0 when the package is installed and its binary resolves. +verify_package() { local bin="$1" command -v "${bin}" >/dev/null 2>&1 } -install_agent() { +install_package() { local pkg="$1" bin="$2" latest="$3" local attempt for attempt in 1 2 3; do if timeout "${INSTALL_TIMEOUT_SECONDS}" npm install -g "${pkg}@${latest}" --silent --no-fund --no-audit; then hash -r 2>/dev/null || true - if verify_agent "${bin}"; then + if verify_package "${bin}"; then log "${pkg} ready: $(${bin} --version 2>/dev/null | head -1 || echo "${latest}")" return 0 fi @@ -129,15 +134,15 @@ install_agent() { failures=0 -for agent in "${AGENTS[@]}"; do - pkg="${agent%%|*}" - bin="${agent##*|}" +for package in "${PACKAGES[@]}"; do + pkg="${package%%|*}" + bin="${package##*|}" installed="$(installed_version "${pkg}")" latest="$(latest_version "${pkg}")" if [[ -z "${latest}" ]]; then - if [[ -n "${installed}" ]] && verify_agent "${bin}"; then + if [[ -n "${installed}" ]] && verify_package "${bin}"; then log "Registry unreachable; keeping installed ${pkg}@${installed}." else warn "Registry unreachable and ${bin} is not installed; will retry later." @@ -147,7 +152,7 @@ for agent in "${AGENTS[@]}"; do fi if [[ "${installed}" == "${latest}" ]]; then - if verify_agent "${bin}"; then + if verify_package "${bin}"; then log "${pkg}@${installed} already up-to-date." continue fi @@ -155,13 +160,13 @@ for agent in "${AGENTS[@]}"; do fi log "Installing ${pkg}@${latest} (previously: ${installed:-not installed})" - if ! install_agent "${pkg}" "${bin}" "${latest}"; then + if ! install_package "${pkg}" "${bin}" "${latest}"; then failures=$((failures + 1)) fi done if [ "${failures}" -gt 0 ]; then - warn "${failures} agent CLI(s) could not be verified; continuing without them." + warn "${failures} package(s) could not be verified; continuing without them." fi exit 0 diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index f364d8af..93695fde 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -66,6 +66,7 @@ VOLUME_DIRS=( "/home/vscode/.npm" "/home/vscode/.nuget/packages" "/home/vscode/.cache/pip" + "/home/vscode/.cache/unity-helpers-devcontainer" "/home/vscode/.unity-test-project" "/home/vscode/.unity-test-project/.unity-license-cache/local-share-unity3d" "/home/vscode/.unity-test-project/.unity-license-cache/config-unity3d" @@ -117,12 +118,12 @@ else log_ok "npm install succeeded (no lockfile)" fi -# ── Step 4: Install/update AI coding agent CLIs ───────────────────────────── -# Ensures OpenAI Codex, OpenCode, and nanocoder are installed globally and -# current on first container create. Failures are non-fatal and retried again -# from post-start. +# ── Step 4: Install/update AI coding agents and MCP runtimes ───────────────── +# Ensures OpenAI Codex, OpenCode, nanocoder, Z.AI Vision MCP, and the remote MCP +# adapter are installed globally and current on first container create. Failures +# are non-fatal and retried again from post-start. -log_step "Installing AI coding agent CLIs (codex, opencode, nanocoder)" +log_step "Installing AI coding agent CLIs and MCP runtimes" if bash "$SCRIPT_DIR/install-agent-clis.sh" --force-latest-check; then for agent_bin in codex opencode nanocoder; do @@ -132,6 +133,13 @@ if bash "$SCRIPT_DIR/install-agent-clis.sh" --force-latest-check; then log_warn "$agent_bin CLI is not currently available (non-fatal). It will retry on next container start." fi done + for mcp_bin in zai-mcp-server mcp-remote; do + if command -v "$mcp_bin" >/dev/null 2>&1; then + log_ok "$mcp_bin MCP runtime is available" + else + log_warn "$mcp_bin MCP runtime is not currently available (non-fatal). It will retry on next container start." + fi + done else log_warn "Agent CLI installation failed (non-fatal). It will retry on next container start." fi @@ -147,16 +155,21 @@ else log_warn "Codex is not logged in yet. Run: npm run codex:login" fi -# ── Step 4c: Sync Unity MCP client configs ────────────────────────────────── +# ── Step 4c: Sync MCP client configs ──────────────────────────────────────── # Regenerates every agent MCP config (Claude Code, Cursor, VS Code, Codex, -# OpenCode, nanocoder) from .env.local so a rebuilt container never serves a -# stale endpoint to a newly installed agent. Best-effort: the bridge is -# usually running on the HOST, so discovery failing here is expected; the -# pinned endpoint is written anyway. Requires node_modules (Step 3 ran first). +# OpenCode, nanocoder) with Unity, GitHub, and Z.AI servers. Best-effort: the +# Unity bridge is usually running on the HOST, so discovery failing here is +# expected; the pinned endpoint and shared server launchers are written anyway. +# Requires node_modules (Step 3 ran first). -log_step "Syncing Unity MCP client configs" +log_step "Syncing MCP client configs" MCP_REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +if node "${MCP_REPO_ROOT}/scripts/mcp/unity-mcp.mjs" configure-shared >/dev/null 2>&1; then + log_ok "GitHub and Z.AI MCP client configs written" +else + log_warn "Could not sync shared MCP client configs (non-fatal). Run: node scripts/mcp/unity-mcp.mjs configure-shared" +fi if node "${MCP_REPO_ROOT}/scripts/mcp/unity-mcp.mjs" configure --no-discover >/dev/null 2>&1; then log_ok "Unity MCP client configs written" else @@ -209,25 +222,29 @@ log_ok "Marked $WORKSPACE_DIR as safe directory" # safe.directory entries (a "not absolute" warning per entry on every git command). bash "$WORKSPACE_DIR/scripts/normalize-container-git-config.sh" || log_warn "Could not normalize container git config" -# ── Step 7: Pre-pull Unity Docker image (background) ───────────────────────── -# Pre-pulls the GameCI Unity Editor Docker image so that unity test/compile -# scripts can run immediately without waiting for the image download. +# ── Step 7: Pre-pull runtime Docker images (background) ───────────────────── +# Pre-pulls the GameCI Unity Editor and official GitHub MCP images so tests and +# the first GitHub tool call do not wait for image downloads. # This runs in the background and is non-fatal (the image will be pulled # on-demand if this step is skipped or fails). -log_step "Pre-pulling Unity Docker image (background)" +log_step "Pre-pulling runtime Docker images (background)" UNITY_VERSION="${UNITY_VERSION:-2021.3.45f1}" UNITY_IMAGE_VERSION="${UNITY_IMAGE_VERSION:-3}" UNITY_IMAGE="unityci/editor:ubuntu-${UNITY_VERSION}-base-${UNITY_IMAGE_VERSION}" +GITHUB_MCP_IMAGE="${GITHUB_MCP_IMAGE:-ghcr.io/github/github-mcp-server:latest}" if command -v docker >/dev/null 2>&1; then # Pull in background so it doesn't block post-create (docker pull "$UNITY_IMAGE" >/dev/null 2>&1 && \ log_ok "Unity image pulled: $UNITY_IMAGE") & log_ok "Unity image pull started in background: $UNITY_IMAGE" + (docker pull "$GITHUB_MCP_IMAGE" >/dev/null 2>&1 && \ + log_ok "GitHub MCP image pulled: $GITHUB_MCP_IMAGE") & + log_ok "GitHub MCP image pull started in background: $GITHUB_MCP_IMAGE" else - log_warn "Docker not available yet (DinD may still be starting). Unity image will be pulled on first use." + log_warn "Docker not available yet (DinD may still be starting). Runtime images will be pulled on first use." fi # ── Done ───────────────────────────────────────────────────────────────────── diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index 2ef203e9..f08b0b92 100755 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -2,9 +2,9 @@ # Post-start setup script for the devcontainer. # Runs as the remoteUser (vscode) after each container start. # -# This script keeps the AI coding agent CLIs (OpenAI Codex, OpenCode, nanocoder) -# available across restarts, repairs volume mount ownership that Docker may have -# reset, and avoids unnecessary npm registry calls on every start. +# This script keeps the AI coding agent CLIs and MCP runtimes available across +# restarts, repairs volume mount ownership that Docker may have reset, and avoids +# unnecessary npm registry calls on every start. set -euo pipefail @@ -46,6 +46,7 @@ USER_OWNED_DIRS=( "${HOME}/.local" "${HOME}/.nuget/packages" "${HOME}/.cache/pip" + "${HOME}/.cache/unity-helpers-devcontainer" "${HOME}/.unity-test-project" ) @@ -153,33 +154,53 @@ else log_warn "Run: bash scripts/check-container-git-credentials.sh --fix" fi -log_step "Verifying AI coding agent CLIs (codex, opencode, nanocoder)" +log_step "Verifying AI coding agent CLIs and MCP runtimes" mkdir -p "$STATE_DIR" load_failure_state -if retry_is_deferred; then - exit 0 -fi +if ! retry_is_deferred; then + # Installer is non-fatal by design; verify command availability explicitly. + bash "$SCRIPT_DIR/install-agent-clis.sh" || true + all_packages_available=true + for agent_bin in codex opencode nanocoder; do + if command -v "$agent_bin" >/dev/null 2>&1 && timeout "${CODEX_VERSION_TIMEOUT_SECONDS}" "$agent_bin" --version >/dev/null 2>&1; then + log_ok "$agent_bin CLI is available" + else + all_packages_available=false + log_warn "$agent_bin CLI verification failed (non-fatal)." + fi + done + for mcp_bin in zai-mcp-server mcp-remote; do + if command -v "$mcp_bin" >/dev/null 2>&1; then + log_ok "$mcp_bin MCP runtime is available" + else + all_packages_available=false + log_warn "$mcp_bin MCP runtime verification failed (non-fatal)." + fi + done -# Installer is non-fatal by design; verify command availability explicitly. -bash "$SCRIPT_DIR/install-agent-clis.sh" || true -all_agents_available=true -for agent_bin in codex opencode nanocoder; do - if command -v "$agent_bin" >/dev/null 2>&1 && timeout "${CODEX_VERSION_TIMEOUT_SECONDS}" "$agent_bin" --version >/dev/null 2>&1; then - log_ok "$agent_bin CLI is available" + if [ "$all_packages_available" = true ]; then + clear_failure_state + log_ok "All agent CLIs and MCP runtimes are available" else - all_agents_available=false - log_warn "$agent_bin CLI verification failed (non-fatal)." + record_failure_and_backoff + log_warn "Package verification failed (non-fatal). Re-run: bash .devcontainer/install-agent-clis.sh --force-latest-check" fi -done +fi + +log_step "Syncing MCP client configs" -if [ "$all_agents_available" = true ]; then - clear_failure_state - log_ok "All agent CLIs are available" +MCP_REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +if node "${MCP_REPO_ROOT}/scripts/mcp/unity-mcp.mjs" configure-shared >/dev/null 2>&1; then + log_ok "GitHub and Z.AI MCP client configs written" +else + log_warn "Could not sync shared MCP client configs (non-fatal). Run: node scripts/mcp/unity-mcp.mjs configure-shared" +fi +if node "${MCP_REPO_ROOT}/scripts/mcp/unity-mcp.mjs" configure --no-discover >/dev/null 2>&1; then + log_ok "Unity MCP client configs written" else - record_failure_and_backoff - log_warn "Agent CLI verification failed (non-fatal). Re-run: bash .devcontainer/install-agent-clis.sh --force-latest-check" + log_warn "Could not sync Unity MCP client configs (non-fatal). Run: npm run unity:mcp:configure" fi exit 0 diff --git a/cspell.json b/cspell.json index 1a170445..7c6c34f0 100644 --- a/cspell.json +++ b/cspell.json @@ -907,7 +907,9 @@ "unredacted", "unwritable", "unanalyzed", - "denormal" + "denormal", + "zai", + "zread" ] } ], diff --git a/docs/guides/mcp-local-setup.md b/docs/guides/mcp-local-setup.md index 6bf189c8..94a0932c 100644 --- a/docs/guides/mcp-local-setup.md +++ b/docs/guides/mcp-local-setup.md @@ -1,5 +1,47 @@ # MCP Local Setup +The devcontainer configures the repository's shared MCP servers for Claude Code, Cursor, VS Code +and GitHub Copilot, Codex, OpenCode, and nanocoder. A full rebuild or fresh clone installs the local +runtimes and regenerates every machine-local client config automatically; every later container +start repairs missing or stale configs. + +The shared catalog is: + +- GitHub's official MCP server, running from its Docker image. +- Z.AI Vision for screenshot, image, diagram, chart, and video understanding. +- Z.AI Web Search for current search results. +- Z.AI Web Reader for structured webpage extraction. +- Z.AI Zread for public GitHub repository documentation and source exploration. + +Set `Z_AI_API_KEY` on the host before opening the devcontainer, or add it to the repository's +gitignored `.env.local` file: + +```bash +Z_AI_API_KEY= +``` + +The key is resolved only when a Z.AI server starts. It is never copied into an MCP client config or +placed on a process command line. Remote services receive it through a mode-0600 temporary header +file that is deleted when the server exits. If the key is absent, the launcher exits with a direct +setup instruction instead of starting an unauthenticated server. + +Run `npm run mcp:configure-shared` to repair only the shared GitHub and Z.AI entries without a live +Unity bridge. The devcontainer runs this independent path before attempting Unity configuration, +so a wrong or unavailable Unity editor cannot suppress the other servers. + +GitHub resolves `GITHUB_PERSONAL_ACCESS_TOKEN` from the process environment first and the +gitignored `.env.local` file second. Add an existing token there with no client-config changes: + +```bash +GITHUB_PERSONAL_ACCESS_TOKEN= +``` + +The prompt-free Git/`gh` credential cache remains the final fallback. Populate it with +`npm run github:token:bootstrap` or `npm run github:token:store`. The devcontainer keeps that +mode-0600 cache in a named volume so it survives full container rebuilds. + +## Unity bridge + Unity runs on a Windows host; agents run in a Linux devcontainer. Unity's relay speaks stdio, which cannot cross into the container, so a small Node bridge serves it over authenticated HTTP and the container's agents point at that endpoint. @@ -36,10 +78,10 @@ for installing the relay. npm run unity:mcp:configure ``` -This discovers the endpoint and writes every machine-local client config — `.mcp.json` (Claude Code -**and** nanocoder), `.cursor/mcp.json`, `.vscode/mcp.json`, `.codex/config.toml`, `opencode.json` -(OpenCode) — under the server name `unity-mcp-remote`, each carrying the bearer token. Claude Code -and nanocoder share `.mcp.json` but select the HTTP transport with different keys (`type` vs +This discovers the endpoint and merges `unity-mcp-remote` into every machine-local client config — +`.mcp.json` (Claude Code **and** nanocoder), `.cursor/mcp.json`, `.vscode/mcp.json`, +`.codex/config.toml`, and `opencode.json` (OpenCode) — with its bearer token. Claude Code and +nanocoder share `.mcp.json` but select the HTTP transport with different keys (`type` vs `transport`), so the generated entry carries both. Writes are transactional: a failure part-way rolls every already-written file back. @@ -91,6 +133,7 @@ UNITY_MCP_BRIDGE_PATH=/mcp UNITY_MCP_BEARER_TOKEN=<64 hex characters> UNITY_MCP_PROJECT_ROOT=D:/Code/YourUnityProject UNITY_PROJECT_PATH=D:\Path\To\HostUnityProject +Z_AI_API_KEY= ``` `UNITY_MCP_BEARER_TOKEN` and `UNITY_MCP_PROJECT_ROOT` are written for you on first success; the rest @@ -101,7 +144,7 @@ full flag list. ## Never commit these `.mcp.json`, `.cursor/mcp.json`, `.vscode/mcp.json`, `.codex/config.toml`, `opencode.json`, and -`.env.local` hold a per-developer endpoint and a bearer token. All six are gitignored, and +`.env.local` hold per-developer endpoints and credentials. All six are gitignored, and `npm run validate:mcp-config` fails if that ever stops being true. ## Binding the server to your agent diff --git a/package.json b/package.json index a2c103d5..00266ab6 100644 --- a/package.json +++ b/package.json @@ -188,6 +188,7 @@ "validate:mcp-config": "pwsh -NoProfile -File scripts/validate-mcp-config.ps1 -VerboseOutput", "unity:mcp:probe": "node scripts/mcp/unity-mcp.mjs probe", "unity:mcp:configure": "node scripts/mcp/unity-mcp.mjs configure", + "mcp:configure-shared": "node scripts/mcp/unity-mcp.mjs configure-shared", "unity:mcp:bridge": "node scripts/mcp/unity-mcp.mjs bridge", "validate:git-push-config": "pwsh -NoProfile -File scripts/validate-git-push-config.ps1", "github:token": "bash scripts/github-token.sh", @@ -317,7 +318,9 @@ "test:check-eol": "pwsh -NoProfile -File scripts/tests/test-check-eol.ps1", "test:gitignore-docs": "pwsh -NoProfile -File scripts/tests/test-gitignore-docs.ps1", "test:validate-mcp-config": "pwsh -NoProfile -File scripts/tests/test-validate-mcp-config.ps1", + "test:github-mcp": "node --test scripts/tests/test-github-mcp.mjs", "test:unity-mcp": "node --test scripts/tests/test-unity-mcp.mjs", + "test:zai-mcp": "node --test scripts/tests/test-zai-mcp.mjs", "test:sync-script-contracts": "pwsh -NoProfile -File scripts/tests/test-sync-script-contracts.ps1", "test:agent-preflight": "pwsh -NoProfile -File scripts/tests/test-agent-preflight.ps1", "test:git-staging-helpers": "bash scripts/tests/test-git-staging-helpers.sh", diff --git a/scripts/mcp/README.md b/scripts/mcp/README.md index 3fa5d769..a412fc47 100644 --- a/scripts/mcp/README.md +++ b/scripts/mcp/README.md @@ -1,4 +1,33 @@ -# Unity MCP in a Linux devcontainer with a Windows host +# MCP servers in the devcontainer + +`configure-shared` writes the repository's GitHub and Z.AI servers to every supported agent client: +Claude Code, Cursor, VS Code and GitHub Copilot, Codex, OpenCode, and nanocoder. The devcontainer +pre-installs the local npm runtimes, warms the GitHub Docker image, runs shared configuration after +container creation, and repairs it on every start. + +```bash +npm run mcp:configure-shared +``` + +The generated catalog contains `github`, `zai-vision`, `zai-web-search`, `zai-web-reader`, and +`zai-zread`. Z.AI documents these as its Vision, Web Search, Web Reader, and Zread MCP services. +Vision runs locally through `@z_ai/mcp-server`; the three HTTP services use `mcp-remote` so all six +frontends receive one consistent stdio configuration. + +`zai-mcp.mjs` resolves `Z_AI_API_KEY` from the process environment first and `.env.local` second. +Remote authorization is written to a private temporary header file and deleted at exit, keeping the +secret out of generated configs and process arguments. The launcher refuses to start without a key. + +```bash +Z_AI_API_KEY= +``` + +First-party references: [Z.AI Vision](https://docs.z.ai/devpack/mcp/vision-mcp-server), +[Web Search](https://docs.z.ai/devpack/mcp/search-mcp-server), +[Web Reader](https://docs.z.ai/devpack/mcp/reader-mcp-server), and +[Zread](https://docs.z.ai/devpack/mcp/zread-mcp-server). + +## Unity MCP with a Windows host Unity runs on Windows; agents run inside a Linux devcontainer. Unity's relay binary speaks stdio and cannot run in the container, so `unity-mcp.mjs` bridges it to authenticated HTTP on the host and @@ -8,13 +37,14 @@ writes the container's client configs to match. Unity (Windows) → relay (stdio) → unity-mcp bridge (HTTP + bearer) → agent clients (container) ``` -One script, three subcommands: +One script, four subcommands: -| Command | Runs on | Does | -| ----------------------------- | --------- | -------------------------------------------------------------- | -| `npm run unity:mcp:bridge` | host | Serves Unity's relay over authenticated streamable HTTP | -| `npm run unity:mcp:configure` | container | Discovers the endpoint and writes every MCP client config | -| `npm run unity:mcp:probe` | container | Handshakes with the endpoint and reports which project answers | +| Command | Runs on | Does | +| ------------------------------ | --------- | -------------------------------------------------------------- | +| `npm run unity:mcp:bridge` | host | Serves Unity's relay over authenticated streamable HTTP | +| `npm run unity:mcp:configure` | container | Discovers the endpoint and writes every MCP client config | +| `npm run unity:mcp:probe` | container | Handshakes with the endpoint and reports which project answers | +| `npm run mcp:configure-shared` | container | Writes GitHub and Z.AI entries without requiring Unity | `node scripts/mcp/unity-mcp.mjs --help` lists every flag. @@ -32,12 +62,14 @@ It is a launcher rather than a bare `docker run` line in five config files becau config cannot run a shell substitution. The alternatives were a token literal duplicated across five files, or an exported environment variable that every client's parent process happens to carry -- and a missing variable starts an **unauthenticated** server, which reads as "GitHub is -down" rather than "you are not logged in". The launcher instead reads the one 0600 credential -through `scripts/github-token.sh`, so refreshing the token fixes every client at once, and exits -**3** with the fix printed when there is none. The token reaches Docker through the environment, -never on the command line, because a command line is visible in `ps`. +down" rather than "you are not logged in". The launcher resolves `GITHUB_PERSONAL_ACCESS_TOKEN` +from the process environment, then `.env.local`, then the one mode-0600 credential cache through +`scripts/github-token.sh`. It exits **3** with the fix printed when all three are empty. The token +reaches Docker through the environment, never on the command line, because a command line is +visible in `ps`. -Set `GITHUB_MCP_IMAGE` to pin a tag other than `latest`. +Set `GITHUB_MCP_IMAGE` to pin a tag other than `latest`. Its credential cache and the package retry +state live in a named devcontainer volume, so both survive a full container rebuild. ## Which Unity, not just which port @@ -92,7 +124,8 @@ there and only the pairing is wrong, so overwriting the config would bake in the ## Machine-local files -These carry a per-developer endpoint and a bearer token and are all gitignored: +These carry machine-local absolute launcher paths, endpoints, and credentials and are all +gitignored: - `.mcp.json` (Claude Code **and** nanocoder — see the note below) - `.cursor/mcp.json` @@ -109,9 +142,10 @@ entry carries **both** keys, so one file configures both agents; each ignores th malformed or does not target `/mcp`, or if these docs reference a script that does not exist. Writes are transactional: files are staged as `0600` temporaries and renamed, and a failure part-way -rolls every already-committed file back. Only the `unity-mcp-remote` entry is touched, so sibling MCP -servers in the same file survive. JSON configs are read as JSONC, since VS Code's own "MCP: Add -Server" scaffolding writes comments into `.vscode/mcp.json`; comments are not preserved on rewrite. +rolls every already-committed file back. Only the six repository-owned server entries are replaced, +so unrelated MCP servers in the same file survive. JSON configs are read as JSONC, since VS Code's +own "MCP: Add Server" scaffolding writes comments into `.vscode/mcp.json`; comments are not preserved +on rewrite. ## Setup diff --git a/scripts/mcp/github-mcp.mjs b/scripts/mcp/github-mcp.mjs new file mode 100644 index 00000000..76058155 --- /dev/null +++ b/scripts/mcp/github-mcp.mjs @@ -0,0 +1,109 @@ +#!/usr/bin/env node + +import { spawn, spawnSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { readLocalEnv } from "./unity-mcp.mjs"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const DEFAULT_IMAGE = "ghcr.io/github/github-mcp-server:latest"; + +function cleanCredential(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function readCachedGithubToken(repoRoot) { + const result = spawnSync("bash", [path.join(repoRoot, "scripts", "github-token.sh")], { + encoding: "utf8", + env: process.env + }); + return result.status === 0 ? cleanCredential(result.stdout) : ""; +} + +export function resolveGithubToken( + repoRoot = REPO_ROOT, + environment = process.env, + readCache = () => readCachedGithubToken(repoRoot) +) { + const fromEnvironment = cleanCredential(environment.GITHUB_PERSONAL_ACCESS_TOKEN); + if (fromEnvironment) { + return fromEnvironment; + } + + const fromLocalEnv = cleanCredential(readLocalEnv(repoRoot).GITHUB_PERSONAL_ACCESS_TOKEN); + if (fromLocalEnv) { + return fromLocalEnv; + } + + return cleanCredential(readCache()); +} + +export async function main(argv = process.argv.slice(2)) { + const token = resolveGithubToken(); + if (!token) { + console.error( + "github-mcp: GITHUB_PERSONAL_ACCESS_TOKEN is not configured; refusing to start unauthenticated." + ); + console.error( + "github-mcp: add it to .env.local, or run `npm run github:token:store`, then restart the MCP client." + ); + return 3; + } + + const image = cleanCredential(process.env.GITHUB_MCP_IMAGE) || DEFAULT_IMAGE; + const child = spawn( + "docker", + ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", image, ...argv], + { + env: { ...process.env, GITHUB_PERSONAL_ACCESS_TOKEN: token }, + stdio: "inherit" + } + ); + const forwardedSignals = new Set(); + const forwardSignal = (signal) => { + forwardedSignals.add(signal); + child.kill(signal); + }; + const signals = ["SIGINT", "SIGTERM"]; + const signalHandlers = new Map(); + for (const signal of signals) { + const handler = forwardSignal.bind(undefined, signal); + signalHandlers.set(signal, handler); + process.on(signal, handler); + } + + try { + return await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => { + if (signal && forwardedSignals.has(signal)) { + resolve(128 + os.constants.signals[signal]); + } else { + resolve(code ?? 1); + } + }); + }); + } finally { + for (const signal of signals) { + process.removeListener(signal, signalHandlers.get(signal)); + } + } +} + +const entry = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === entry) { + main() + .then((status) => { + process.exitCode = status; + }) + .catch((error) => { + console.error( + error.code === "ENOENT" + ? "github-mcp: docker is not installed, so the GitHub MCP server cannot start." + : `github-mcp: ${error.message}` + ); + process.exitCode = error.code === "ENOENT" ? 127 : 1; + }); +} diff --git a/scripts/mcp/github-mcp.mjs.meta b/scripts/mcp/github-mcp.mjs.meta new file mode 100644 index 00000000..c2970da2 --- /dev/null +++ b/scripts/mcp/github-mcp.mjs.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7b73155c752ed6ba5c62bf6064390431 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/mcp/github-mcp.sh b/scripts/mcp/github-mcp.sh index cb4ca185..b268d2e3 100755 --- a/scripts/mcp/github-mcp.sh +++ b/scripts/mcp/github-mcp.sh @@ -7,29 +7,12 @@ # secret; the second silently yields an UNAUTHENTICATED server whenever the variable is missing, # which looks like "GitHub is down" rather than "you are not logged in". # -# This reads the credential the same way everything else in the repository does -- through -# scripts/github-token.sh, which never prompts -- so the token lives in exactly one 0600 file and -# refreshing it fixes every client at once. +# The Node launcher resolves GITHUB_PERSONAL_ACCESS_TOKEN from the process environment first, the +# repository's gitignored .env.local second, and scripts/github-token.sh's prompt-free cache last. # # The token is passed to `docker run` through the environment, never on the command line, because a # command line is visible in `ps` to every process on the machine. set -euo pipefail REPO_ROOT="$(CDPATH= cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" -IMAGE="${GITHUB_MCP_IMAGE:-ghcr.io/github/github-mcp-server:latest}" - -if ! command -v docker >/dev/null 2>&1; then - printf 'github-mcp: docker is not installed, so the GitHub MCP server cannot start.\n' >&2 - exit 127 -fi - -# Exit 3 means "no credential cached", which is a request for a human rather than a reason to -# start an unauthenticated server that fails one tool call at a time. -if ! GITHUB_PERSONAL_ACCESS_TOKEN="$("$REPO_ROOT/scripts/github-token.sh")"; then - printf 'github-mcp: no GitHub credential is cached; the server would answer nothing.\n' >&2 - printf 'github-mcp: store one with `npm run github:token:store`, then restart your client.\n' >&2 - exit 3 -fi -export GITHUB_PERSONAL_ACCESS_TOKEN - -exec docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN "$IMAGE" "$@" +exec node "$REPO_ROOT/scripts/mcp/github-mcp.mjs" "$@" diff --git a/scripts/mcp/unity-mcp.mjs b/scripts/mcp/unity-mcp.mjs index 1205803a..79649926 100644 --- a/scripts/mcp/unity-mcp.mjs +++ b/scripts/mcp/unity-mcp.mjs @@ -1055,6 +1055,10 @@ function readJsonObject(filePath) { } export function prepareJsonServer(filePath, collection, server) { + return prepareJsonServers(filePath, collection, { "unity-mcp-remote": server }); +} + +export function prepareJsonServers(filePath, collection, servers) { const document = readJsonObject(filePath); const existing = document[collection]; if ( @@ -1063,7 +1067,7 @@ export function prepareJsonServer(filePath, collection, server) { ) { fail(`Expected ${collection} to be an object in ${filePath}`); } - document[collection] = { ...(existing ?? {}), "unity-mcp-remote": server }; + document[collection] = { ...(existing ?? {}), ...servers }; return `${JSON.stringify(document, null, 2)}\n`; } @@ -1076,14 +1080,14 @@ function tomlString(value) { * sentinel key is how a `[mcp_servers.unity_mcp_remote]` header is told apart from any other header without * hand-writing a TOML grammar. */ -function classifyTomlHeader(line) { +function classifyTomlHeader(line, serverName) { if (!line.trimStart().startsWith("[")) { return undefined; } const marker = "__dxm_mcp_table_marker_7d0f__"; try { const parsed = parseToml(`${line}\n${marker} = true\n`); - const owned = parsed.mcp_servers?.["unity_mcp_remote"]?.[marker] === true; + const owned = parsed.mcp_servers?.[serverName]?.[marker] === true; const hasMarker = JSON.stringify(parsed).includes(`"${marker}":true`); return hasMarker ? { owned } : undefined; } catch { @@ -1091,58 +1095,49 @@ function classifyTomlHeader(line) { } } -const CODEX_AMBIGUOUS_MESSAGE = (reason) => +const CODEX_AMBIGUOUS_MESSAGE = (reason, serverName) => `${reason} in .codex/config.toml, so this tool cannot tell which lines it owns. ` + - "Delete the [mcp_servers.unity_mcp_remote] table from .codex/config.toml (or move it to the end of the " + + `Delete the [mcp_servers.${serverName}] table from .codex/config.toml (or move it to the end of the ` + "file, after every multi-line value) and re-run configure."; -export function mergeCodexToml(raw, url, bearerToken) { +function mergeCodexServerTable(raw, serverName, block) { let parsed; try { parsed = raw.trim() ? parseToml(raw) : {}; } catch (error) { fail(`Invalid TOML in Codex config: ${error.message}`); } - const block = [ - "[mcp_servers.unity_mcp_remote]", - `url = ${tomlString(url)}`, - `http_headers = { Authorization = ${tomlString(`Bearer ${bearerToken}`)} }`, - "startup_timeout_sec = 20", - "tool_timeout_sec = 120", - "enabled = true", - "" - ].join("\n"); - // Normalize line endings once so both the append and the replace path emit LF only; mixing CRLF - // input with an LF block would otherwise leave the file churning on every run under Windows. const normalized = raw.replace(/\r\n/g, "\n"); const lines = normalized.split("\n"); const owned = lines - .map((line, index) => ({ index, header: classifyTomlHeader(line) })) + .map((line, index) => ({ index, header: classifyTomlHeader(line, serverName) })) .filter((item) => item.header?.owned) .map((item) => item.index); if (owned.length > 1) { - fail(CODEX_AMBIGUOUS_MESSAGE("Duplicate unity-mcp table")); + fail(CODEX_AMBIGUOUS_MESSAGE(`Duplicate ${serverName} table`, serverName)); } if (owned.length === 0) { - if (parsed.mcp_servers?.["unity_mcp_remote"] !== undefined) { - fail("Unsupported inline or dotted unity-mcp definition in Codex config"); + if (parsed.mcp_servers?.[serverName] !== undefined) { + fail(`Unsupported inline or dotted ${serverName} definition in Codex config`); } return `${normalized.trimEnd()}${normalized.trim() ? "\n\n" : ""}${block}`; } const start = owned[0]; - // A `[mcp_servers.unity_mcp_remote]` line inside a multi-line string is not a table header. Everything - // before a real header is itself complete TOML, so a prefix that will not parse proves the line - // scanner is about to splice through a string literal. try { parseToml(lines.slice(0, start).join("\n")); } catch { - fail(CODEX_AMBIGUOUS_MESSAGE("A unity-mcp header line appears inside a multi-line value")); + fail( + CODEX_AMBIGUOUS_MESSAGE( + `A ${serverName} header line appears inside a multi-line value`, + serverName + ) + ); } let end = lines.length; for (let index = start + 1; index < lines.length; index += 1) { - if (classifyTomlHeader(lines[index])) { + if (classifyTomlHeader(lines[index], serverName)) { end = index; break; } @@ -1152,11 +1147,87 @@ export function mergeCodexToml(raw, url, bearerToken) { try { parseToml(result); } catch { - fail(CODEX_AMBIGUOUS_MESSAGE("Rewriting the unity-mcp table produced invalid TOML")); + fail( + CODEX_AMBIGUOUS_MESSAGE(`Rewriting the ${serverName} table produced invalid TOML`, serverName) + ); } return result; } +export function mergeCodexToml(raw, url, bearerToken) { + const block = [ + "[mcp_servers.unity_mcp_remote]", + `url = ${tomlString(url)}`, + `http_headers = { Authorization = ${tomlString(`Bearer ${bearerToken}`)} }`, + "startup_timeout_sec = 20", + "tool_timeout_sec = 120", + "enabled = true", + "" + ].join("\n"); + return mergeCodexServerTable(raw, "unity_mcp_remote", block); +} + +function codexStdioBlock(serverName, command, args) { + return [ + `[mcp_servers.${serverName}]`, + `command = ${tomlString(command)}`, + `args = [${args.map(tomlString).join(", ")}]`, + "startup_timeout_sec = 60", + "tool_timeout_sec = 120", + "enabled = true", + "" + ].join("\n"); +} + +function sharedMcpDefinitions(repoRoot) { + const githubLauncher = path.join(repoRoot, "scripts", "mcp", "github-mcp.sh"); + const zaiLauncher = path.join(repoRoot, "scripts", "mcp", "zai-mcp.mjs"); + const definitions = [ + { name: "github", command: "bash", args: [githubLauncher] }, + { name: "zai-vision", command: "node", args: [zaiLauncher, "vision"] }, + { name: "zai-web-search", command: "node", args: [zaiLauncher, "web-search"] }, + { name: "zai-web-reader", command: "node", args: [zaiLauncher, "web-reader"] }, + { name: "zai-zread", command: "node", args: [zaiLauncher, "zread"] } + ]; + const json = Object.fromEntries( + definitions.map(({ name, command, args }) => [name, { type: "stdio", command, args }]) + ); + const sharedJson = Object.fromEntries( + Object.entries(json).map(([name, server]) => [name, { ...server, transport: "stdio" }]) + ); + const opencode = Object.fromEntries( + definitions.map(({ name, command, args }) => [ + name, + { type: "local", command: [command, ...args], enabled: true } + ]) + ); + return { definitions, json, sharedJson, opencode }; +} + +function mergeSharedCodexServers(raw, definitions) { + return definitions.reduce((current, { name, command, args }) => { + const serverName = name.replaceAll("-", "_"); + return mergeCodexServerTable(current, serverName, codexStdioBlock(serverName, command, args)); + }, raw); +} + +export function configureShared(repoRoot, beforeCommit) { + const paths = clientConfigPaths(repoRoot); + const shared = sharedMcpDefinitions(repoRoot); + const codexRaw = fs.existsSync(paths.codex) ? fs.readFileSync(paths.codex, "utf8") : ""; + const written = transactionalWrite( + [ + [paths.claudeCode, prepareJsonServers(paths.claudeCode, "mcpServers", shared.sharedJson)], + [paths.cursor, prepareJsonServers(paths.cursor, "mcpServers", shared.json)], + [paths.vscode, prepareJsonServers(paths.vscode, "servers", shared.json)], + [paths.codex, mergeSharedCodexServers(codexRaw, shared.definitions)], + [paths.opencode, prepareJsonServers(paths.opencode, "mcp", shared.opencode)] + ], + beforeCommit + ); + return { written }; +} + /** Every MCP client config this repository owns, keyed by the schema each client expects. */ export function clientConfigPaths(repoRoot) { return { @@ -1182,14 +1253,43 @@ export function configure(inputOptions, endpoint, beforeCommit) { const opencodeServer = { type: "remote", url, enabled: true, headers: server.headers }; const paths = clientConfigPaths(options.repoRoot); const codexRaw = fs.existsSync(paths.codex) ? fs.readFileSync(paths.codex, "utf8") : ""; + const shared = sharedMcpDefinitions(options.repoRoot); + const codex = mergeSharedCodexServers( + mergeCodexToml(codexRaw, url, options.bearerToken), + shared.definitions + ); const written = transactionalWrite( [ - [paths.claudeCode, prepareJsonServer(paths.claudeCode, "mcpServers", sharedFileServer)], - [paths.cursor, prepareJsonServer(paths.cursor, "mcpServers", server)], - [paths.vscode, prepareJsonServer(paths.vscode, "servers", server)], - [paths.codex, mergeCodexToml(codexRaw, url, options.bearerToken)], - [paths.opencode, prepareJsonServer(paths.opencode, "mcp", opencodeServer)] + [ + paths.claudeCode, + prepareJsonServers(paths.claudeCode, "mcpServers", { + "unity-mcp-remote": sharedFileServer, + ...shared.sharedJson + }) + ], + [ + paths.cursor, + prepareJsonServers(paths.cursor, "mcpServers", { + "unity-mcp-remote": server, + ...shared.json + }) + ], + [ + paths.vscode, + prepareJsonServers(paths.vscode, "servers", { + "unity-mcp-remote": server, + ...shared.json + }) + ], + [paths.codex, codex], + [ + paths.opencode, + prepareJsonServers(paths.opencode, "mcp", { + "unity-mcp-remote": opencodeServer, + ...shared.opencode + }) + ] ], beforeCommit ); @@ -1809,10 +1909,11 @@ export async function runBridge(options) { function usage() { return [ - "Usage: node scripts/mcp/unity-mcp.mjs [options]", + "Usage: node scripts/mcp/unity-mcp.mjs [options]", "", " probe Discover a live Unity MCP endpoint and complete an initialize handshake.", " configure Discover, then write .mcp.json, .cursor/mcp.json, .vscode/mcp.json, .codex/config.toml, opencode.json.", + " configure-shared Write GitHub and Z.AI servers without requiring the Unity bridge.", " bridge Serve the Unity relay over authenticated streamable HTTP (run next to Unity).", "", "Options:", @@ -1842,7 +1943,7 @@ export async function main(argv = process.argv.slice(2)) { console.log(usage()); return; } - if (!["bridge", "configure", "probe"].includes(command)) { + if (!["bridge", "configure", "configure-shared", "probe"].includes(command)) { fail(`Unknown command: ${command}`); } if (rest.includes("--help") || rest.includes("-h")) { @@ -1860,6 +1961,13 @@ export async function main(argv = process.argv.slice(2)) { if (command === "configure") { await runConfigure(options); } + if (command === "configure-shared") { + const { written } = configureShared(options.repoRoot); + const summary = written.length + ? written.map((filePath) => path.relative(options.repoRoot, filePath)).join(", ") + : "no changes"; + console.log(`Configured shared MCP servers (${summary}).`); + } if (command === "bridge") { await runBridge(options); } diff --git a/scripts/mcp/zai-mcp.mjs b/scripts/mcp/zai-mcp.mjs new file mode 100644 index 00000000..241f4864 --- /dev/null +++ b/scripts/mcp/zai-mcp.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { readLocalEnv } from "./unity-mcp.mjs"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const REMOTE_SERVERS = Object.freeze({ + "web-search": "https://api.z.ai/api/mcp/web_search_prime/mcp", + "web-reader": "https://api.z.ai/api/mcp/web_reader/mcp", + zread: "https://api.z.ai/api/mcp/zread/mcp" +}); + +function cleanCredential(value) { + return typeof value === "string" ? value.trim() : ""; +} + +export function resolveZaiApiKey(repoRoot = REPO_ROOT, environment = process.env) { + const fromEnvironment = cleanCredential(environment.Z_AI_API_KEY); + if (fromEnvironment) { + return fromEnvironment; + } + return cleanCredential(readLocalEnv(repoRoot).Z_AI_API_KEY); +} + +export function createAuthorizationHeaderFile(apiKey, temporaryRoot = os.tmpdir()) { + const directory = fs.mkdtempSync(path.join(temporaryRoot, "unity-helpers-zai-mcp-")); + fs.chmodSync(directory, 0o700); + const filePath = path.join(directory, "headers"); + fs.writeFileSync(filePath, `Authorization: Bearer ${apiKey}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600 + }); + fs.chmodSync(filePath, 0o600); + return { + directory, + filePath, + cleanup: () => fs.rmSync(directory, { recursive: true, force: true }) + }; +} + +export function launchDefinition(mode, apiKey, temporaryRoot = os.tmpdir()) { + if (mode === "vision") { + return { + command: "zai-mcp-server", + args: [], + environment: { Z_AI_API_KEY: apiKey, Z_AI_MODE: "ZAI" }, + cleanup: () => {} + }; + } + + const url = REMOTE_SERVERS[mode]; + if (!url) { + throw new Error(`Unknown Z.AI MCP server: ${mode}`); + } + const header = createAuthorizationHeaderFile(apiKey, temporaryRoot); + return { + command: "mcp-remote", + args: [url, "--header-file", header.filePath], + environment: {}, + cleanup: header.cleanup + }; +} + +function usage() { + return [ + "Usage: node scripts/mcp/zai-mcp.mjs ", + "", + "Set Z_AI_API_KEY in the environment or the repository's gitignored .env.local file." + ].join("\n"); +} + +export async function main(argv = process.argv.slice(2)) { + const [mode, ...unexpected] = argv; + if (!mode || mode === "--help" || mode === "-h") { + console.log(usage()); + return 0; + } + if (unexpected.length > 0) { + throw new Error(`Unexpected argument: ${unexpected[0]}`); + } + + const apiKey = resolveZaiApiKey(); + if (!apiKey) { + console.error("zai-mcp: Z_AI_API_KEY is not configured; refusing to start unauthenticated."); + console.error("zai-mcp: add Z_AI_API_KEY= to .env.local, then restart the MCP client."); + return 3; + } + + const definition = launchDefinition(mode, apiKey); + const child = spawn(definition.command, definition.args, { + env: { ...process.env, ...definition.environment }, + stdio: "inherit" + }); + const forwardedSignals = new Set(); + const forwardSignal = (signal) => { + forwardedSignals.add(signal); + child.kill(signal); + }; + const signals = ["SIGINT", "SIGTERM"]; + const signalHandlers = new Map(); + for (const signal of signals) { + const handler = forwardSignal.bind(undefined, signal); + signalHandlers.set(signal, handler); + process.on(signal, handler); + } + + try { + return await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => { + if (signal && forwardedSignals.has(signal)) { + resolve(128 + os.constants.signals[signal]); + } else { + resolve(code ?? 1); + } + }); + }); + } finally { + for (const signal of signals) { + process.removeListener(signal, signalHandlers.get(signal)); + } + definition.cleanup(); + } +} + +const entry = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === entry) { + main() + .then((status) => { + process.exitCode = status; + }) + .catch((error) => { + console.error(`zai-mcp: ${error.message}`); + process.exitCode = error.code === "ENOENT" ? 127 : 1; + }); +} diff --git a/scripts/mcp/zai-mcp.mjs.meta b/scripts/mcp/zai-mcp.mjs.meta new file mode 100644 index 00000000..61adf284 --- /dev/null +++ b/scripts/mcp/zai-mcp.mjs.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 734defd605a70a1045b0fbf2c68ee5f7 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/run-contract-tests.js b/scripts/run-contract-tests.js index 66103b5f..c4959c25 100644 --- a/scripts/run-contract-tests.js +++ b/scripts/run-contract-tests.js @@ -331,7 +331,9 @@ const CHECKS = [ name: "MCP configuration validator", run: "npm run test:validate-mcp-config" }, + { id: "github-mcp", name: "GitHub MCP launcher", run: "npm run test:github-mcp" }, { id: "unity-mcp", name: "Unity MCP helpers", run: "npm run test:unity-mcp" }, + { id: "zai-mcp", name: "Z.AI MCP launcher", run: "npm run test:zai-mcp" }, { id: "accelerator", name: "Unity Accelerator configuration", run: "npm run test:accelerator" }, { id: "project-workspace", diff --git a/scripts/tests/test-github-mcp.mjs b/scripts/tests/test-github-mcp.mjs new file mode 100644 index 00000000..f62186e1 --- /dev/null +++ b/scripts/tests/test-github-mcp.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { resolveGithubToken } from "../mcp/github-mcp.mjs"; + +test("GitHub MCP credential resolution prefers the process environment", () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "github-mcp-credential-")); + try { + fs.writeFileSync(path.join(repoRoot, ".env.local"), "GITHUB_PERSONAL_ACCESS_TOKEN=from-file\n"); + assert.equal( + resolveGithubToken( + repoRoot, + { GITHUB_PERSONAL_ACCESS_TOKEN: "from-environment" }, + () => "from-cache" + ), + "from-environment" + ); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } +}); + +test("GitHub MCP credential resolution reads the gitignored local environment before the cache", () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "github-mcp-credential-")); + try { + fs.writeFileSync( + path.join(repoRoot, ".env.local"), + "GITHUB_PERSONAL_ACCESS_TOKEN='from local file'\n" + ); + assert.equal( + resolveGithubToken(repoRoot, {}, () => "from-cache"), + "from local file" + ); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } +}); + +test("GitHub MCP credential resolution retains the prompt-free cache as its final fallback", () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "github-mcp-credential-")); + try { + let cacheReads = 0; + assert.equal( + resolveGithubToken(repoRoot, {}, () => { + cacheReads += 1; + return "from-cache\n"; + }), + "from-cache" + ); + assert.equal(cacheReads, 1); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } +}); + +test("the GitHub MCP CLI passes only the credential name to Docker argv", () => { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "github-mcp-cli-test-")); + try { + const fakeBin = path.join(temporaryRoot, "bin"); + const capture = path.join(temporaryRoot, "capture"); + fs.mkdirSync(fakeBin); + const fakeDocker = path.join(fakeBin, "docker"); + fs.writeFileSync( + fakeDocker, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'printf \'%s\\n\' "$@" >"${GITHUB_MCP_TEST_CAPTURE:?}.args"', + 'printf \'%s\' "${GITHUB_PERSONAL_ACCESS_TOKEN:?}" >"${GITHUB_MCP_TEST_CAPTURE}.token"' + ].join("\n") + "\n" + ); + fs.chmodSync(fakeDocker, 0o755); + + const script = path.resolve("scripts", "mcp", "github-mcp.mjs"); + const result = spawnSync(process.execPath, [script], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`, + GITHUB_MCP_TEST_CAPTURE: capture, + GITHUB_PERSONAL_ACCESS_TOKEN: "github-cli-secret" + } + }); + assert.equal(result.status, 0, result.stderr); + const args = fs.readFileSync(`${capture}.args`, "utf8"); + assert.deepEqual(args.trimEnd().split("\n"), [ + "run", + "-i", + "--rm", + "-e", + "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server:latest" + ]); + assert.doesNotMatch(args, /github-cli-secret/); + assert.equal(fs.readFileSync(`${capture}.token`, "utf8"), "github-cli-secret"); + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +}); diff --git a/scripts/tests/test-github-mcp.mjs.meta b/scripts/tests/test-github-mcp.mjs.meta new file mode 100644 index 00000000..ca4abea4 --- /dev/null +++ b/scripts/tests/test-github-mcp.mjs.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f1f5c202578fc648f5a7c31816853faa +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/tests/test-post-create.sh b/scripts/tests/test-post-create.sh index c4138bbf..3dc16526 100755 --- a/scripts/tests/test-post-create.sh +++ b/scripts/tests/test-post-create.sh @@ -342,6 +342,58 @@ else "install-agent-clis.sh must install @nanocollective/nanocoder" fi +if grep -q '@z_ai/mcp-server' "$INSTALL_AGENT_CLIS"; then + pass "install-agent-clis.sh installs the Z.AI Vision MCP server" +else + fail "install-agent-clis.sh installs the Z.AI Vision MCP server" \ + "Expected @z_ai/mcp-server in install-agent-clis.sh" +fi + +if grep -q 'mcp-remote' "$INSTALL_AGENT_CLIS"; then + pass "install-agent-clis.sh installs the remote MCP transport adapter" +else + fail "install-agent-clis.sh installs the remote MCP transport adapter" \ + "Expected mcp-remote in install-agent-clis.sh" +fi + +if grep -q '"@z_ai/mcp-server@latest"' "$DOCKERFILE" && grep -q '"mcp-remote@latest"' "$DOCKERFILE"; then + pass "Dockerfile pre-installs Z.AI MCP runtime packages" +else + fail "Dockerfile pre-installs Z.AI MCP runtime packages" \ + "Expected @z_ai/mcp-server@latest and mcp-remote@latest in the global npm install" +fi + +if grep -q 'Z_AI_API_KEY.*localEnv:Z_AI_API_KEY' "$DEVCONTAINER_JSON"; then + pass "devcontainer.json forwards the host Z.AI API key" +else + fail "devcontainer.json forwards the host Z.AI API key" \ + "Expected remoteEnv to forward Z_AI_API_KEY without storing its value" +fi + +if grep -q 'github-mcp-server' "$POST_CREATE"; then + pass "post-create.sh warms the official GitHub MCP image" +else + fail "post-create.sh warms the official GitHub MCP image" \ + "Expected post-create.sh to pull ghcr.io/github/github-mcp-server" +fi + +if grep -q 'unity-mcp.mjs.*configure.*--no-discover' "$POST_START"; then + pass "post-start.sh repairs all generated MCP client configs" +else + fail "post-start.sh repairs all generated MCP client configs" \ + "Expected every container start to regenerate shared MCP configs" +fi + +for lifecycle_script in "$POST_CREATE" "$POST_START"; do + lifecycle_name="$(basename "$lifecycle_script")" + if grep -q 'unity-mcp.mjs.*configure-shared' "$lifecycle_script"; then + pass "$lifecycle_name independently repairs shared MCP configs" + else + fail "$lifecycle_name independently repairs shared MCP configs" \ + "Expected configure-shared to run independently of Unity configuration" + fi +done + if grep -q 'NPM_CONFIG_PREFIX' "$INSTALL_AGENT_CLIS"; then pass "install-agent-clis.sh uses user-global npm prefix" else diff --git a/scripts/tests/test-unity-mcp.mjs b/scripts/tests/test-unity-mcp.mjs index 7e631057..6053c0a4 100644 --- a/scripts/tests/test-unity-mcp.mjs +++ b/scripts/tests/test-unity-mcp.mjs @@ -15,6 +15,7 @@ import test from "node:test"; import { clientConfigPaths, configure, + configureShared, findUnityProjectRoot, isUnityProjectRoot, mergeCodexToml, @@ -354,6 +355,102 @@ test("Cursor and VS Code configs keep only the standard type key", () => { } }); +const EXPECTED_SHARED_MCP_SERVERS = [ + "github", + "zai-vision", + "zai-web-search", + "zai-web-reader", + "zai-zread" +]; + +test("configure writes every shared MCP server for every supported frontend", () => { + const repoRoot = newTempRepoRoot(); + try { + configure({ repoRoot, bearerToken: "t".repeat(32) }, configuredEndpoint()); + + const clients = [ + { + file: ".mcp.json", + collection: "mcpServers", + commandFor: (server) => server.command + }, + { + file: path.join(".cursor", "mcp.json"), + collection: "mcpServers", + commandFor: (server) => server.command + }, + { + file: path.join(".vscode", "mcp.json"), + collection: "servers", + commandFor: (server) => server.command + }, + { + file: "opencode.json", + collection: "mcp", + commandFor: (server) => server.command[0] + } + ]; + + for (const client of clients) { + const document = JSON.parse(fs.readFileSync(path.join(repoRoot, client.file), "utf8")); + for (const serverName of EXPECTED_SHARED_MCP_SERVERS) { + const server = document[client.collection][serverName]; + assert.ok(server, `${client.file} must configure ${serverName}`); + assert.match(client.commandFor(server), /^(?:bash|node)$/); + } + } + + const codex = fs.readFileSync(path.join(repoRoot, ".codex", "config.toml"), "utf8"); + for (const serverName of EXPECTED_SHARED_MCP_SERVERS.map((name) => name.replaceAll("-", "_"))) { + assert.match(codex, new RegExp(`^\\[mcp_servers\\.${serverName}\\]$`, "m")); + } + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } +}); + +test("shared MCP configs use tracked launchers and contain no credentials", () => { + const repoRoot = newTempRepoRoot(); + try { + configure({ repoRoot, bearerToken: "t".repeat(32) }, configuredEndpoint()); + const document = JSON.parse(fs.readFileSync(path.join(repoRoot, ".mcp.json"), "utf8")); + const github = document.mcpServers.github; + assert.deepEqual(github.args, [path.join(repoRoot, "scripts", "mcp", "github-mcp.sh")]); + + for (const [serverName, mode] of [ + ["zai-vision", "vision"], + ["zai-web-search", "web-search"], + ["zai-web-reader", "web-reader"], + ["zai-zread", "zread"] + ]) { + const server = document.mcpServers[serverName]; + assert.deepEqual(server.args, [path.join(repoRoot, "scripts", "mcp", "zai-mcp.mjs"), mode]); + } + + for (const configPath of Object.values(clientConfigPaths(repoRoot))) { + const raw = fs.readFileSync(configPath, "utf8"); + assert.doesNotMatch(raw, /Z_AI_API_KEY/); + } + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } +}); + +test("shared MCP configuration does not require a Unity endpoint or token", () => { + const repoRoot = newTempRepoRoot(); + try { + const { written } = configureShared(repoRoot); + assert.equal(written.length, 5); + const document = JSON.parse(fs.readFileSync(path.join(repoRoot, ".mcp.json"), "utf8")); + assert.ok(document.mcpServers.github); + assert.ok(document.mcpServers["zai-vision"]); + assert.equal(document.mcpServers["unity-mcp-remote"], undefined); + assert.equal(fs.existsSync(path.join(repoRoot, ".env.local")), false); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } +}); + // This repository is a PACKAGE at /Packages/com.wallstop-studios.unity-helpers, not a // Unity project. The sibling repos the bridge was ported from ARE projects, so their default of // "relay --project-path " is wrong here and would point the relay at a non-project. diff --git a/scripts/tests/test-validate-mcp-config.ps1 b/scripts/tests/test-validate-mcp-config.ps1 index b7adb8a0..43d6b965 100644 --- a/scripts/tests/test-validate-mcp-config.ps1 +++ b/scripts/tests/test-validate-mcp-config.ps1 @@ -225,9 +225,24 @@ try { } finally { Remove-Item -Recurse -Force -LiteralPath $f9 -ErrorAction SilentlyContinue } -# --- Test 10: regression smoke test against the real repo --- -$r10 = Invoke-Validator -FixtureRoot $repoRoot -Write-TestResult 'Real repository passes (exit 0)' ($r10.ExitCode -eq 0) $r10.Output +# --- Test 10: shared-only configs pass when Unity is unavailable --- +$sharedMcpJson = '{ "mcpServers": { "github": { "type": "stdio", "command": "bash", "args": ["scripts/mcp/github-mcp.sh"] } } }' +$sharedToml = "[mcp_servers.github]`ncommand = `"bash`"`nargs = [`"scripts/mcp/github-mcp.sh`"]`n" +$f10 = New-McpFixture -GitIgnore $cleanGitIgnore -Files @{ + '.mcp.json' = $sharedMcpJson + '.codex/config.toml' = $sharedToml + 'scripts/mcp/README.md' = $readmeOk + 'scripts/mcp/unity-mcp.mjs' = $bridgeScript +} +try { + $r10 = Invoke-Validator -FixtureRoot $f10 + Write-TestResult 'Shared-only configs pass without Unity (exit 0)' ($r10.ExitCode -eq 0) $r10.Output +} +finally { Remove-Item -Recurse -Force -LiteralPath $f10 -ErrorAction SilentlyContinue } + +# --- Test 11: regression smoke test against the real repo --- +$r11 = Invoke-Validator -FixtureRoot $repoRoot +Write-TestResult 'Real repository passes (exit 0)' ($r11.ExitCode -eq 0) $r11.Output Write-Host '' Write-Host "Passed: $script:TestsPassed Failed: $script:TestsFailed" -ForegroundColor White diff --git a/scripts/tests/test-zai-mcp.mjs b/scripts/tests/test-zai-mcp.mjs new file mode 100644 index 00000000..db83d723 --- /dev/null +++ b/scripts/tests/test-zai-mcp.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + createAuthorizationHeaderFile, + launchDefinition, + resolveZaiApiKey +} from "../mcp/zai-mcp.mjs"; + +test("Z.AI credential resolution prefers the process environment", () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "zai-mcp-credential-")); + try { + fs.writeFileSync(path.join(repoRoot, ".env.local"), "Z_AI_API_KEY=from-file\n"); + assert.equal( + resolveZaiApiKey(repoRoot, { Z_AI_API_KEY: "from-environment" }), + "from-environment" + ); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } +}); + +test("Z.AI credential resolution falls back to the gitignored local environment", () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "zai-mcp-credential-")); + try { + fs.writeFileSync(path.join(repoRoot, ".env.local"), "Z_AI_API_KEY='from file'\n"); + assert.equal(resolveZaiApiKey(repoRoot, {}), "from file"); + } finally { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } +}); + +test("remote Z.AI servers receive credentials through a private temporary header file", () => { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "zai-mcp-header-test-")); + try { + const header = createAuthorizationHeaderFile("secret-key", temporaryRoot); + assert.equal(fs.statSync(header.directory).mode & 0o777, 0o700); + assert.equal(fs.statSync(header.filePath).mode & 0o777, 0o600); + assert.equal(fs.readFileSync(header.filePath, "utf8"), "Authorization: Bearer secret-key\n"); + header.cleanup(); + assert.equal(fs.existsSync(header.directory), false); + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +}); + +test("the launcher maps every official Z.AI MCP service to its current transport", () => { + const cases = [ + ["vision", "zai-mcp-server", undefined], + ["web-search", "mcp-remote", "https://api.z.ai/api/mcp/web_search_prime/mcp"], + ["web-reader", "mcp-remote", "https://api.z.ai/api/mcp/web_reader/mcp"], + ["zread", "mcp-remote", "https://api.z.ai/api/mcp/zread/mcp"] + ]; + + for (const [mode, expectedCommand, expectedUrl] of cases) { + const definition = launchDefinition(mode, "secret-key", os.tmpdir()); + try { + assert.equal(definition.command, expectedCommand); + if (expectedUrl) { + assert.equal(definition.args[0], expectedUrl); + assert.equal(definition.args[1], "--header-file"); + assert.doesNotMatch(definition.args.join(" "), /secret-key/); + } else { + assert.deepEqual(definition.args, []); + assert.equal(definition.environment.Z_AI_API_KEY, "secret-key"); + assert.equal(definition.environment.Z_AI_MODE, "ZAI"); + } + } finally { + definition.cleanup(); + } + } +}); + +test("the CLI keeps the remote credential out of argv and removes its header file", () => { + const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "zai-mcp-cli-test-")); + try { + const fakeBin = path.join(temporaryRoot, "bin"); + const capture = path.join(temporaryRoot, "capture"); + fs.mkdirSync(fakeBin); + const fakeRemote = path.join(fakeBin, "mcp-remote"); + fs.writeFileSync( + fakeRemote, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'printf \'%s\\n\' "$@" >"${ZAI_TEST_CAPTURE:?}"', + 'cp "$3" "${ZAI_TEST_CAPTURE}.header"' + ].join("\n") + "\n" + ); + fs.chmodSync(fakeRemote, 0o755); + + const script = path.resolve("scripts", "mcp", "zai-mcp.mjs"); + const result = spawnSync(process.execPath, [script, "web-search"], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`, + ZAI_TEST_CAPTURE: capture, + Z_AI_API_KEY: "cli-secret" + } + }); + assert.equal(result.status, 0, result.stderr); + const args = fs.readFileSync(capture, "utf8"); + assert.doesNotMatch(args, /cli-secret/); + assert.equal( + fs.readFileSync(`${capture}.header`, "utf8"), + "Authorization: Bearer cli-secret\n" + ); + const headerPath = args.trimEnd().split("\n")[2]; + assert.equal(fs.existsSync(headerPath), false); + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +}); diff --git a/scripts/tests/test-zai-mcp.mjs.meta b/scripts/tests/test-zai-mcp.mjs.meta new file mode 100644 index 00000000..b1db1587 --- /dev/null +++ b/scripts/tests/test-zai-mcp.mjs.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 0a1b7651152f17406bc238c9b1a5b67f +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/scripts/validate-mcp-config.ps1 b/scripts/validate-mcp-config.ps1 index ad6b2d21..b4b67fe1 100644 --- a/scripts/validate-mcp-config.ps1 +++ b/scripts/validate-mcp-config.ps1 @@ -5,19 +5,19 @@ Param( <# .SYNOPSIS - Validates the repository's machine-local Unity MCP client configuration. + Validates the repository's machine-local MCP client configuration. .DESCRIPTION - The Unity MCP bridge endpoint (host:port) is per-developer, so the generated - MCP client config files must never be committed, must be structurally valid, - and must target the `/mcp` streamable-HTTP path. This linter enforces three - invariants so the MCP setup copied from DxMessaging cannot silently rot: + The Unity MCP bridge endpoint and generated launcher paths are per-developer, + so the generated MCP client config files must never be committed and must be + structurally valid. This linter enforces the invariants below so the setup + cannot silently rot: 1. UNH-MCP-TRACKED - every machine-local MCP client config path is matched by .gitignore (it holds a per-developer host:port and must never be committed). 2. UNH-MCP-INVALID - any config that IS present is structurally valid (JSON - configs are parsed; the Codex TOML block is regex-checked) and its - `unity-mcp-remote` server URL ends with `/mcp` (case-sensitive). + configs are parsed; the Codex TOML block is regex-checked). When the + optional Unity entry is present, its URL ends with `/mcp` (case-sensitive). 3. UNH-MCP-MISSINGREF - every `scripts/mcp/*.sh|*.ps1|*.mjs` path referenced by the MCP docs actually exists on disk (catches the dangling-reference class of bug, e.g. a documented helper script that was never copied over). @@ -26,8 +26,9 @@ Param( sibling studio project's port appears in it. A shared port is how a client config ends up aimed at another project's editor (issue #333). - Keep the config list in sync with scripts/mcp/unity-mcp.mjs and - docs/guides/mcp-local-setup.md. + Shared-only config is valid when Unity is unavailable; configure-shared is + deliberately independent of Unity bridge health. Keep the config list in + sync with scripts/mcp/unity-mcp.mjs and docs/guides/mcp-local-setup.md. .PARAMETER VerboseOutput Show detailed per-check output. @@ -100,10 +101,17 @@ try { continue } $serverKey = $jsonConfigs[$path] + $servers = $json.$serverKey + $hasSharedServer = $null -ne $servers -and $null -ne $servers.PSObject.Properties['github'] $url = $null try { $url = $json.$serverKey.'unity-mcp-remote'.url } catch { $url = $null } if ([string]::IsNullOrWhiteSpace($url)) { - $errors.Add("::error file=$path::UNH-MCP-INVALID: missing '$serverKey.unity-mcp-remote.url'.") + if (-not $hasSharedServer) { + $errors.Add("::error file=$path::UNH-MCP-INVALID: missing '$serverKey.unity-mcp-remote.url'.") + } + else { + Write-Info " shared-only config OK: $path" + } } elseif ($url -cnotmatch '/mcp/?$') { # Case-SENSITIVE: the server serves /mcp, not /MCP. @@ -121,7 +129,12 @@ try { # check, then drop comment lines so a commented-out url cannot pass either. $tomlBlock = [regex]::Match($toml, '(?ms)^\s*\[mcp_servers\.unity_mcp_remote\]\s*(.*?)(?=^\s*\[|\z)') if (-not $tomlBlock.Success) { - $errors.Add("::error file=.codex/config.toml::UNH-MCP-INVALID: missing [mcp_servers.unity_mcp_remote] block.") + if ($toml -notmatch '(?m)^\s*\[mcp_servers\.github\]\s*$') { + $errors.Add("::error file=.codex/config.toml::UNH-MCP-INVALID: missing [mcp_servers.unity_mcp_remote] block.") + } + else { + Write-Info ' shared-only config OK: .codex/config.toml' + } } else { $tomlBody = (($tomlBlock.Groups[1].Value -split "`n") | Where-Object { $_ -notmatch '^\s*#' }) -join "`n"