> ## Documentation Index
> Fetch the complete documentation index at: https://www.truefoundry.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Enforce Claude settings with MDM

> Lock the AI Gateway config onto every developer machine: managed-settings.json for Claude Code, managed preferences for Claude Desktop, and token refresh.

Pointing Claude Code or Claude Desktop at the gateway on one machine is a settings change a developer can undo. This guide makes it tamper-resistant across a fleet: the gateway URL, model mapping, MCP allowlist, and tool permissions go into system-level managed configuration that user and project settings cannot override, and a TrueFoundry binary deployed over MDM keeps the gateway credential fresh so no long-lived API key ever sits on a developer machine.

It covers **Claude Code** (macOS · Linux · Windows) and **Claude Desktop** (macOS · Windows). Claude Web has no endpoint to enforce — see [Claude Web](/docs/ai-gateway/claude-web) for how it is governed instead.

<Note>
  Identity setup (SSO, domain capture) and admin-console settings are configured in Anthropic's own console and documented in [Claude's docs](https://support.claude.com/en/articles/13132885-set-up-single-sign-on-sso). Enforce those first so access is tied to your IdP and employees can't fall back to personal accounts; this guide covers only what is specific to a TrueFoundry deployment.
</Note>

## How settings are enforced

Claude Code reads settings in priority order, with the system-level `managed-settings.json` taking precedence and being un-overridable by developers:

```text theme={"dark"}
System (managed-settings.json)  ← admin-controlled, highest priority
  └── Project (.claude/settings.json)
        └── User (~/.claude/settings.json)  ← developer-controlled
```

**System paths:** macOS `/Library/Application Support/ClaudeCode/managed-settings.json` · Linux `/etc/claude-code/managed-settings.json` · Windows `C:\Program Files\ClaudeCode\managed-settings.json`.

Claude Desktop has the equivalent in OS-native **managed preferences** under the `com.anthropic.claudefordesktop` domain — macOS `/Library/Managed Preferences/<user>/com.anthropic.claudefordesktop.plist`, Windows `HKLM\SOFTWARE\Policies\Claude`. The key reference is on [Claude Desktop](/docs/ai-gateway/claude-desktop#enforce-fleet-wide-via-mdm-managed-preferences).

One `managed-settings.json` enforces everything for Claude Code — gateway routing, model mapping, MCP allowlisting, tool permissions, and sandboxing. A recommended baseline:

```json expandable theme={"dark"}
{
  "permissions": {
    "disableBypassPermissionsMode": "disable",
    "deny": [],
    "ask": [
      "Bash(git push:*)",
      "Write(**)",
      "Bash(curl:*)",
      "Bash(wget:*)",
      "Read(**/.env)",
      "Read(**/.env.*)",
      "Read(**/secrets/**)",
      "Read(**/.ssh/**)",
      "Read(**/credentials/**)"
    ]
  },
  "allowManagedPermissionRulesOnly": true,
  "allowManagedHooksOnly": true,
  "transcriptRetentionDays": 14,
  "sandbox": {
    "enabled": true,
    "network": { "httpProxyPort": 8080, "socksProxyPort": 8081 }
  },
  "allowedMcpServers": [
    { "serverUrl": "https://<your-truefoundry-gateway-url>/*" }
  ],
  "strictKnownMarketplaces": [],
  "env": {
    "ANTHROPIC_BASE_URL": "https://<your-truefoundry-gateway-url>",
    "ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-code/claude-opus",
    "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-code/claude-sonnet",
    "ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-code/claude-haiku"
  }
}
```

Key controls (see [Claude's settings reference](https://code.claude.com/docs/en/settings#settings-files) for the full list):

| Setting | Effect |
| - | - |
| `disableBypassPermissionsMode` | Blocks `--dangerously-skip-permissions` |
| `allowManagedPermissionRulesOnly` | Only system rules apply — project/user can't add permissions |
| `allowManagedHooksOnly` | Prevents rogue hook injection |
| `deny` / `ask` rules | Block outright, or require explicit approval |
| `sandbox` | OS-level filesystem and network isolation ([docs](https://code.claude.com/docs/en/sandboxing)) |

## Deployment scripts

The scripts below deploy and **lock** `managed-settings.json`, then write the org MCP connectors for **both** Claude Code and Claude Desktop. On every run the script fetches a fresh gateway token for the logged-in user and writes it into `ANTHROPIC_CUSTOM_HEADERS` — no PAT or manual credentials needed. **Schedule the script to run hourly** so the token stays fresh; the browser device flow only appears on first run or after the refresh token at `~/.tf/refresh-token` expires (**30 days** by default).

<Warning>
  **Write connectors after the binary, never before.** `tfy-local-ai-setup` does not emit MCP keys, and on macOS it rewrites and re-locks the *entire* managed-preferences plist on every run — anything written before it is silently gone. On Windows the registry values are written individually, so a connector value survives, but the ordering rule is the same on both platforms.
</Warning>

The scripts download the `tfy-local-ai-setup` binary from the [truefoundry/tfy-local-ai-setup](https://github.com/truefoundry/tfy-local-ai-setup) GitHub repo. The README there documents all available flags, the default `managed-settings.json` config, and advanced usage (custom model IDs, settings templates, manual runs).

<Warning>
  Update the placeholders in the **Config** section before running: `<your-gateway-url>`, `<your-control-plane-url>`, and `<your-tenant-name>`. The script will misconfigure Claude Code if these are left as placeholders.
</Warning>

**Prerequisites**

Before running the MDM script, verify one thing: **no server-managed settings active**. Check the Anthropic Admin Console under **Settings → Policies**. If any server-side policies are enabled, they will conflict with or override the file-based `managed-settings.json`. Disable server-managed settings before proceeding.

**How the script works**

The script runs as root on a schedule (recommended: hourly) and does six things on every execution:

1. **Saves and loads config** — On first run, the three required values (`GATEWAY_URL`, `CONTROL_PLANE_URL`, `TENANT_NAME`) are read from the script's Config section and written to a root-owned config file (macOS: `/Library/Preferences/com.truefoundry.tfy-local-ai-setup.conf`; Linux: `/etc/tfy/tfy-local-ai-setup.conf`; Windows: `C:\ProgramData\TrueFoundry\tfy-local-ai-setup.conf`). On subsequent runs the file is loaded automatically — no changes to the script are needed. A non-empty value in the Config section overrides the saved file (useful for one-off updates).

2. **Installs or updates `tfy-local-ai-setup`** — Downloads the binary from the [`tfy-local-ai-setup` GitHub releases](https://github.com/truefoundry/tfy-local-ai-setup/releases) if it is not present or if the installed version does not match `RELEASE_TAG`. On subsequent runs where the version already matches, this step is skipped entirely.

3. **Detects the logged-in user and fetches a fresh auth token** — The binary identifies who is currently logged into the machine, then silently refreshes the token from `~/.tf/refresh-token`. That refresh token is valid for **30 days**. Each successful run rotates it and writes a new 30-day token, so developers do not re-login as long as the script runs at least once in that window. The browser device-authorization flow only appears the very first time, or after 30 days without a refresh. Every other run completes silently in the background.

4. **Writes and locks `managed-settings.json`** — The binary builds the JSON with the AI Gateway URL, model IDs, and the freshly fetched token, then writes it to the system-level path and locks the file so developers cannot modify it (macOS: `chflags schg`; Linux: `chattr +i`; Windows: `icacls` ACL).

5. **Writes the org MCP connectors** — From one `CONNECTORS` list the script derives both payloads and writes each to its own surface: `managed-mcp.json` for **Claude Code**, and `managedMcpServers` in the managed preferences for **Claude Desktop** (macOS plist, Windows `HKLM\SOFTWARE\Policies\Claude`). The two schemas differ — see the table below — so keep the list as the single source of truth and let the script build the JSON.

6. **Reads everything back** — Both a `plutil -replace` against a still-locked file and a registry write into a redirected 32-bit view can report success while changing nothing, so the script re-reads every value it wrote and exits non-zero on a mismatch. Run it with `--verify` (`-VerifyOnly` on Windows) at any time to print the machine's current state without writing.

| | Claude Code (CLI) | Claude Desktop |
| - | - | - |
| Delivered in | `managed-mcp.json` | `managedMcpServers` managed-preference key |
| Shape | Object keyed by server name | Array of objects, each with its own `name` |
| Per-entry | `{"type": "http", "url": "…"}` | `{"transport": "http", "url": "…", "oauth": true}` |
| Sign-in | `/mcp` or `claude mcp login <server>` | **Customize → Connectors → Connect** |
| Allow user-added servers | Omit `managed-mcp.json`, or use `managedMcpServers` in `managed-settings.json` | `isLocalDevMcpEnabled` |

<Note>
  `oauth: true` needs no `clientId` and no pre-registered redirect URI: Claude registers itself against the AI Gateway's authorization server through dynamic client registration. Leave `toolPolicy` out unless you are naming real tool names — its keys are individual tools, and an undocumented `{"*": "allow"}` risks the whole entry being rejected by the schema.
</Note>

<Tabs>
  <Tab title="macOS">
    Run via Jamf, Mosyle, Kandji, or any MDM supporting script execution. Must run as root. Detects Apple Silicon vs Intel automatically.

    ```bash expandable theme={"dark"}
    #!/bin/bash
    # =======================================================================
    # MDM Deployment Script — macOS (arm64 + amd64)
    # Claude Code / Codex / Claude Desktop  +  org MCP connectors
    #
    # Must be run as root. Re-running is safe and self-healing.
    #   sudo ./tfy-mdm-setup.sh             # deploy
    #   sudo ./tfy-mdm-setup.sh --dry-run   # print the payloads, write nothing
    #   sudo ./tfy-mdm-setup.sh --verify    # read back what is on this machine
    # =======================================================================

    set -euo pipefail

    [[ "$(id -u)" -ne 0 ]] && { echo "ERROR: Must be run as root." >&2; exit 1; }

    CONFIG_FILE="/Library/Preferences/com.truefoundry.tfy-local-ai-setup.conf"
    LOG_FILE="/var/log/tfy-local-ai-mdm.log"

    log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "${LOG_FILE}"; }

    # ---------------------------------------------------------------------------
    # Config — fill in on first run. Values are saved to ${CONFIG_FILE} and
    # reloaded automatically on subsequent runs — no edits needed after that.
    # To update a value later, set it here and run the script once more.
    # ---------------------------------------------------------------------------
    GATEWAY_URL="<your-gateway-url>"
    CONTROL_PLANE_URL="<your-control-plane-url>"
    TENANT_NAME="<your-tenant-name>"

    # Org MCP connectors. Each name must match the server name in the TrueFoundry
    # MCP registry exactly — both connector payloads are derived from it. Leave
    # the array empty to deploy inference only.
    CONNECTORS=( "github" "slack" "atlassian" )

    # true keeps user-added local MCP servers working in Claude Desktop alongside
    # the org connectors. false is the locked-down posture.
    ALLOW_LOCAL_MCP=true

    # Optional: per-tool gateway URL overrides (both default to GATEWAY_URL if not set)
    # CLAUDE_GATEWAY_URL=""
    # CODEX_GATEWAY_URL=""

    # Optional: override model IDs (defaults shown). These strings reach the
    # gateway verbatim, so each must exist in the model catalog for THIS tenant.
    # OPUS_MODEL="claude-code/claude-opus"
    # SONNET_MODEL="claude-code/claude-sonnet"
    # HAIKU_MODEL="claude-code/claude-haiku"

    # Optional: templates for Claude Code's managed-settings.json / Codex config
    # SETTINGS_FILE="/etc/tfy/base-settings.json"
    # CODEX_SETTINGS_FILE="/etc/tfy/base-codex-config.toml"

    MODE="apply"
    while [[ $# -gt 0 ]]; do
      case "$1" in
        --dry-run) MODE="dry-run"; shift ;;
        --verify)  MODE="verify";  shift ;;
        *) echo "ERROR: unknown argument: $1" >&2; exit 1 ;;
      esac
    done

    # ---------------------------------------------------------------------------
    # Load config
    # ---------------------------------------------------------------------------
    if [[ -f "${CONFIG_FILE}" ]]; then
      while IFS='=' read -r _key _value; do
        [[ -z "${_key}" || "${_key}" == \#* ]] && continue
        _key="${_key// /}"; _value="${_value%\"}"; _value="${_value#\"}"
        case "${_key}" in
          GATEWAY_URL)       [[ "${GATEWAY_URL}"       == "<your-gateway-url>" ]]       && GATEWAY_URL="${_value}" ;;
          CONTROL_PLANE_URL) [[ "${CONTROL_PLANE_URL}" == "<your-control-plane-url>" ]] && CONTROL_PLANE_URL="${_value}" ;;
          TENANT_NAME)       [[ "${TENANT_NAME}"       == "<your-tenant-name>" ]]       && TENANT_NAME="${_value}" ;;
        esac
      done < "${CONFIG_FILE}"
    fi

    [[ "${GATEWAY_URL}"       == "<your-gateway-url>" ]]       && { log "ERROR: GATEWAY_URL not set — fill it in above or check ${CONFIG_FILE}."; exit 1; }
    [[ "${CONTROL_PLANE_URL}" == "<your-control-plane-url>" ]] && { log "ERROR: CONTROL_PLANE_URL not set — fill it in above or check ${CONFIG_FILE}."; exit 1; }
    [[ "${TENANT_NAME}"       == "<your-tenant-name>" ]]       && { log "ERROR: TENANT_NAME not set — fill it in above or check ${CONFIG_FILE}."; exit 1; }

    BINARY_PATH="/usr/local/bin/tfy-local-ai-setup"
    RELEASE_TAG="v1.3.6"
    RELEASE_BASE="https://github.com/truefoundry/tfy-local-ai-setup/releases/download/${RELEASE_TAG}"
    VERSION_FILE="${BINARY_PATH}.version"
    MANAGED_DIR="/Library/Application Support/ClaudeCode"

    # Claude Desktop reads the PER-USER managed-preferences plist, and that is the
    # file tfy-local-ai-setup writes, so extend the same one.
    CONSOLE_USER="$(stat -f%Su /dev/console 2>/dev/null || echo '')"
    PLIST="/Library/Managed Preferences/${CONSOLE_USER}/com.anthropic.claudefordesktop.plist"

    # ---------------------------------------------------------------------------
    # Build both connector payloads from the one list
    #
    # Claude Desktop wants an ARRAY of {name, transport, url, oauth}, with
    # transport as a flat string. Claude Code wants an OBJECT keyed by server name
    # holding {type, url}. Same servers, two schemas — never cross them.
    # ---------------------------------------------------------------------------
    DESKTOP_MCP="["
    CLI_MCP="{\"mcpServers\":{"
    for name in "${CONNECTORS[@]:-}"; do
      [[ -z "${name}" ]] && continue
      url="${GATEWAY_URL%/}/${TENANT_NAME}/mcp/${name}/server"
      [[ "${DESKTOP_MCP}" != "[" ]] && { DESKTOP_MCP+=","; CLI_MCP+=","; }
      DESKTOP_MCP+="{\"name\":\"${name}\",\"transport\":\"http\",\"url\":\"${url}\",\"oauth\":true}"
      CLI_MCP+="\"${name}\":{\"type\":\"http\",\"url\":\"${url}\"}"
    done
    DESKTOP_MCP+="]"
    CLI_MCP+="}}"

    # A bare object and an accidental [[{...}]] both parse as JSON and are then
    # dropped by Claude Desktop with no error. This proves the payload is an array
    # whose first element is an object carrying a name.
    if [[ ${#CONNECTORS[@]} -gt 0 ]]; then
      printf '%s' "${DESKTOP_MCP}" | plutil -extract 0.name raw -o - - >/dev/null 2>&1 \
        || { echo "ERROR: built connector payload is not an array of objects: ${DESKTOP_MCP}" >&2; exit 1; }
    fi

    plist_get() { plutil -extract "$1" raw -o - "${PLIST}" 2>/dev/null || true; }

    if [[ "${MODE}" == "verify" ]]; then
      echo "Claude Code connectors : $(cat "${MANAGED_DIR}/managed-mcp.json" 2>/dev/null || echo 'managed-mcp.json NOT SET')"
      echo "Desktop connectors     : $(plist_get managedMcpServers)"
      echo "Desktop local MCP      : $(plist_get isLocalDevMcpEnabled)"
      echo "Desktop provider       : $(plist_get inferenceProvider)"
      exit 0
    fi

    if [[ "${MODE}" == "dry-run" ]]; then
      echo "managed-mcp.json  = ${CLI_MCP}"
      echo "managedMcpServers = ${DESKTOP_MCP}"
      echo "target plist      = ${PLIST}"
      exit 0
    fi

    # Persist config for future runs (root-owned, 644 — developers cannot edit)
    cat > "${CONFIG_FILE}" <<CONF
    GATEWAY_URL="${GATEWAY_URL}"
    CONTROL_PLANE_URL="${CONTROL_PLANE_URL}"
    TENANT_NAME="${TENANT_NAME}"
    CONF
    chown root:wheel "${CONFIG_FILE}" && chmod 644 "${CONFIG_FILE}"
    log "Config saved to ${CONFIG_FILE}."

    # ---------------------------------------------------------------------------
    # Install binary (skip if already on the correct release tag)
    # ---------------------------------------------------------------------------
    INSTALLED_TAG="$([[ -f "${VERSION_FILE}" ]] && cat "${VERSION_FILE}" || echo '')"

    if [[ ! -f "${BINARY_PATH}" ]] || [[ "${INSTALLED_TAG}" != "${RELEASE_TAG}" ]]; then
      case "$(uname -m)" in
        arm64)  BINARY_URL="${RELEASE_BASE}/tfy-local-ai-setup-darwin-arm64" ;;
        x86_64) BINARY_URL="${RELEASE_BASE}/tfy-local-ai-setup-darwin-amd64" ;;
        *) echo "ERROR: Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
      esac
      log "Installing tfy-local-ai-setup ${RELEASE_TAG} ($(uname -m))..."
      # Download to a temp path and move into place, so an interrupted transfer
      # cannot leave a truncated binary that later runs treat as installed.
      curl -fsSL "${BINARY_URL}" -o "${BINARY_PATH}.download"
      chmod +x "${BINARY_PATH}.download"
      mv "${BINARY_PATH}.download" "${BINARY_PATH}"
      log "tfy-local-ai-setup installed at ${BINARY_PATH}."
    else
      log "tfy-local-ai-setup ${RELEASE_TAG} already installed — skipping download."
    fi

    # ---------------------------------------------------------------------------
    # Run — device login, then write + lock the managed config for every AI tool
    # actually installed. The binary owns the API key; this script never sees one.
    #
    # No exec: the version file must only be written after a successful run, or a
    # binary that installs but never works is treated as current and later runs
    # skip the re-download that would fix it.
    # ---------------------------------------------------------------------------
    RUN_ARGS=( --url="${CONTROL_PLANE_URL}" --tenant="${TENANT_NAME}" --gateway="${GATEWAY_URL}" )
    [[ -n "${CLAUDE_GATEWAY_URL:-}" ]]  && RUN_ARGS+=(--claude-gateway="${CLAUDE_GATEWAY_URL}")
    [[ -n "${CODEX_GATEWAY_URL:-}" ]]   && RUN_ARGS+=(--codex-gateway="${CODEX_GATEWAY_URL}")
    [[ -n "${OPUS_MODEL:-}" ]]          && RUN_ARGS+=(--opus-model="${OPUS_MODEL}")
    [[ -n "${SONNET_MODEL:-}" ]]        && RUN_ARGS+=(--sonnet-model="${SONNET_MODEL}")
    [[ -n "${HAIKU_MODEL:-}" ]]         && RUN_ARGS+=(--haiku-model="${HAIKU_MODEL}")
    [[ -n "${SETTINGS_FILE:-}" ]]       && RUN_ARGS+=(--settings-file="${SETTINGS_FILE}")
    [[ -n "${CODEX_SETTINGS_FILE:-}" ]] && RUN_ARGS+=(--codex-settings-file="${CODEX_SETTINGS_FILE}")

    set +e
    "${BINARY_PATH}" "${RUN_ARGS[@]}" 2>&1 | tee -a "${LOG_FILE}"
    CODE=${PIPESTATUS[0]}
    set -e

    if [[ ${CODE} -ne 0 ]]; then
      log "Setup failed with exit code ${CODE}."
      log "Stopping before the connector writes — there is no inference config to attach them to."
      exit ${CODE}
    fi
    echo "${RELEASE_TAG}" > "${VERSION_FILE}"

    # ---------------------------------------------------------------------------
    # Claude Code connectors — managed-mcp.json
    #
    # The binary does not write this file, so the script owns it: unlock, write,
    # re-lock, so developers cannot edit it between runs.
    # ---------------------------------------------------------------------------
    if [[ ${#CONNECTORS[@]} -gt 0 && -d "${MANAGED_DIR}" ]]; then
      chflags noschg "${MANAGED_DIR}/managed-mcp.json" 2>/dev/null || true
      printf '%s' "${CLI_MCP}" > "${MANAGED_DIR}/managed-mcp.json"
      plutil -convert json -o /dev/null "${MANAGED_DIR}/managed-mcp.json"   # parses, or abort
      chown root:wheel "${MANAGED_DIR}/managed-mcp.json"
      chmod 644 "${MANAGED_DIR}/managed-mcp.json"
      chflags schg "${MANAGED_DIR}/managed-mcp.json"
      log "Claude Code: ${#CONNECTORS[@]} connectors written to managed-mcp.json."
    else
      log "Claude Code not configured on this machine — skipping managed-mcp.json."
    fi

    # ---------------------------------------------------------------------------
    # Claude Desktop connectors — managedMcpServers
    #
    # Must run AFTER the binary and on EVERY run: it rewrites and re-locks the
    # whole plist each time, so anything written before it is gone.
    # ---------------------------------------------------------------------------
    if [[ -z "${CONSOLE_USER}" || "${CONSOLE_USER}" == "root" ]]; then
      log "No console user logged in — skipping Desktop connectors; the next run picks it up."
      exit 0
    fi
    if [[ ! -f "${PLIST}" ]]; then
      log "Claude Desktop not configured for ${CONSOLE_USER} — skipping. Clean no-op, not a failure."
      exit 0
    fi

    chflags noschg "${PLIST}" 2>/dev/null || true
    plutil -replace managedMcpServers    -string "${DESKTOP_MCP}"     "${PLIST}"
    plutil -replace isLocalDevMcpEnabled -bool   "${ALLOW_LOCAL_MCP}" "${PLIST}"
    chflags schg "${PLIST}" 2>/dev/null || true
    killall cfprefsd 2>/dev/null || true   # else the app reads cached values

    # ---------------------------------------------------------------------------
    # Verify — plutil -replace can report success while the file stays unchanged
    # (it was still immutable), so read the values back rather than trust exit codes.
    # ---------------------------------------------------------------------------
    FAILED=0
    if [[ "$(plist_get managedMcpServers)" != "${DESKTOP_MCP}" ]]; then
      log "FAIL: managedMcpServers does not match what was written."; FAILED=1
    fi
    # Connectors ride on the inference block: in first-party mode the app ignores
    # the entire managed config and the Connectors pane comes up empty.
    if [[ "$(plist_get inferenceProvider)" != "gateway" ]]; then
      log "FAIL: inferenceProvider is not 'gateway' — Desktop will ignore the managed block."; FAILED=1
    fi
    if [[ -z "$(plist_get inferenceGatewayApiKey)" ]]; then
      log "FAIL: inferenceGatewayApiKey is empty — the device login did not produce a token."; FAILED=1
    fi
    if [[ ${FAILED} -ne 0 ]]; then
      log "Configuration was NOT applied correctly — re-run and check the log above."
      exit 1
    fi

    log "Done. Fully quit Claude Desktop (Cmd-Q, not just the window) and relaunch."
    log "Connectors appear under Customize → Connectors, each with a Connect button —"
    log "they are not pre-connected; every user signs in with their own account."
    log "If the pane is empty, grep ~/Library/Logs/Claude-3p/main.log for:"
    log "  [custom-3p] Credentials loaded from managed config { mcpServerCount: N }"
    log "N is how many entries survived parsing; a 0 is preceded by an 'entry [i]"
    log "dropped' error naming the exact field the schema rejected."
    ```

    <Note>
      Pass `--claude-code`, `--codex`, or `--claude-desktop` to target specific tools. From `v1.3.6` an explicit flag **configures that tool whether or not it is currently installed**, so a client installed later is governed from its first launch — useful when you are rolling a tool out and the MDM job runs before the install lands. `--codex` covers both Codex surfaces, the CLI and the desktop app, which share the same managed config.

      With none of those flags the binary auto-detects what is installed and configures each one, and a machine with no AI client is a clean no-op — so the same script is safe to push fleet-wide.
    </Note>

    | Path | Owner | Mode | Effect |
    | - | - | - | - |
    | `/Library/Application Support/ClaudeCode/` | `root:wheel` | `755` | Users can read but not write |
    | `managed-settings.json` | `root:wheel` | `644` + `schg` | Locked — root must run `chflags noschg` to edit |
    | `managed-mcp.json` | `root:wheel` | `644` + `schg` | Locked — exclusive control of Claude Code's MCP servers |
    | `/Library/Managed Preferences/<user>/com.anthropic.claudefordesktop.plist` | `root:wheel` | `644` + `schg` | Claude Desktop inference config + connectors |
  </Tab>

  <Tab title="Linux">
    Run via your Linux MDM or configuration management tool (Ansible, Chef, Puppet, etc.). Must run as root. Claude Desktop has no Linux build, so this script configures **Claude Code and Codex** only.

    ```bash expandable theme={"dark"}
    #!/bin/bash
    # =======================================================================
    # MDM Deployment Script — Linux (amd64)
    # Claude Code / Codex  +  org MCP connectors
    #
    # Must be run as root. Re-running is safe and self-healing.
    #   sudo ./tfy-mdm-setup.sh             # deploy
    #   sudo ./tfy-mdm-setup.sh --dry-run   # print the payload, write nothing
    #   sudo ./tfy-mdm-setup.sh --verify    # read back what is on this machine
    # =======================================================================

    set -euo pipefail

    [[ "$(id -u)" -ne 0 ]] && { echo "ERROR: Must be run as root." >&2; exit 1; }

    CONFIG_FILE="/etc/tfy/tfy-local-ai-setup.conf"
    LOG_FILE="/var/log/tfy-local-ai-mdm.log"

    log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "${LOG_FILE}"; }

    # ---------------------------------------------------------------------------
    # Config — fill in on first run. Values are saved to ${CONFIG_FILE} and
    # reloaded automatically on subsequent runs — no edits needed after that.
    # ---------------------------------------------------------------------------
    GATEWAY_URL="<your-gateway-url>"
    CONTROL_PLANE_URL="<your-control-plane-url>"
    TENANT_NAME="<your-tenant-name>"

    # Org MCP connectors. Each name must match the server name in the TrueFoundry
    # MCP registry exactly — the URL is derived from it. Leave the array empty to
    # deploy inference only.
    CONNECTORS=( "github" "slack" "atlassian" )

    # Optional: per-tool gateway URL overrides (both default to GATEWAY_URL if not set)
    # CLAUDE_GATEWAY_URL=""
    # CODEX_GATEWAY_URL=""

    # Optional: override model IDs (defaults shown)
    # OPUS_MODEL="claude-code/claude-opus"
    # SONNET_MODEL="claude-code/claude-sonnet"
    # HAIKU_MODEL="claude-code/claude-haiku"

    # Optional: templates for Claude Code's managed-settings.json / Codex config
    # SETTINGS_FILE="/etc/tfy/base-settings.json"
    # CODEX_SETTINGS_FILE="/etc/tfy/base-codex-config.toml"

    MODE="apply"
    while [[ $# -gt 0 ]]; do
      case "$1" in
        --dry-run) MODE="dry-run"; shift ;;
        --verify)  MODE="verify";  shift ;;
        *) echo "ERROR: unknown argument: $1" >&2; exit 1 ;;
      esac
    done

    # ---------------------------------------------------------------------------
    # Load config
    # ---------------------------------------------------------------------------
    if [[ -f "${CONFIG_FILE}" ]]; then
      while IFS='=' read -r _key _value; do
        [[ -z "${_key}" || "${_key}" == \#* ]] && continue
        _key="${_key// /}"; _value="${_value%\"}"; _value="${_value#\"}"
        case "${_key}" in
          GATEWAY_URL)       [[ "${GATEWAY_URL}"       == "<your-gateway-url>" ]]       && GATEWAY_URL="${_value}" ;;
          CONTROL_PLANE_URL) [[ "${CONTROL_PLANE_URL}" == "<your-control-plane-url>" ]] && CONTROL_PLANE_URL="${_value}" ;;
          TENANT_NAME)       [[ "${TENANT_NAME}"       == "<your-tenant-name>" ]]       && TENANT_NAME="${_value}" ;;
        esac
      done < "${CONFIG_FILE}"
    fi

    [[ "${GATEWAY_URL}"       == "<your-gateway-url>" ]]       && { log "ERROR: GATEWAY_URL not set — fill it in above or check ${CONFIG_FILE}."; exit 1; }
    [[ "${CONTROL_PLANE_URL}" == "<your-control-plane-url>" ]] && { log "ERROR: CONTROL_PLANE_URL not set — fill it in above or check ${CONFIG_FILE}."; exit 1; }
    [[ "${TENANT_NAME}"       == "<your-tenant-name>" ]]       && { log "ERROR: TENANT_NAME not set — fill it in above or check ${CONFIG_FILE}."; exit 1; }

    BINARY_PATH="/usr/local/bin/tfy-local-ai-setup"
    RELEASE_TAG="v1.3.6"
    BINARY_URL="https://github.com/truefoundry/tfy-local-ai-setup/releases/download/${RELEASE_TAG}/tfy-local-ai-setup-linux-amd64"
    VERSION_FILE="${BINARY_PATH}.version"
    MANAGED_DIR="/etc/claude-code"
    MANAGED_MCP="${MANAGED_DIR}/managed-mcp.json"

    # ---------------------------------------------------------------------------
    # Build the Claude Code connector payload: an OBJECT keyed by server name.
    # (Claude Desktop's array-of-objects form does not apply here — no Linux build.)
    # ---------------------------------------------------------------------------
    CLI_MCP="{\"mcpServers\":{"
    for name in "${CONNECTORS[@]:-}"; do
      [[ -z "${name}" ]] && continue
      [[ "${CLI_MCP}" != "{\"mcpServers\":{" ]] && CLI_MCP+=","
      CLI_MCP+="\"${name}\":{\"type\":\"http\",\"url\":\"${GATEWAY_URL%/}/${TENANT_NAME}/mcp/${name}/server\"}"
    done
    CLI_MCP+="}}"

    if [[ "${MODE}" == "verify" ]]; then
      echo "Claude Code connectors : $(cat "${MANAGED_MCP}" 2>/dev/null || echo 'managed-mcp.json NOT SET')"
      echo "Gateway                : $(python3 -c 'import json,sys;print(json.load(open("/etc/claude-code/managed-settings.json"))["env"].get("ANTHROPIC_BASE_URL",""))' 2>/dev/null || echo 'managed-settings.json NOT SET')"
      exit 0
    fi

    if [[ "${MODE}" == "dry-run" ]]; then
      echo "managed-mcp.json = ${CLI_MCP}"
      exit 0
    fi

    # Persist config for future runs (root-owned, 644 — developers cannot edit)
    mkdir -p "$(dirname "${CONFIG_FILE}")"
    cat > "${CONFIG_FILE}" <<CONF
    GATEWAY_URL="${GATEWAY_URL}"
    CONTROL_PLANE_URL="${CONTROL_PLANE_URL}"
    TENANT_NAME="${TENANT_NAME}"
    CONF
    chown root:root "${CONFIG_FILE}" && chmod 644 "${CONFIG_FILE}"
    log "Config saved to ${CONFIG_FILE}."

    # ---------------------------------------------------------------------------
    # Install binary (skip if already on the correct release tag)
    # ---------------------------------------------------------------------------
    INSTALLED_TAG="$([[ -f "${VERSION_FILE}" ]] && cat "${VERSION_FILE}" || echo '')"

    if [[ ! -f "${BINARY_PATH}" ]] || [[ "${INSTALLED_TAG}" != "${RELEASE_TAG}" ]]; then
      log "Installing tfy-local-ai-setup ${RELEASE_TAG} (linux-amd64)..."
      # Download to a temp path and move into place, so an interrupted transfer
      # cannot leave a truncated binary that later runs treat as installed.
      curl -fsSL "${BINARY_URL}" -o "${BINARY_PATH}.download"
      chmod +x "${BINARY_PATH}.download"
      mv "${BINARY_PATH}.download" "${BINARY_PATH}"
      log "tfy-local-ai-setup installed at ${BINARY_PATH}."
    else
      log "tfy-local-ai-setup ${RELEASE_TAG} already installed — skipping download."
    fi

    # ---------------------------------------------------------------------------
    # Run — device login, then write + lock the managed config for every AI tool
    # actually installed. The binary owns the API key; this script never sees one.
    # ---------------------------------------------------------------------------
    RUN_ARGS=( --url="${CONTROL_PLANE_URL}" --tenant="${TENANT_NAME}" --gateway="${GATEWAY_URL}" )
    [[ -n "${CLAUDE_GATEWAY_URL:-}" ]]  && RUN_ARGS+=(--claude-gateway="${CLAUDE_GATEWAY_URL}")
    [[ -n "${CODEX_GATEWAY_URL:-}" ]]   && RUN_ARGS+=(--codex-gateway="${CODEX_GATEWAY_URL}")
    [[ -n "${OPUS_MODEL:-}" ]]          && RUN_ARGS+=(--opus-model="${OPUS_MODEL}")
    [[ -n "${SONNET_MODEL:-}" ]]        && RUN_ARGS+=(--sonnet-model="${SONNET_MODEL}")
    [[ -n "${HAIKU_MODEL:-}" ]]         && RUN_ARGS+=(--haiku-model="${HAIKU_MODEL}")
    [[ -n "${SETTINGS_FILE:-}" ]]       && RUN_ARGS+=(--settings-file="${SETTINGS_FILE}")
    [[ -n "${CODEX_SETTINGS_FILE:-}" ]] && RUN_ARGS+=(--codex-settings-file="${CODEX_SETTINGS_FILE}")

    set +e
    "${BINARY_PATH}" "${RUN_ARGS[@]}" 2>&1 | tee -a "${LOG_FILE}"
    CODE=${PIPESTATUS[0]}
    set -e

    if [[ ${CODE} -ne 0 ]]; then
      log "Setup failed with exit code ${CODE} — stopping before the connector write."
      exit ${CODE}
    fi
    echo "${RELEASE_TAG}" > "${VERSION_FILE}"

    # ---------------------------------------------------------------------------
    # Claude Code connectors — managed-mcp.json
    #
    # The binary does not write this file, so the script owns it: unlock, write,
    # re-lock, so developers cannot edit it between runs.
    # ---------------------------------------------------------------------------
    if [[ ${#CONNECTORS[@]} -gt 0 && -d "${MANAGED_DIR}" ]]; then
      chattr -i "${MANAGED_MCP}" 2>/dev/null || true
      printf '%s' "${CLI_MCP}" > "${MANAGED_MCP}"
      chown root:root "${MANAGED_MCP}" && chmod 644 "${MANAGED_MCP}"
      chattr +i "${MANAGED_MCP}" 2>/dev/null || log "WARNING: chattr +i failed (not supported on this filesystem)."
      log "Claude Code: ${#CONNECTORS[@]} connectors written to ${MANAGED_MCP}."
    else
      log "Claude Code not configured on this machine — skipping managed-mcp.json."
    fi

    log "Done. Developers run /mcp inside Claude Code once to sign in to each server."
    ```

    | Path | Owner | Mode | Effect |
    | - | - | - | - |
    | `/etc/claude-code/` | `root:root` | `755` | Users can read but not write |
    | `managed-settings.json` | `root:root` | `644` + `chattr +i` | Immutable — root must run `chattr -i` to edit |
    | `managed-mcp.json` | `root:root` | `644` + `chattr +i` | Immutable — exclusive control of Claude Code's MCP servers |
  </Tab>

  <Tab title="Windows">
    Run via Intune, SCCM, ManageEngine, or Group Policy as SYSTEM (or from an elevated PowerShell). This is a PowerShell script.

    ```powershell expandable theme={"dark"}
    # =========================================================================
    # MDM Deployment Script — Windows (amd64)
    # Claude Code / Codex / Claude Desktop  +  org MCP connectors
    #
    # Run hourly as SYSTEM. Re-running is safe and self-healing.
    #   .\tfy-mdm-setup.ps1              # deploy
    #   .\tfy-mdm-setup.ps1 -DryRun      # print the payloads, write nothing
    #   .\tfy-mdm-setup.ps1 -VerifyOnly  # read back what is on this machine
    # =========================================================================

    #Requires -RunAsAdministrator
    [CmdletBinding()]
    param(
      [string]$GatewayUrl      = "<your-gateway-url>",
      [string]$ControlPlaneUrl = "<your-control-plane-url>",
      [string]$TenantName      = "<your-tenant-name>",

      # Org MCP connectors. Each name must match the server name in the
      # TrueFoundry MCP registry exactly — both payloads are derived from it.
      [string[]]$Connectors = @("github", "slack", "atlassian"),

      # "true" keeps user-added local MCP servers working in Claude Desktop
      # alongside the org connectors. "false" is the locked-down posture.
      [ValidateSet("true", "false")][string]$AllowLocalMcp = "true",

      [string]$OpusModel    = "",   # defaults to claude-code/claude-opus
      [string]$SonnetModel  = "",   # defaults to claude-code/claude-sonnet
      [string]$HaikuModel   = "",   # defaults to claude-code/claude-haiku
      [string]$SettingsFile = "",   # JSON template for managed-settings.json
      [string]$ReleaseTag   = "v1.3.6",
      [switch]$DryRun,
      [switch]$VerifyOnly,
      [switch]$Relaunched           # internal — set by the re-launch below
    )

    # -------------------------------------------------------------------------
    # Re-launch under 64-bit PowerShell if started from a 32-bit host.
    #
    # A 32-bit MDM agent (ManageEngine lives in "Program Files (x86)") gets WOW64
    # redirection, so a write to HKLM\SOFTWARE\Policies\Claude lands under
    # WOW6432Node — where the 64-bit Claude Desktop never looks. That is a silent,
    # green-in-the-console failure. "Sysnative" exists only inside a 32-bit
    # process and is the supported way back to the native shell.
    # -------------------------------------------------------------------------
    if (-not $Relaunched -and -not [Environment]::Is64BitProcess -and [Environment]::Is64BitOperatingSystem) {
      $NativeShell = Join-Path $env:SystemRoot "Sysnative\WindowsPowerShell\v1.0\powershell.exe"
      if ($PSCommandPath -and (Test-Path $NativeShell)) {
        $Forward = @()
        foreach ($Entry in $PSBoundParameters.GetEnumerator()) {
          $Value = $Entry.Value
          if ($Value -is [switch]) { if ($Value.IsPresent) { $Forward += "-$($Entry.Key)" } }
          elseif ($Value -is [array]) { $Forward += "-$($Entry.Key)"; $Forward += (($Value | ForEach-Object { "'$_'" }) -join ",") }
          else { $Forward += "-$($Entry.Key)"; $Forward += "'$Value'" }
        }
        & $NativeShell -NoProfile -ExecutionPolicy Bypass -File $PSCommandPath @Forward -Relaunched
        exit $LASTEXITCODE
      }
      Write-Warning "Running 32-bit and cannot re-launch — registry writes may land in WOW6432Node."
    }

    Set-StrictMode -Version Latest
    $ErrorActionPreference = "Stop"
    # PowerShell 7.4+ turns a non-zero native exit code into a terminating error
    # while $ErrorActionPreference is Stop. icacls below is best-effort and the
    # binary's exit code is handled explicitly, so opt out.
    if (Test-Path Variable:PSNativeCommandUseErrorActionPreference) { $PSNativeCommandUseErrorActionPreference = $false }
    # GitHub requires TLS 1.2; PowerShell 5.1 on older builds still defaults to 1.0.
    [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12

    $ConfigFile  = "C:\ProgramData\TrueFoundry\tfy-local-ai-setup.conf"
    $BinaryDir   = "C:\Program Files\TrueFoundry"
    $BinaryPath  = "$BinaryDir\tfy-local-ai-setup.exe"
    $VersionFile = "$BinaryPath.version"
    $BinaryUrl   = "https://github.com/truefoundry/tfy-local-ai-setup/releases/download/$ReleaseTag/tfy-local-ai-setup-windows-amd64.exe"
    $ManagedMcp  = "C:\Program Files\ClaudeCode\managed-mcp.json"
    $LogFile     = "C:\Windows\Temp\tfy-local-ai-mdm.log"

    # HKLM is the hive SYSTEM writes and the one to use. Since Claude Desktop
    # v1.19367.0 the hives are NOT merged: anything at all under HKLM makes the
    # app ignore HKCU entirely, so the whole configuration must live in one hive.
    $RegistryKey = "HKLM:\SOFTWARE\Policies\Claude"

    function Write-Log {
      param([string]$Message)
      $Line = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $Message"
      Write-Host $Line
      Add-Content -Path $LogFile -Value $Line
    }

    # Set-StrictMode makes a missing property throw, so every read goes through here.
    function Get-RegValue {
      param([string]$Name)
      $Item = Get-ItemProperty -Path $RegistryKey -ErrorAction SilentlyContinue
      if (-not $Item) { return $null }
      if (@($Item.PSObject.Properties.Name) -contains $Name) { return $Item.$Name }
      return $null
    }

    # -------------------------------------------------------------------------
    # Build both connector payloads from the one list
    #
    # Claude Desktop wants an ARRAY of {name, transport, url, oauth}; Claude Code
    # wants an OBJECT keyed by server name holding {type, url}.
    #
    # String concatenation on purpose: ConvertTo-Json -Compress can emit a bare
    # object for a ONE-element array, and re-wrapping it yields [[{...}]] — which
    # parses cleanly and is then silently dropped by the app.
    # -------------------------------------------------------------------------
    $GatewayBase = $GatewayUrl.TrimEnd("/")
    $DesktopMcp = "[" + (($Connectors | ForEach-Object {
      '{{"name":"{0}","transport":"http","url":"{1}/{2}/mcp/{0}/server","oauth":true}}' -f $_, $GatewayBase, $TenantName
    }) -join ",") + "]"
    $CliMcp = '{"mcpServers":{' + (($Connectors | ForEach-Object {
      '"{0}":{{"type":"http","url":"{1}/{2}/mcp/{0}/server"}}' -f $_, $GatewayBase, $TenantName
    }) -join ",") + "}}"

    # Parseable is not enough — a bare object and a nested array both parse. The
    # ForEach-Object is load-bearing: Windows PowerShell 5.1's ConvertFrom-Json
    # returns a JSON array as ONE Object[] instead of enumerating it.
    $Parsed = @($DesktopMcp | ConvertFrom-Json | ForEach-Object { $_ })
    if ($Parsed.Count -ne $Connectors.Count) { throw "connector payload has $($Parsed.Count) entries, expected $($Connectors.Count): $DesktopMcp" }
    foreach ($Entry in $Parsed) {
      if ($Entry -is [System.Object[]]) { throw "connector payload is a nested array, not an array of objects: $DesktopMcp" }
    }
    $null = $CliMcp | ConvertFrom-Json

    if ($VerifyOnly) {
      Write-Host ("inferenceProvider      : {0}" -f (Get-RegValue "inferenceProvider"))
      Write-Host ("managedMcpServers      : {0}" -f (Get-RegValue "managedMcpServers"))
      Write-Host ("isLocalDevMcpEnabled   : {0}" -f (Get-RegValue "isLocalDevMcpEnabled"))
      Write-Host ("Claude Code connectors : {0}" -f $(if (Test-Path $ManagedMcp) { (Get-Content $ManagedMcp -Raw).Trim() } else { "managed-mcp.json NOT SET" }))
      exit 0
    }
    if ($DryRun) {
      Write-Host "managed-mcp.json  = $CliMcp"
      Write-Host "managedMcpServers = $DesktopMcp"
      exit 0
    }

    # -------------------------------------------------------------------------
    # Persist config so a later run with no parameters still has it.
    #
    # ACLs are granted by SID: the literal strings SYSTEM / Administrators / Users
    # do not resolve on a non-English Windows install, where icacls then fails.
    # -------------------------------------------------------------------------
    $ConfigDir = Split-Path $ConfigFile
    if (-not (Test-Path $ConfigDir)) { New-Item -ItemType Directory -Path $ConfigDir -Force | Out-Null }
    @"
    GATEWAY_URL="$GatewayUrl"
    CONTROL_PLANE_URL="$ControlPlaneUrl"
    TENANT_NAME="$TenantName"
    "@ | Set-Content -Path $ConfigFile -Encoding UTF8
    icacls $ConfigFile /inheritance:r /grant '*S-1-5-18:(F)' /grant '*S-1-5-32-544:(F)' /grant '*S-1-5-32-545:(R)' 2>$null | Out-Null
    Write-Log "Config saved to $ConfigFile."

    # -------------------------------------------------------------------------
    # Install the binary (skipped when already on $ReleaseTag)
    # -------------------------------------------------------------------------
    $InstalledTag = if (Test-Path $VersionFile) { (Get-Content $VersionFile -Raw).Trim() } else { "" }

    if (-not (Test-Path $BinaryPath) -or $InstalledTag -ne $ReleaseTag) {
      Write-Log "Installing tfy-local-ai-setup $ReleaseTag (windows-amd64)..."
      if (-not (Test-Path $BinaryDir)) { New-Item -ItemType Directory -Path $BinaryDir -Force | Out-Null }
      # Download to a temp path and move into place, so an interrupted transfer
      # cannot leave a truncated binary that later runs treat as installed.
      Invoke-WebRequest -Uri $BinaryUrl -OutFile "$BinaryPath.download" -UseBasicParsing
      Move-Item -Path "$BinaryPath.download" -Destination $BinaryPath -Force
      Write-Log "Installed at $BinaryPath."
    } else {
      Write-Log "tfy-local-ai-setup $ReleaseTag already installed — skipping download."
    }

    # -------------------------------------------------------------------------
    # Run it — device login, then write + lock the managed config for every AI
    # tool installed. The binary owns the API key; this script never sees one.
    # -------------------------------------------------------------------------
    $RunArgs = @("--url=$ControlPlaneUrl", "--tenant=$TenantName", "--gateway=$GatewayUrl")
    if ($OpusModel)    { $RunArgs += "--opus-model=$OpusModel" }
    if ($SonnetModel)  { $RunArgs += "--sonnet-model=$SonnetModel" }
    if ($HaikuModel)   { $RunArgs += "--haiku-model=$HaikuModel" }
    if ($SettingsFile) { $RunArgs += "--settings-file=$SettingsFile" }

    & $BinaryPath @RunArgs
    if ($LASTEXITCODE -ne 0) {
      Write-Log "Setup failed ($LASTEXITCODE) — stopping before the connector writes."
      exit $LASTEXITCODE
    }
    # Record the version only after a successful run, or a binary that installs
    # but never works is treated as current and never re-downloaded.
    Set-Content -Path $VersionFile -Value $ReleaseTag

    # -------------------------------------------------------------------------
    # Claude Code connectors — managed-mcp.json (the binary does not write it)
    # -------------------------------------------------------------------------
    if ($Connectors.Count -gt 0 -and (Test-Path (Split-Path $ManagedMcp))) {
      Set-Content -Path $ManagedMcp -Value $CliMcp -Encoding UTF8
      icacls $ManagedMcp /inheritance:r /grant '*S-1-5-18:(F)' /grant '*S-1-5-32-544:(F)' /grant '*S-1-5-32-545:(R)' 2>$null | Out-Null
      Write-Log "Claude Code: $($Connectors.Count) connectors written to $ManagedMcp."
    } else {
      Write-Log "Claude Code not configured on this machine — skipping managed-mcp.json."
    }

    # -------------------------------------------------------------------------
    # Claude Desktop connectors — managedMcpServers
    #
    # New-ItemProperty, NOT reg add: PowerShell does not escape embedded double
    # quotes when building a native command line, so reg.exe re-parses the JSON
    # and strips every " from the stored value — which yields an empty
    # Connectors pane and no error anywhere.
    #
    # Never run New-Item -Force on the key: on the registry provider that deletes
    # and recreates it, wiping the inference values the binary just wrote and
    # logging the user out of Claude Desktop.
    # -------------------------------------------------------------------------
    if (-not (Test-Path $RegistryKey)) {
      Write-Log "Claude Desktop not configured on this machine — skipping. Clean no-op, not a failure."
      exit 0
    }

    # -PropertyType String is REG_SZ, the only type to write here. REG_EXPAND_SZ
    # counts as machine policy being PRESENT but unreadable, which disables HKCU
    # too and takes the entire managed config down silently.
    New-ItemProperty -Path $RegistryKey -Name managedMcpServers    -Value $DesktopMcp    -PropertyType String -Force | Out-Null
    New-ItemProperty -Path $RegistryKey -Name isLocalDevMcpEnabled -Value $AllowLocalMcp -PropertyType String -Force | Out-Null

    # -------------------------------------------------------------------------
    # Verify — a write can succeed against a redirected view, and a round-tripped
    # value can come back quote-stripped, so read everything back.
    # -------------------------------------------------------------------------
    $Failed = 0
    if ((Get-RegValue "managedMcpServers") -ne $DesktopMcp) {
      Write-Log "FAIL: managedMcpServers does not match what was written."; $Failed = 1
    }
    # Connectors ride on the inference block: in first-party mode the app ignores
    # the entire managed config and the Connectors pane comes up empty.
    if ((Get-RegValue "inferenceProvider") -ne "gateway") {
      Write-Log "FAIL: inferenceProvider is not 'gateway' — Desktop will ignore the managed block."; $Failed = 1
    }
    if (-not (Get-RegValue "inferenceGatewayApiKey")) {
      Write-Log "FAIL: inferenceGatewayApiKey is empty — the device login did not produce a token."; $Failed = 1
    }
    if ($Failed -ne 0) { Write-Log "Configuration was NOT applied correctly — re-run and check $LogFile."; exit 1 }

    Write-Log "Done. Fully quit Claude Desktop (tray icon → Quit) and relaunch."
    Write-Log "Connectors appear under Customize → Connectors, each with a Connect button —"
    Write-Log "they are not pre-connected; every user signs in with their own account."
    Write-Log "If the pane is empty, grep %LOCALAPPDATA%\Claude-3p\logs\main.log (NOT %APPDATA%) for:"
    Write-Log "  [custom-3p] Credentials loaded from managed config { mcpServerCount: N }"
    ```

    | Path | Permissions | Effect |
    | - | - | - |
    | `C:\Program Files\ClaudeCode\` | SYSTEM: Full, Admins: Full, Users: Read | Users cannot write to the directory |
    | `managed-settings.json` | ACL: SYSTEM full, Users read-only | Re-run the script to update it |
    | `managed-mcp.json` | ACL: SYSTEM full, Users read-only | Exclusive control of Claude Code's MCP servers |
    | `HKLM\SOFTWARE\Policies\Claude` | REG\_SZ values, machine hive | Claude Desktop inference config + connectors |
  </Tab>
</Tabs>

<Note>
  Prefer a server-side option? Claude's Admin Console (**Settings → Policies**) can push tool permissions, MCP allowlists, and model restrictions to all members without deploying files — useful for BYOD. Note that server-managed settings require a direct connection to `api.anthropic.com` and are bypassed when you route through a gateway via `ANTHROPIC_BASE_URL`.
</Note>

### Let users bring their claude.ai history across (`claudeAiImport`)

Teams moving onto a gateway-backed Claude Desktop usually arrive with history on claude.ai. The **Import from Claude** wizard copies those conversations and projects into the local session store so that work continues against your AI Gateway — but it is **off by default and not user-settable**. It exists only as managed config, so MDM (or the Claude Admin Console) is the only way to turn it on.

<Warning>
  The deployment scripts above do **not** write this key — they cover inference and MCP connectors only. Add `claudeAiImport` to the same managed-preferences store yourself, after the binary runs, for the same reason connectors go last: on macOS `tfy-local-ai-setup` rewrites and re-locks the whole plist on every run.
</Warning>

`claudeAiImport` is an object with four subfields, all off unless set. The posture below is the one we deploy for customers rolling Desktop out fleet-wide:

| Subfield | Value | Effect |
| - | - | - |
| `enabled` | `true` | **Settings → Import & export** offers the Import wizard. Nothing else in the object does anything while this is `false` |
| `exportEnabled` | `true` | Adds **Export…**, writing this machine's sessions to a zip another install can import |
| `bannerBehavior` | `detect` | Prompts atop a new chat or task only when importable sessions are found (`show` always prompts, `off` never does) |
| `automatic3pImport` | omitted | Beta. Silently copies earlier third-party sessions with no prompt, and additionally needs `deploymentOrganizationUuid`. Opt in only with the customer's agreement, since the user is never asked |

Like the other object-valued keys, it is stored as a JSON-encoded string:

```bash theme={"dark"}
# macOS — /Library/Managed Preferences/<user>/com.anthropic.claudefordesktop.plist
plutil -replace claudeAiImport -string \
  '{"enabled":true,"exportEnabled":true,"bannerBehavior":"detect"}' "${PLIST}"
```

```powershell theme={"dark"}
# Windows — HKLM\SOFTWARE\Policies\Claude
reg add "HKLM\SOFTWARE\Policies\Claude" /v claudeAiImport /t REG_SZ /f `
  /d '{"enabled":true,"exportEnabled":true,"bannerBehavior":"detect"}'
```

Keep the inner booleans as real JSON booleans — `"enabled":"true"` is a string and will not parse as on. MDM delivery of this key needs Claude Desktop **1.25927.0 or later**; earlier builds accept it only from a bootstrap server, and an older build silently ignores it.

<Warning>
  **Two switches are needed and only one of them is yours.** If the source history lives in a claude.ai **Team or Enterprise** workspace, a workspace owner must also enable **Settings → Organization → Data and privacy → Allow members to export their own data**. Personal claude.ai accounts can always export. No managed key fixes that side.
</Warning>

Also weigh the egress: the **Sign in to claude.ai** path has the app authorize against claude.ai and download the export itself, so devices need egress to claude.ai for that step. On a locked-down fleet the downloaded-`.zip` path needs no such egress and is the one that works.

<Note>
  **Terminal Claude Code history is not covered.** "Code sessions" throughout this wizard means Claude Desktop's **Code** tab. There is no Claude-Code-specific import key — Claude Code rides inside `claudeAiImport` — and neither half of the feature reaches the CLI: the wizard only finds sessions left by an earlier Claude Desktop install, and the export explicitly excludes terminal Claude Code sessions. A developer's own `~/.claude` history (`%USERPROFILE%\.claude` on Windows) stays where it is, so don't plan a CLI migration around this flag. The similarly named `isClaudeCodeForDesktopEnabled` gates whether the **Code** tab appears at all and has nothing to do with import.
</Note>

Inference still comes from your gateway: the first message in an imported conversation raises a **Resume imported session?** prompt, and the reply is served by your configured model. The transcript is replayed as context on that turn, so pin models with enough context for what users bring over and expect those requests in **AI Gateway → Analytics**. Anthropic's [Import history from claude.ai](https://claude.com/docs/third-party/claude-desktop/import).

### Stop connector schemas from crowding the context window (`toolSearchEnabled`)

Every connector you push through `managedMcpServers` costs context. By default Claude Desktop inlines the full JSON schema of *every* MCP tool into each session up front, so a fleet-wide connector set can spend a large slice of the window before the user types anything. **Tool search** puts only tool names in context and has Claude fetch a tool's full schema the first time it needs it.

Like `claudeAiImport`, this is managed-only and off by default, so MDM is the only way to turn it on. Unlike it, the value is a plain boolean rather than a JSON-encoded string:

```bash theme={"dark"}
# macOS — /Library/Managed Preferences/<user>/com.anthropic.claudefordesktop.plist
plutil -replace toolSearchEnabled -bool true "${PLIST}"
```

```powershell theme={"dark"}
# Windows — HKLM\SOFTWARE\Policies\Claude
reg add "HKLM\SOFTWARE\Policies\Claude" /v toolSearchEnabled /t REG_SZ /d true /f
```

If you deploy a `.mobileconfig` or a Group Policy template instead of writing the store directly, the key is a first-class subkey of the `com.anthropic.claudefordesktop` payload and appears in Anthropic's ADMX as a normal boolean policy — no custom payload needed. MDM delivery needs Claude Desktop **1.21459.0 or later**; earlier builds ignore the key. The same ordering caveat as connectors applies: the deployment scripts above don't write it, and on macOS `tfy-local-ai-setup` rewrites and re-locks the whole plist on every run, so it has to go in after the binary.

<Warning>
  **Setting `ENABLE_TOOL_SEARCH` yourself will not work — this key is the only switch.** When Desktop runs against a gateway it launches every Code session with `CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1` and strips `ENABLE_TOOL_SEARCH` out of the session environment, precisely because `toolSearchEnabled` is unset. A developer who adds `ENABLE_TOOL_SEARCH` to their own `~/.claude/settings.json` will therefore see no change and report tool search as broken. Setting `toolSearchEnabled` makes the app lift that suppression on its own — depending on the bundled Claude Code version it either stops disabling the betas or passes `ENABLE_TOOL_SEARCH=force` down through Claude Code managed settings. Editing the rendered config under `configLibrary` on a single machine is not a workaround either: that file is regenerated from managed config on the next sync.
</Warning>

Enabling it changes the request shape your gateway receives — sessions add the `tool-search-tool-2025-10-19` `anthropic-beta` value and send `tool_reference` content blocks. Whether that survives the hop depends on which provider backs the model:

* **Anthropic direct** — works as-is. TrueFoundry forwards `anthropic-beta` to `api.anthropic.com` unfiltered and does not validate `/v1/messages` content blocks, so the beta and the new blocks pass straight through.
* **Bedrock, Vertex, Azure Foundry, Microsoft Foundry, Bedrock Mantle** — check first. These providers run `anthropic-beta` through a per-provider allowlist and silently drop any value not configured for them. If `tool-search-tool-2025-10-19` isn't on that provider's list, the beta is stripped while the `tool_reference` blocks still go up, and the upstream rejects the request with **HTTP 400**.

Either way, pilot it with one group before the fleet. Anthropic's reference: [MCP tool search](https://claude.com/docs/third-party/claude-desktop/gateway#mcp-tool-search).

***

## Using Claude Code MDM with a Claude Enterprise Account

If your organization has a **Claude Enterprise subscription** (rather than a direct Anthropic API key), the MDM setup requires one additional configuration step on TrueFoundry: you create an Anthropic provider that acts as a pass-through, and the authentication is supplied at request time by each developer's own Claude Enterprise account — not stored as a credential on TrueFoundry.

This approach also gives you **per-user usage attribution on both sides** — TrueFoundry logs which user made each request, and Anthropic attributes usage to the individual Claude Enterprise seat. This works because the authorization token is user-specific: each developer obtains it by logging into their own Claude Enterprise account, so every request carries their identity end-to-end through the AI Gateway.

<Warning>
  **First-time login required.** The first time a developer opens Claude Code on a managed machine, they will be prompted to log in to their Claude Enterprise account through a browser. This device-authorization flow is what binds the machine to the developer's identity and generates the initial refresh token (valid for **30 days**). After that, the MDM script silently refreshes the token on every hourly run — no further interaction is needed unless 30 days pass without a refresh, or the developer switches accounts.
</Warning>

<Steps>
  <Step title="Create an Anthropic provider on TrueFoundry">
    In the TrueFoundry platform, navigate to **Integrations → Providers** and create a new provider of type **Anthropic**.

    When prompted for authentication credentials, **leave the API key field empty**. Do not enter any API key or secret. The AI Gateway will authenticate using the `X-TFY-API-KEY` token injected by the MDM script at the time of each request — no stored credential is needed or desired.
  </Step>

  <Step title="Copy the model IDs">
    After saving the provider, TrueFoundry assigns model IDs for each Claude model tier (for example, `claude-enterprise/claude-opus-4-6`, `claude-enterprise/claude-sonnet-4-6`, `claude-enterprise/claude-haiku-4-5`). Copy these model IDs from the provider detail page.
  </Step>

  <Step title="Update the MDM script config">
    In the MDM script, set the three config values to match your TrueFoundry setup:

    ```bash theme={"dark"}
    GATEWAY_URL="<your-truefoundry-gateway-url>"
    CONTROL_PLANE_URL="<your-truefoundry-control-plane-url>"
    TENANT_NAME="<your-tenant-name>"
    ```

    Then confirm the model env vars in the JSON block match the model IDs from the previous step:

    ```json theme={"dark"}
    "ANTHROPIC_DEFAULT_OPUS_MODEL":   "claude-enterprise/claude-opus-4-6",
    "ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-enterprise/claude-sonnet-4-6",
    "ANTHROPIC_DEFAULT_HAIKU_MODEL":  "claude-enterprise/claude-haiku-4-5"
    ```
  </Step>
</Steps>

<Warning>
  The Anthropic provider on TrueFoundry **must have an empty API key**. If a stored API key is present, TrueFoundry will use it instead of the per-request token from the MDM script, which breaks the Claude Enterprise subscription flow and may route traffic under the wrong account.
</Warning>

<Note>
  The `tfy-local-ai-setup` binary fetches a fresh OAuth token on every MDM run and writes it into `ANTHROPIC_CUSTOM_HEADERS` as `X-TFY-API-KEY`. TrueFoundry's gateway forwards this header to Anthropic's API to authenticate each request against the developer's Claude Enterprise subscription. No long-lived credentials are stored anywhere on the managed machine.
</Note>

**Using a virtual model with Claude Enterprise**

The MDM setup works with [virtual models](/docs/ai-gateway/virtual-model) as well. Point the model IDs in the MDM script to a virtual model instead of a direct provider model — you get two immediate benefits:

* **Fallbacks** — if one target is unavailable, traffic automatically falls through to the next
* **Model changes without touching the MDM script** — update the virtual model's routing on TrueFoundry and the change takes effect immediately across all managed machines, with no script redeployment

One important constraint: **only one of the virtual model's targets can be a Claude Enterprise model account**. The authorization header injected by the MDM script (`X-TFY-API-KEY`) is issued by Anthropic for a specific Claude Enterprise account. If multiple targets were Claude Enterprise providers, all of them would receive the same token — which is only valid for one account, causing the others to fail. Non-Claude-Enterprise targets (e.g. a fallback to a different provider) can coexist without issue.

**Troubleshooting: 401 / 403 errors**

If Claude Code returns a `401 Unauthorized` or `403 Forbidden` error, the session token has expired or was never initialized. The developer needs to re-authenticate manually:

1. Open Claude Code and run `/login`
2. When prompted to choose an authentication method, select **Claude Enterprise** (the first option)
3. Complete the browser login flow

Once logged in, the MDM script will automatically pick up the new refresh token on its next hourly run and keep it fresh without further manual steps.

***

## Observability

Once Claude traffic flows through the gateway, every model request and MCP tool call is traced with user, model, server, and tool attribution, and metrics for models, MCP, and guardrails are available in **AI Gateway → Analytics** — no extra instrumentation. Traces export to any OTEL-compatible platform for your SIEM. See [Analytics overview](/docs/ai-gateway/analytics-overview), [Request logging](/docs/ai-gateway/request-logging), and [Export OpenTelemetry data](/docs/ai-gateway/export-opentelemetry-data). Set spending guardrails per user and team with [budget limits](/docs/ai-gateway/budgetlimiting) and [rate limits](/docs/ai-gateway/ratelimiting).

For local transcripts, `transcriptRetentionDays` in `managed-settings.json` (7–14 days recommended, set to 14 in the baseline above) auto-deletes Claude Code's on-disk history. Anthropic-side retention, Zero Data Retention, and compliance posture are configured with Anthropic — see [Claude's data retention docs](https://platform.claude.com/docs/en/build-with-claude/zero-data-retention).

***

## Frequently asked questions

<AccordionGroup>
  <Accordion title="Why route Claude Code through TrueFoundry instead of straight to Anthropic?">
    A direct connection gives you no central place to enforce policy. Routing through the TrueFoundry AI Gateway lets you apply access control, rate and budget limits, multi-provider routing with failover, and full tracing — across web and CLI — from a single control point, without changing client config.
  </Accordion>

  <Accordion title="How does authentication work without distributing API keys?">
    The MDM deployment installs the `tfy-local-ai-setup` binary and runs it on a schedule (hourly) to fetch a fresh gateway token scoped to the user's TrueFoundry identity. The token is written into `ANTHROPIC_CUSTOM_HEADERS` as `X-TFY-API-KEY` and refreshed automatically — no personal or long-lived API keys on developer machines. The refresh token cached at `~/.tf/refresh-token` is valid for **30 days**; hourly runs rotate it so developers are not prompted to log in again.
  </Accordion>

  <Accordion title="How long is the refresh token at ~/.tf/refresh-token valid?">
    **30 days** from when it was last issued or rotated. `tfy-local-ai-setup` uses the TrueFoundry Auth Server refresh token rather than setting its own expiry. Each successful MDM run rotates the token and writes a new 30-day value to `~/.tf/refresh-token`. If the script runs at least once within those 30 days, no re-login is required. After 30 days without a refresh, the next run opens the browser device-authorization flow. Contact TrueFoundry support if you need a different lifetime.
  </Accordion>

  <Accordion title="What does the TrueFoundry MCP Gateway add over connecting MCP servers directly?">
    It centralizes MCP governance: a registry of approved servers, unified outbound authentication, role-based access, tool-level controls, guardrails, and a full audit trail of every tool call. You allowlist only the AI Gateway URL, so developers can't connect to unvetted servers.
  </Accordion>

  <Accordion title="I accidentally closed the browser login window — do I have to wait for the next MDM sync?">
    No. The cancelled device code expires immediately, but you can start a fresh login at any time. Since your config is already saved from the initial run, source the config file and run the binary directly — no flags to look up:

    <Tabs>
      <Tab title="macOS">
        ```bash theme={"dark"}
        source /Library/Preferences/com.truefoundry.tfy-local-ai-setup.conf
        sudo /usr/local/bin/tfy-local-ai-setup \
          --url="${CONTROL_PLANE_URL}" \
          --tenant="${TENANT_NAME}" \
          --gateway="${GATEWAY_URL}"
        ```
      </Tab>

      <Tab title="Linux">
        ```bash theme={"dark"}
        source /etc/tfy/tfy-local-ai-setup.conf
        sudo /usr/local/bin/tfy-local-ai-setup \
          --url="${CONTROL_PLANE_URL}" \
          --tenant="${TENANT_NAME}" \
          --gateway="${GATEWAY_URL}"
        ```
      </Tab>

      <Tab title="Windows">
        Open **PowerShell as Administrator**, then run:

        ```powershell theme={"dark"}
        $c = @{}
        Get-Content "C:\ProgramData\TrueFoundry\tfy-local-ai-setup.conf" |
          Where-Object { $_ -match '^([A-Z_]+)="(.*)"$' } |
          ForEach-Object { $c[$Matches[1]] = $Matches[2] }
        & "C:\Program Files\TrueFoundry\tfy-local-ai-setup.exe" `
          --url="$($c.CONTROL_PLANE_URL)" `
          --tenant="$($c.TENANT_NAME)" `
          --gateway="$($c.GATEWAY_URL)"
        ```
      </Tab>
    </Tabs>

    Once you complete the login, the refresh token is saved and all future MDM runs will finish silently — no browser prompt needed.
  </Accordion>

  <Accordion title="Can developers override these settings locally?">
    No. When `managed-settings.json` is deployed to the system path and locked (and `allowManagedPermissionRulesOnly` is set), it takes highest priority and cannot be overridden by project- or user-level settings.
  </Accordion>

  <Accordion title="A browser login window opens every time the MDM script runs — is this expected?">
    No, this should only happen on the **first run** or when the cached refresh token at `~/.tf/refresh-token` has expired (**30 days** without a successful refresh). Once the developer completes the login, subsequent MDM runs silently refresh the token without any browser prompt. If the window keeps appearing on every run, the refresh token is not being saved — run the setup manually (see the FAQ above) to re-authenticate and reset the token.
  </Accordion>
</AccordionGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.