Clash Control MCP
# Clash Control MCP
English | [简体中文](README.zh-CN.md)
> An MCP server that lets AI agents safely read, audit, and manage Clash Verge Rev / Mihomo configuration.
> v1.0.0-rc.1 — authenticated Clash Verge Adapter writes, recovery inspection, and evidence-driven optimization helpers.
## What This Does
Clash Control MCP exposes Clash Verge Rev's configuration and Mihomo's runtime state to AI agents (Codex, ZCode, Claude Code) through the Model Context Protocol. The supported end-user product includes the authenticated Adapter; Core-only setup is retained only as a degraded recovery/development path and is not a release installation choice. Agents can:
- Detect Clash Verge Rev installation, version, and config paths
- Read Mihomo runtime status (version, mode, ports, connections, traffic)
- List and inspect proxy groups, profiles, and providers
- Audit configuration for issues
- Discover Verge settings and their storage locations
The original v0.1 tools remain read-only. v0.2-A adds two runtime-only operations: allowlisted delay testing and confirmed proxy selection with verification and rollback. It does not modify persistent configuration, DNS, TUN, or system proxy settings.
## Full Installation (Adapter Required)
The `v1.0.0-rc.1` end-user release supports only macOS Apple Silicon (`arm64`),
official Clash Verge Rev `2.5.1`, and Node.js `20.19.0` or newer. The npm
package deliberately has no Core-only setup command.
1. Install the MCP and its verified lifecycle installer:
```bash
npm install --global clash-control-mcp@next
```
2. Download the matching Adapter patch from the
[`v1.0.0-rc.1` GitHub Release](https://github.com/huaqing0/clash-control-mcp/releases/tag/v1.0.0-rc.1),
or use GitHub CLI:
```bash
gh release download v1.0.0-rc.1 \
--repo huaqing0/clash-control-mcp \
--pattern 'Clash-Verge-2.5.1-Adapter-Unsigned-macos-arm64.zip'
unzip Clash-Verge-2.5.1-Adapter-Unsigned-macos-arm64.zip
```
3. Run the zero-write preflight while Clash Verge is still open:
```bash
clash-control-adapter patch-install \
--patch-package "$PWD/Clash-Verge-2.5.1-Adapter-Unsigned-macos-arm64" \
--dry-run
```
4. Quit Clash Verge completely, install the full product, then restart Clash
Verge and the configured MCP clients:
```bash
clash-control-adapter patch-install \
--patch-package "$PWD/Clash-Verge-2.5.1-Adapter-Unsigned-macos-arm64" \
--clients codex,zcode,trae
clash-control-adapter patch-verify
```
The installer detects the local Controller, creates verifiable backups, writes
the managed client entries, and never prints the Controller secret or Adapter
token. After verification, call `clash.capabilities`; the Adapter handshake
must be authenticated and available for the installation to be accepted.
## Clash Verge Adapter Lifecycle
The Adapter is part of the supported product, not an optional feature tier.
From a source checkout, maintainers use the same lifecycle entrypoint:
```bash
npm ci
npm run build
npm exec -- clash-control-adapter --help
npm exec -- clash-control-adapter install --help
```
It provides `install`, `upgrade`, `verify`, `uninstall`, `patch-install`,
`patch-verify`, and `patch-uninstall`. Installation requires an explicitly
downloaded, version-pinned release patch or a separately built and validated
Adapter `.app`; npm installation alone never replaces Clash Verge. See [ADAPTER-INSTALLATION.md](ADAPTER-INSTALLATION.md)
for the dry-run-first workflow, supported global client paths, recovery model,
and recovery rules. Users of the version-pinned Apple Silicon unsigned patch
should follow [ADAPTER-PATCH-INSTALLATION.md](ADAPTER-PATCH-INSTALLATION.md).
Upgrades reuse the exact client set and paths from a `VERIFIED` acceptance
lineage; legacy Adapter installs without that lineage must restore the official
App from a trusted backup and perform a fresh install.
The GPL-3.0 Adapter source is maintained separately at
[huaqing0/clash-verge-adapter](https://github.com/huaqing0/clash-verge-adapter).
### Build Only
```bash
npm install
npm run build
```
## Configure
Set environment variables (see `.env.example` for reference):
```bash
CLASH_CONTROLLER_URL=http://127.0.0.1:9097
CLASH_CONTROLLER_SECRET=your-secret-here
# Optional: Unix socket fallback (auto-detected from Verge if not set)
# CLASH_CONTROLLER_SOCKET=/tmp/verge/verge-mihomo.sock
```
The secret is in your Clash Verge Rev generated config (`clash-verge.yaml`), under the `secret:` key.
**Never commit your secret to git.** Configure it via MCP `env` in your agent config or system environment variables.
## Run
```bash
# Start MCP server (stdio)
npm start
# Or run directly
node dist/index.js
```
Source-checkout MCP client configuration:
```json
{
"mcpServers": {
"clash-control": {
"command": "node",
"args": ["/path/to/clash-control-mcp/dist/index.js"],
"env": {
"CLASH_CONTROLLER_URL": "http://127.0.0.1:9097",
"CLASH_CONTROLLER_SECRET": "your-secret"
}
}
}
}
```
## Smoke Test
```bash
npm run smoke-test
```
Validates the safe read-only Mihomo endpoints and writes a local, gitignored
`API-SMOKE-TEST.json`. It may contain machine-specific runtime metadata and
must not be published. The reload endpoint remains `unverified` when no
side-effect-free probe is available.
## Tools (66 tools)
### v0.1 Read-Only Tools (15)
| Tool | Description |
|---|---|
| `clash.capabilities` | Environment capabilities (must call first) |
| `clash.doctor` | Comprehensive diagnostics |
| `clash.verge.detect` | Verge detection: version, paths, compatibility |
| `clash.verge.get_settings` | Read Verge network/runtime settings |
| `clash.verge.get_directories` | Get config directories and paths |
| `clash.verge.get_active_profile` | Get currently active profile |
| `clash.profiles.list` | List Verge profiles (summarized and paginated) |
| `clash.profiles.inspect` | Inspect a specific profile by UID |
| `clash.config.get_effective` | Mihomo runtime config (from API) |
| `clash.config.get_generated` | Generated Verge config summary with a safe section allowlist |
| `clash.config.get_persistent` | Verge persistent config (from file) |
| `clash.config.audit` | Audit configuration for issues |
| `clash.runtime.get_status` | Mihomo runtime status summary |
| `clash.runtime.list_proxy_groups` | List policy groups (summarized and paginated) |
| `clash.runtime.get_proxy_group` | Get one policy group's safe, paginated candidate names and runtime health details |
### v0.2-A Runtime Control Tools (2)
| Tool | Description |
|---|---|
| `clash.runtime.test_proxy_delay` | Test proxy delay with multiple samples, returns median and jitter |
| `clash.runtime.select_proxy` | Switch proxy in Selector group with verification and auto-rollback |
### v0.2-B Runtime Discovery and Ops Tools (6)
| Tool | Description |
|---|---|
| `clash.runtime.list_proxy_providers` | List safe proxy Provider names and summaries |
| `clash.runtime.list_rule_providers` | List safe rule Provider names and summaries |
| `clash.runtime.list_connections` | List redacted connection summaries and IDs |
| `clash.runtime.refresh_proxy_provider` | Refresh a proxy provider (per-provider lock, confirmed, verified) |
| `clash.runtime.refresh_rule_provider` | Refresh a rule provider (per-provider lock, confirmed, verified) |
| `clash.runtime.close_connection` | Close a specific connection by ID (close-all NOT supported) |
### v0.3 Config and Recovery Tools (7)
| Tool | Description |
|---|---|
| `clash.config.create_workspace` | Create an isolated in-memory workspace |
| `clash.config.preview_change` | Preview a semantic, redacted diff |
| `clash.config.validate_change` | Fail-closed schema, fingerprint, and risk validation |
| `clash.config.apply_transaction` | Apply a validated low-risk workspace through the authenticated Adapter, then run file/runtime/network/GUI health checks with automatic rollback |
| `clash.config.rollback` | Restore a persisted transaction snapshot |
| `clash.transaction.resume` | Verify a persisted PENDING_RESTART after Verge restart |
| `clash.recovery.status` | Read durable transaction/recovery state without modifying manifests or snapshots |
### v0.4 Verge Settings Tools (8, capability-gated)
| Tool | Description |
|---|---|
| `clash.verge.set_unified_delay` | Set unified delay through the guarded settings transaction path |
| `clash.verge.set_log_level` | Set Mihomo log level |
| `clash.verge.set_ipv6` | Set IPv6 state |
| `clash.verge.configure_dns_override` | Strategic-merge the DNS kernel block |
| `clash.verge.configure_ports` | Configure the mixed proxy port |
| `clash.verge.configure_lan_access` | Configure LAN access with strong confirmation |
| `clash.verge.configure_tun` | Strategic-merge the TUN kernel block with strong confirmation |
| `clash.verge.set_auto_close_connection` | Configure Verge connection cleanup through the native Adapter settings path |
Each setting remains independently capability-gated. On the tested Verge
2.5.1 QA Adapter build, the native settings path performs optimistic
concurrency checks, updates Verge memory and persistence, applies Mihomo
runtime state, queues GUI refresh, verifies desired/persisted/reported/GUI/
observed evidence, and compensates on failure. Official builds without the
Adapter continue to fail closed.
### v0.5-A/C Profile & Subscription Tools (6)
| Tool | Description |
|---|---|
| `clash.profiles.inspect_chain` | Inspect a Verge profile's full chain: base profile, merge layer, override/script layer, rule layer; with per-layer fingerprint, parse status, and redacted subscription URL |
| `clash.merge.inspect` | Inspect a Merge profile's raw structure, fingerprint, and summary |
| `clash.override.inspect` | Inspect an Override (Script) profile; Script files are fingerprinted by raw content (`raw-content` algorithm), never parsed as YAML |
| `clash.rules.inspect` | Inspect a profile's rule list with stable rule IDs, pagination (default 50, hard cap 200), and rule-level fingerprints |
| `clash.profiles.refresh` | Refresh a remote subscription through the authenticated local Clash Verge Adapter. Local profiles return `NOT_APPLICABLE`; remote profiles require light confirmation, per-UID limiting/locking, and a bounded timeout. Returns `BLOCKED` when the Adapter is unavailable |
| `clash.profiles.activate` | Activate a Profile through Clash Verge itself. Strong two-step nonce confirmation, active-Profile drift detection, and post-operation verification are mandatory because network routing may change |
Security and capability notes:
- All UID/path inputs are validated by `^[A-Za-z0-9_-]{1,120}$` and resolved
with `realpath` + parent-directory walk. Directory-traversal payloads
(`..`, absolute paths, `.`, `-`) are rejected before any filesystem access.
- Inspect payloads are capped (`MAX_RULES_RETURNED = 200`,
`DEFAULT_RULES_LIMIT = 50`) and paginate via `cursor` tokens; every layer
returns a structured `parseStatus` (`ok` / `missing` / `parse_error` /
`not_yaml`) with a `parseError` object — no silent failures.
- Subscription URLs are deeply redacted to `scheme://host/***` (path and
query stripped) before entering any return payload.
- `clash.profiles.refresh` distinguishes `refreshKind: 'subscription'` from
Mihomo Proxy/Rule Provider refresh (`clash.runtime.refresh_proxy_provider` /
`clash.runtime.refresh_rule_provider`). It never fakes `REFRESHED` — when
the authenticated Adapter is unavailable it returns `BLOCKED` with manual
GUI steps and the `profileRefresh` capability stays `false`.
### Clash Verge Adapter
Profile refresh, activation, persistent Profile writes, synchronized proxy
selection, and native Verge settings writes require the companion Adapter
build. The Adapter is opt-in, listens only on `127.0.0.1`, and requires a
per-installation Bearer token. The installer stores that token only in a
private `0600` credentials file; clients receive
`CLASH_VERGE_ADAPTER_TOKEN_FILE`, not the token itself. Official Clash Verge
builds remain supported for read-only and Mihomo runtime tools; Adapter-owned
writes fail closed. Installation verification first challenges the service with
an invalid token, then verifies that the listener is the current user's
`/Applications/Clash Verge.app` executable before sending the real token.
### v0.5-B Profile Persistent Write Tools (5, capability-gated)
| Tool | Description |
|---|---|
| `clash.profiles.update_metadata` | Update non-sensitive profile metadata in `profiles.yaml`. Allowlist: `name`, `update_interval`, `desc`. Protected fields (`uid`, `type`, `file`, `url`, `option`, `auth`, `token`, `secret`) are never modifiable. Two-phase: preview → execute with nonce |
| `clash.merge.update` | Update a Merge YAML file with a strategic merge patch. Owner resolved from the target Profile's `option.merge`. Two-phase preview/nonce execution with Adapter verification and compensation |
| `clash.override.update` | Update a YAML Override. JavaScript Script files (`.js`/`.mjs`) return `NOT_SUPPORTED` |
| `clash.rules.add` | Add rules to a section (`prepend`/`append`/`delete`) of the independent Rules owner file. Stable anchors via `beforeRuleFingerprint`/`afterRuleFingerprint` (no line numbers). Duplicate rules rejected. Max 100 per operation. Two-phase: preview → execute with nonce |
| `clash.rules.remove` | Remove rules by fingerprint from a section of the Rules owner file. Each fingerprint must uniquely match exactly one rule — ambiguous matches are rejected. Two-phase: preview → execute with nonce |
All five tools remain fail closed unless the authenticated Adapter reports the
matching capability for the tested Verge version. The 2.5.1 RC QA build has
passed controlled real update-and-restore samples for metadata, Merge YAML,
YAML Override, and independent Rules; JavaScript mutation remains blocked.
Security model:
- Owner resolution from `profiles.yaml` real `file` and `option` references;
no filename guessing. `realpath` boundary check prevents symlink traversal.
- Non-reusable nonce binds: target Profile UID, owner UID, owner path, owner
fingerprint, chain fingerprint, normalized patch hash, section, ruleset
fingerprint, and expiry (5-minute TTL).
- Full transaction: preview → schema validation → optimistic owner fingerprint
check → native Adapter write → persistence/runtime verification → commit or
compensating restore.
- Dual-layer lock (in-process mutex + cross-process file lock with PID liveness).
- Rules use stable anchors (normalized content + adjacent fingerprints), not
line numbers. `RULESET_CHANGED_DURING_TRANSACTION` aborts on concurrent modification.
- A write returns `COMMITTED` only after the Adapter verifies the applicable
persistence/runtime state and queues GUI refresh; unverified paths never
claim success.
### v1.0 Optimization Tools (12, read-only orchestration helpers)
| Tool | Description |
|---|---|
| `clash.optimize.diagnose_network` | Collect a versioned network baseline and bounded real probes |
| `clash.optimize.dns` | Diagnose DNS structure, exposure, consistency, and reachability |
| `clash.optimize.rules` | Diagnose duplicate, shadowed, invalid, and owner-partitioned rules |
| `clash.optimize.proxy_groups` | Diagnose group membership, selections, liveness, and failover |
| `clash.optimize.health_checks` | Diagnose provider/group health-check intervals, targets, and cost |
| `clash.optimize.openai` | Layered OpenAI connectivity diagnosis without API keys or paid calls |
| `clash.optimize.github` | Layered GitHub web/API/download diagnosis without repository writes |
| `clash.optimize.youtube` | Layered YouTube network diagnosis without account or DRM access |
| `clash.optimize.streaming` | Bounded streaming endpoint diagnosis without unlock claims |
| `clash.optimize.verify_baseline` | Reject execution when the original baseline has drifted |
| `clash.optimize.compare_probes` | Classify comparable before/after probes as improved, unchanged, regressed, or inconclusive |
| `clash.optimize.execution_plan` | Validate proposed actions against their individual lower-level capability gates |
Optimizers do not write configuration. They return facts, probes, findings,
recommendations, and proposed calls to the existing guarded tools. The calling
AI performs one confirmed action at a time, compares identical probes, and
invokes the original rollback path immediately on regression.
These helpers are an evidence harness, not a replacement for agent reasoning.
They make common routing semantics explicit for less capable models: global and
direct modes cannot be mistaken for active rule routing, live connection chains
and the current `GLOBAL` selection are reported separately from static rules,
single-candidate groups are identified as lacking group-level failover, and a
health-check report distinguishes an unreadable configuration from a verified
topology where health checks are not applicable. An agent may still inspect the
lower-level MCP facts and form a different recommendation, but every write stays
behind the same confirmation, capability, verification, and rollback gates.
### v1.1 Verge Preferences Tools (5, capability-gated, fail-closed)
| Tool | Description |
|---|---|
| `clash.verge.preferences.inspect` | Read-only inspect of Clash Verge Preferences (Basic/Theme/Layout/Hotkeys) from verge.yaml plus Adapter memory state; sensitive fields (css_injection, WebDAV credentials, system proxy, TUN, DNS, ports) are listed by name only and redacted |
| `clash.verge.preferences.set_basic` | Write Basic Preferences (language, theme_mode, tray_event, env_type, start_page) with light confirmation. Two-phase preview→confirm; `confirm=true` must carry the real `ownerFingerprint` returned by preview |
| `clash.verge.preferences.configure_theme` | Write Theme Preferences (primary/secondary/text/info/error/warning/success_color, font_family) with light confirmation and ownerFingerprint |
| `clash.verge.preferences.configure_layout` | Write Layout Preferences (traffic_graph, enable_memory_usage, enable_group_icon, pause_render_traffic_stats_on_blur, collapse_navbar, menu_icon, notice_position, enable_hover_jump_navigator, menu_order) with light confirmation and ownerFingerprint |
| `clash.verge.preferences.configure_hotkeys` | Write Hotkeys (enable_global_hotkey + 8 function→accelerator mappings) with strong nonce-bound confirmation. Nonce binds mapping digest + ownerFingerprint + enable state + 60s expiry; one-time use; no replay |
Schema is keyed to real Clash Verge Rev 2.5.1 source (`i18n.ts`, `setting-verge-basic.tsx`, `pages/_routers.tsx`, `layout-viewer.tsx`, `hotkey-viewer.tsx`, `core/hotkey.rs`, `config/verge.rs`):
- 13 real languages, 5 tray events (Linux unsupported), 5 env types (IMMEDIATE), 8 start pages (with leading `/`), 3 menu icons, 4 notice positions
- 8 public hotkey functions (`open_or_close_dashboard`, `clash_mode_rule`, `clash_mode_global`, `clash_mode_direct`, `toggle_system_proxy`, `toggle_tun_mode`, `entry_lightweight_mode`, `reactivate_profiles`); `quit`/`hide` are native internal shortcuts and are NOT in the allowlist
- Accelerator aliases are canonicalized before duplicate/reserved checks; macOS input/window combinations observed in the real desktop environment (`Command+Space`, `Control+Space`, and their protected variants) are rejected by both MCP and Adapter
- `common_tray_icon` removed from the write allowlist (no safe icon asset and verifiable side effect)
Security model (per Codex v1.1 review P0/P1 rectification):
- **No fake COMMITTED** — Basic/Theme/Layout remain `UNVERIFIED` until GUI-visible effects are acknowledged; `start_page` is `PENDING_RESTART` until the next Clash Verge launch because closing the dashboard only hides and reuses the current WebView. Hotkeys become `COMMITTED` only when memory, `verge.yaml`, and the native Tauri registration set all match; incomplete `ok=true` bodies remain `UNVERIFIED`.
- **Structured 409/429** — 409 + `errorCode: "CHANGED"` → `PREFERENCES_CHANGED` (state drift, caller must re-preview, returns REJECTED); 409 without errorCode → `TRANSACTION_BUSY` (mutation busy, returns BLOCKED); 429 + `retryAfterMs` → `ADAPTER_RATE_LIMITED` (returns BLOCKED with retryAfterMs).
- **Light confirmation fingerprint** — `confirm=true` for Basic/Theme/Layout must carry the `ownerFingerprint` returned by a matching, unexpired preview for the identical values. MCP re-reads the strict Adapter snapshot and rejects missing, drifted, changed, or direct-confirm attempts.
- **Real ownerFingerprint** — Adapter and read-only inspect both use SHA-256 over the exact `verge.yaml` bytes (first 16 bytes / 32 lowercase hex); the legacy fixed `"adapter"` placeholder and JSON-object fingerprint are not used for writes.
- **Adapter-side deny_unknown_fields DTO** — `VergePreferencesBody`/`HotkeysBody` reject unknown fields; Adapter independently validates types/enums/lengths/colors/fonts/menu_order/platform/prototype pollution and returns 400 REJECTED before any memory/file/OS modification.
- **Compensating rollback re-verification** — On failure, the Adapter re-reads memory and persisted state; Hotkeys also re-check the native registration set. A Preferences rollback is `ROLLED_BACK` only when memory, persisted bytes, native side-effect replay, and GUI-refresh scheduling all succeed; missing evidence remains `RECOVERY_REQUIRED`. `rollbackVerified` never relies only on `patch_verge(...).is_ok()`.
- **Hotkeys transactional** — Adapter saves original mapping/enable/verifiable OS registration set before writing; on partial registration failure it explicitly unregisters the new keys and restores the old set, then re-verifies. If the Tauri global-shortcut API cannot reliably enumerate or verify OS registration, the result is fail-closed `UNVERIFIED`/`ROLLED_BACK`/`RECOVERY_REQUIRED`, never `COMMITTED`.
The four write tools require authenticated Adapter capability `settings.verge-preferences` plus the matching fine-grained write capability. Read-only inspect may fall back to local `verge.yaml`, but never invents confirmation evidence when the real owner bytes are unavailable. macOS/Clash Verge 2.5.1 real-desktop acceptance covered visible Basic/Theme/Layout changes, `NEXT_LAUNCH` start-page behavior, native hotkey registration, 409/429/BUSY faults, same-call rollback, byte-exact restoration, and unchanged Profile/network state. Synthetic accessibility key events are not treated as proof of a physical global-hotkey callback.
## Supported Versions and Platforms
| Component | Platform | Verge Rev Version | Status |
|---|---|---|---|
| Full MCP + Adapter product | macOS Apple Silicon (`arm64`) | 2.5.1 | supported release path |
| Unsigned patch package | macOS Apple Silicon (`arm64`) | 2.5.1 | required RC asset; not notarized |
| Core-only degraded mode | development/recovery only | 2.0.0+ | not an end-user release path |
| Any component | any | < 2.0.0 | unsupported |
## Security
- Controller credentials are read from the selected client's private managed
config or process environment; Adapter tokens are read from the private
credentials file referenced by `CLASH_VERGE_ADAPTER_TOKEN_FILE`
- MCP outputs pass through common secret redaction plus tool-specific filtering;
runtime Controller/Adapter secrets are also removed if a peer echoes them
- No user-controlled arbitrary shell command, filesystem path, URL request, or
API endpoint passthrough; all exposed operations use fixed or allowlisted
routes, paths, commands, and probe targets
- See [SECURITY.md](SECURITY.md) for details
## FAQ
**Where is the Controller secret stored?** In the selected MCP client's private
environment configuration. The Adapter token is stored separately in an
owner-only `0600` credentials file and clients receive only its path.
**Why are Adapter write tools blocked?** The required Adapter installation is
missing, incomplete, or no longer verified. Read-only fallback remains available
for diagnosis/recovery, but it is not considered a complete release install.
**What do `PENDING_RESTART` and `RECOVERY_REQUIRED` mean?** `PENDING_RESTART`
requires restarting Clash Verge and the MCP client, then running Adapter
verification. `RECOVERY_REQUIRED` means automated recovery could not be proven;
stop further writes and follow the recovery section in
[ADAPTER-INSTALLATION.md](ADAPTER-INSTALLATION.md#12-故障与恢复).
**How do I remove the Adapter patch?** Exit Clash Verge and run
`clash-control-adapter patch-uninstall`. The uninstaller restores only the
hash-verified official App backup recorded by the installation transaction.
**Why does setup reject my existing client config?** The installer will not
follow symlinks, accept unsafe permissions, or overwrite an entry it cannot
prove it owns. Correct the reported permission/ownership issue or configure the
MCP entry manually.
## Roadmap
See [ROADMAP.md](ROADMAP.md).
## License
MIT
TDQS
Scored across 66 tools
Each tool has a highly specific purpose within a clear hierarchical namespace (e.g., clash.verge.*, clash.runtime.*). Descriptions are detailed and unique, making it easy to distinguish between similar tools like list vs inspect variants.
All tools follow a consistent pattern: `clash.<category>.<action>`. The hierarchy is uniform, verbs are descriptive (get, set, list, inspect, configure), and snake_case is used throughout without deviations.
At 66 tools, the server is excessively large. While the Clash Verge domain is complex, this many tools in a single server creates cognitive overload and violates typical MCP scoping. The count far exceeds the 15–25 range considered reasonable.
The tool set covers a broad and deep range of operations: profiles, configuration, runtime control, preferences, optimization diagnostics, and recovery. Minor gaps exist (e.g., no direct runtime config write), but core workflows are well-supported.