diff --git a/specs/GH8803/product.md b/specs/GH8803/product.md new file mode 100644 index 000000000..7119c8d25 --- /dev/null +++ b/specs/GH8803/product.md @@ -0,0 +1,125 @@ +# Product Spec: User-configurable language servers + +**Issue:** [warpdotdev/warp#8803](https://github.com/warpdotdev/warp/issues/8803) +**Figma:** none provided + +## Summary + +Let users add language servers Warp does not ship out of the box by declaring them in a settings file (binary path, arguments, file types they apply to, optional root-file globs, optional initialization options). When the user opens a file matching a configured server's file types, Warp offers to enable that server for the workspace and — once enabled — uses it for code intelligence (diagnostics, hover, goto, completions) on that file type, alongside any built-in servers. + +This is the contributor-facing alternative to baking each language server into the Warp binary. It directly addresses why PRs adding specific built-in LSPs (e.g. PHP Intelephense in #9562, JSON in #9568) were closed in favor of this product direction. + +## Problem + +Warp currently ships a closed set of language servers as variants of `crates/lsp/src/supported_servers::LSPServerType`. Every new language requires: + +1. A new `LSPServerType` enum variant. +2. A new `LanguageServerCandidate` impl with detection, install, and `fetch_latest_server_metadata` logic. +3. A new `LanguageId` enum variant in `crates/lsp/src/config.rs` plus extension mapping. +4. A new entry in `LanguageId::lsp_language_identifier`. + +This scales poorly and pulls language-specific install logic into the core. Users with niche languages (Lua, Zig, Swift, Elixir, Solidity, Bash, Tailwind, etc.) cannot add support without modifying the binary. Maintainers cannot accept PRs adding specific servers without committing to ongoing maintenance of those servers' install/version-fetch code paths. + +## Goals + +- A user can add a language server to Warp by editing a settings file — no binary modifications, no agent involvement. +- Configured servers run side-by-side with built-in servers and follow the same code-intelligence surface (diagnostics, hover, goto, completions, semantic tokens). +- Configuration is portable across workspaces and discoverable in Warp's settings UI. +- Enablement is per-workspace by default with a clear opt-in moment, not silent global activation. +- Mis-configuration produces a visible, actionable error — not a silent disabled state. + +## Non-goals + +- **Auto-installation of user-configured servers.** The user installs the binary themselves (npm, cargo, system package manager, brew, etc.). Warp does not run package managers on the user's behalf for these. +- **Version pinning / auto-update of user-configured servers.** Out of scope; the user owns the lifecycle. +- **Per-file dynamic switching.** A single file is associated with at most one user-configured server, plus any built-in servers that already match. No "try server A, fall back to server B" runtime logic. +- **Replacing built-in servers.** A user-configured server with the same `file_types` as a built-in does **not** disable the built-in. Both run; the LSP client merges their results. +- **Cross-workspace global enablement on first open.** A configured server is *defined* globally but *enabled* per-workspace. +- **Marketplace / discovery of community configs.** Out of scope; users find configs themselves. + +## User experience + +### Adding a server + +1. User edits their Warp settings file (TOML, located at the standard Warp settings path) and adds an `[[lsp.servers]]` entry. (See the "Configuration shape" section below.) +2. Warp detects the new entry on settings reload. No restart required. +3. If the entry is malformed (missing `name`, missing `command`, empty `file_types`), Warp shows a non-blocking notification: *"Custom LSP `` is misconfigured: . See settings."* with a button to open the settings file at the offending line. + +### First time opening a matching file + +1. User opens a file whose extension matches a configured server's `file_types` (e.g. opens `foo.lua` with a Lua server configured). +2. Warp detects the configured server is *defined* but not yet *enabled* for this workspace. +3. The editor footer shows a chip: *"Enable `` for this workspace?"* with `Enable` / `Dismiss` buttons. +4. If `Enable` is pressed, Warp: + - Records the per-workspace enablement. + - Spawns the server process with the configured command and args. + - Starts driving LSP traffic for files matching `file_types` in this workspace. +5. If `Dismiss` is pressed, the chip is suppressed for this workspace until the user re-opens it from settings. + +### Subsequent opens in an enabled workspace + +1. User opens any file matching `file_types` in a workspace where the server is already enabled. +2. The server is already running; no UI surfaces. Diagnostics, hover, goto, completions all behave as they do for built-in servers. + +### Disabling a server in a workspace + +1. User opens settings → "Code intelligence" → "Custom language servers". +2. Each configured server lists which workspaces it is enabled for. +3. User clicks the workspace row's `Disable` button. Warp shuts down that server's process for that workspace and removes the per-workspace enablement record. + +### Misconfiguration scenarios + +1. **Binary not on PATH:** When a user enables a server whose `command[0]` is not on PATH, Warp shows: *"Could not start ``: binary `` not found on PATH."* The chip's `Enable` button is replaced with `Open settings`. +2. **Binary on PATH but spawn fails:** Warp shows: *"`` exited with status ``. Last 200 bytes of stderr: `<...>`."* with `Open settings` and `Retry` buttons. +3. **Spawn hangs:** A configurable `start_timeout` (default 5s) bounds the LSP `initialize` request. On timeout, Warp shows: *"`` did not respond to `initialize` within 5s."* The server's process is killed. + +## Configuration shape + +The user's Warp settings TOML grows a new `[lsp]` table. Multiple servers via array-of-tables `[[lsp.servers]]`: + +```toml +[[lsp.servers]] +name = "intelephense" +command = ["intelephense", "--stdio"] +file_types = ["php", "phtml"] +root_files = ["composer.json", "composer.lock"] # optional; default: file's parent dir +initialization_options = { storagePath = "/tmp/intelephense" } # optional, opaque to Warp +start_timeout_ms = 5000 # optional, default 5000 +``` + +| Field | Required | Type | Notes | +|---|---|---|---| +| `name` | yes | string | Display name. Must be unique across all configured servers. | +| `command` | yes | array of strings | First element is the binary; rest are args. Resolved against PATH. | +| `file_types` | yes | array of strings | File extensions (no dot) the server handles. Must be non-empty. | +| `root_files` | no | array of strings | Glob patterns whose presence in an ancestor directory marks the workspace root. Default: the file's parent directory. | +| `initialization_options` | no | TOML table | Passed verbatim to the LSP `initialize` request as `initializationOptions`. Opaque to Warp. | +| `start_timeout_ms` | no | integer | Bound on the time we wait for `initialize` to return. Default 5000. | + +Settings reload re-reads the entire `[lsp]` table; servers whose configuration changed are restarted. Removed entries shut down. Added entries become available (but are not auto-enabled). + +## Testable behavior invariants + +Numbered list — each maps to a verification path in the tech spec: + +1. A `[[lsp.servers]]` entry with `name`, `command` (non-empty array), and `file_types` (non-empty array) is accepted at settings parse time. +2. A `[[lsp.servers]]` entry missing any of `name` / `command` / `file_types`, OR with empty `command` / `file_types`, OR with a duplicate `name`, is rejected at parse time and surfaces a settings-error notification with the offending line range. +3. Opening a file whose extension is in `file_types` of a configured-but-not-enabled server shows the "Enable" chip in the editor footer for that file's workspace exactly once per (server, workspace) pair until the user dismisses or enables. +4. Pressing "Enable" on the chip starts a server process via `command[0]` with `command[1..]` as args, sends an LSP `initialize` request (with `initializationOptions` from the config), and on receiving a successful response begins routing LSP traffic for files matching `file_types` in that workspace. +5. If `command[0]` is not on PATH at enablement time, no process is spawned and the user sees an error notification with `Open settings` action. +6. If the spawned process exits non-zero before sending an `initialize` response, the user sees an error notification with the exit status and last 200 bytes of stderr. +7. If `initialize` does not return within `start_timeout_ms` (default 5000), the spawned process is killed via `Drop` of the `Child` handle, the LSP client is torn down cleanly, and the user sees a timeout notification. +8. After a server is enabled in workspace W, opening any file matching `file_types` in W routes LSP requests to that server **without** showing the chip again. +9. After settings change (server entry edited or removed), an enabled-and-running server for the changed entry is restarted with the new config (or shut down if removed) within 1s of settings reload, with no Warp restart required. +10. The user-configured server runs alongside any built-in server whose `LSPServerType` matches the same file extension; both servers receive requests, and the LSP client merges responses (existing built-in client behavior; not changed by this feature). +11. Disabling a server via settings UI shuts down its process for the targeted workspace within 1s, removes the per-workspace enablement record, and the chip reappears on next file open. +12. Restarting Warp preserves per-workspace enablement state — workspaces where a server was enabled before restart auto-spawn the server on the next file open without the chip reappearing. +13. `initialization_options` in the TOML is forwarded to the `initialize` request's `initializationOptions` field byte-equivalent (TOML → JSON conversion preserves nested tables and arrays). +14. The chip is **not** shown for a configured server in a workspace that has explicitly dismissed it; the user must re-enable from settings UI. +15. Shutting down the LSP system (e.g. on Warp quit) sends `shutdown` then `exit` to all running custom servers and waits up to 1s for graceful exit before SIGKILL. + +## Open questions + +- **Should `command` support `~` and `$VAR` expansion?** Cmd-O / `/open-file` use `shellexpand::tilde`; consistent behavior here would be friendly. Recommend yes for `~`, defer `$VAR` to a follow-up. +- **Should we ship example configs?** A `docs/custom-lsp-examples.md` with intelephense / lua-language-server / zls / bash-language-server entries would shorten time-to-first-success. Recommend yes; not part of the core feature gate. +- **Schema-fetching restriction:** The JSON LSP work in #9568 found that VS Code's JSON server fetches remote schemas by default. Should the spec mandate that custom LSPs run with `network_access = false` by default? This is hard to enforce generically (each server has its own config keys for schema fetching). Recommend punting to per-server `initialization_options` and documenting the pattern. diff --git a/specs/GH8803/tech.md b/specs/GH8803/tech.md new file mode 100644 index 000000000..7fea53500 --- /dev/null +++ b/specs/GH8803/tech.md @@ -0,0 +1,281 @@ +# Tech Spec: User-configurable language servers + +**Issue:** [warpdotdev/warp#8803](https://github.com/warpdotdev/warp/issues/8803) + +## Context + +Warp's LSP layer currently treats every language server as a closed-set enum variant on `LSPServerType` (`crates/lsp/src/supported_servers.rs`). Each variant has: + +- A `LanguageServerCandidate` impl in `crates/lsp/src/servers/.rs` (rust_analyzer, gopls, pyright, intelephense, etc.). +- An entry in `LanguageId` (`crates/lsp/src/config.rs:25-36`) that maps file extensions to language identifiers. +- A `LanguageId::lsp_language_identifier` arm and a `LanguageId::from_path` arm. + +Adding a new language requires touching all four sites. PRs #9562 (PHP Intelephense) and #9568 (JSON via vscode-json-languageserver) demonstrated this pattern but were closed by maintainer @kevinyang372 in favor of this user-configurable path. The infrastructure those PRs built (probe-spawn install detection, executable-bit checks, cross-platform PATH handling, `INSTALL_PROBE_TIMEOUT` via `warpui::r#async::FutureExt::with_timeout`) is reusable here. + +### Relevant code + +| Path | Role | +|---|---| +| `crates/lsp/src/language_server_candidate.rs` | The trait every server impl satisfies. The natural extension point — a new impl, `UserConfiguredLanguageServer`, will go here. | +| `crates/lsp/src/supported_servers.rs` | `LSPServerType` enum + the closed registry of impls. Will grow a new arm carrying user config. | +| `crates/lsp/src/config.rs` | `LanguageId` enum + `from_path` extension mapping + `lsp_language_identifier`. Needs a path that bypasses the enum for user-configured types. | +| `crates/lsp/src/manager.rs` | Spawns/owns running LSP processes. The new code lives here for per-workspace lifecycle. | +| `app/src/settings/` | Settings group definitions (see `app/src/settings/input.rs` for the macro pattern). Where `[lsp.servers]` parsing lands. | +| `app/src/code/editor/` | Editor footer rendering — where the "Enable `` for this workspace?" chip surfaces. | + +### Related closed PRs (input to this spec) + +- #9562 — PHP Intelephense as built-in. Closed; lessons: probe-spawn `--stdio` with bounded timeout, executable-bit check on Unix, executable's full PATH search via `binary_in_path` helper, cross-platform tests via `std::env::join_paths`. +- #9568 — JSON via vscode-json-languageserver. Closed; lessons: schema-fetching is a security surface (the JSON server defaults to fetching `http`/`https`/`file` schema URIs); `initializationOptions` is the right place for per-server restrictions like `handledSchemaProtocols`. + +## Proposed changes + +### 1. New settings group: `LspSettings` + +**File:** new `app/src/settings/lsp.rs`. Pattern matches `app/src/settings/input.rs` (`define_settings_group!` macro). + +The group holds a single setting, `custom_servers`, of type `Vec`. The struct: + +```rust +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct UserLspServerConfig { + pub name: String, + pub command: Vec, + pub file_types: Vec, + #[serde(default)] + pub root_files: Vec, + #[serde(default)] + pub initialization_options: Option, + #[serde(default = "default_start_timeout_ms")] + pub start_timeout_ms: u64, +} + +fn default_start_timeout_ms() -> u64 { 5000 } +``` + +Validation runs at parse time (in a `validate()` method called from the settings init path): + +- `name` non-empty and unique across the vec. +- `command` non-empty. +- `file_types` non-empty; each entry stripped of leading `.`. +- `start_timeout_ms` ≥ 100 and ≤ 60_000. + +Validation failures emit a settings-error notification (existing pattern in `app/src/settings/initializer.rs`) with the offending entry index. Other entries continue to load. + +### 2. Per-workspace enablement state + +**File:** new fields on the existing per-workspace settings struct (`app/src/workspace/state.rs` or similar — exact location depends on where workspace-scoped state lives; verify against current code at implementation time). + +```rust +pub struct WorkspaceLspState { + /// Set of `UserLspServerConfig.name` values the user explicitly enabled + /// for this workspace. Survives Warp restart via the existing workspace + /// state persistence path. + enabled_custom_servers: HashSet, + /// Set of `UserLspServerConfig.name` values dismissed via the chip. + /// Suppresses the chip until cleared from settings UI. + dismissed_custom_servers: HashSet, +} +``` + +### 3. New `LanguageServerCandidate` impl + +**File:** new `crates/lsp/src/servers/user_configured.rs`. + +```rust +pub struct UserConfiguredLanguageServer { + config: UserLspServerConfig, + workspace_root: PathBuf, +} + +#[async_trait] +impl LanguageServerCandidate for UserConfiguredLanguageServer { + async fn should_suggest_for_repo(&self, path: &Path, _executor: &CommandBuilder) -> bool { + // Heuristic: any file in `path` matches one of `file_types`, + // OR (if `root_files` is non-empty) one of those globs is present + // in `path` or any ancestor up to the workspace root. + // Implementation-wise: glob over the immediate dir for `file_types`, + // walk parents for `root_files`. + ... + } + + async fn is_installed_in_data_dir(&self, _executor: &CommandBuilder) -> bool { + // We never install user-configured servers. Always false. + false + } + + async fn is_installed_on_path(&self, executor: &CommandBuilder) -> bool { + // Reuse the patterns from #9562's probe: + // 1. `binary_in_path` filesystem check for `command[0]` (with executable-bit check on Unix) + // 2. NO probe-spawn — that contract was specific to npm-installed servers + // that may have stale shims. For user-configured servers we trust + // the user; an unhealthy spawn is surfaced via the start-time + // error toast instead. + binary_in_path(&self.config.command[0], executor.path_env_var()) + } + + async fn install(&self, _: LanguageServerMetadata, _: &CommandBuilder) -> anyhow::Result<()> { + // User-configured servers are never installed by Warp. The user owns + // the lifecycle. Surface a clear error if anything tries to call this. + anyhow::bail!("user-configured LSP `{}` is not installable by Warp", self.config.name) + } + + async fn fetch_latest_server_metadata(&self) -> anyhow::Result { + anyhow::bail!("user-configured LSP `{}` has no version metadata", self.config.name) + } +} +``` + +### 4. `LSPServerType` extension + +**File:** `crates/lsp/src/supported_servers.rs`. + +Add a variant: + +```rust +pub enum LSPServerType { + // ...existing variants... + UserConfigured(UserLspServerConfig), +} +``` + +The construction site that walks built-in servers becomes: + +```rust +fn all_candidates_for(workspace: &Workspace, settings: &LspSettings) -> Vec> { + let mut out = built_in_candidates(); + for cfg in &settings.custom_servers.0 { + if workspace.lsp_state.enabled_custom_servers.contains(&cfg.name) { + out.push(Box::new(UserConfiguredLanguageServer::new(cfg.clone(), workspace.root.clone()))); + } + } + out +} +``` + +### 5. Bypassing `LanguageId` for custom file types + +The `LanguageId` enum is used to populate the `languageId` field in LSP `textDocument/didOpen`. For user-configured servers, the user provides `file_types` directly. Two implementation options: + +**Option A — extend the enum with a `Custom(String)` variant.** The `Custom(String)` carries the file extension and `lsp_language_identifier()` returns it as-is. + +**Option B — keep the enum closed and add a parallel `LspLanguageId` type that's either `Builtin(LanguageId)` or `Custom { extension: String, identifier: String }`.** + +**Recommendation: Option A.** It's mechanical (one variant + two match arms) and avoids splitting the type system. The downside (more `Custom(...)` arms in match exhaustiveness) is acceptable. + +### 6. Footer chip + +**File:** `app/src/code/editor/` — extending whatever surface renders the LSP-related chip when a built-in server is detected-but-not-installed (find via `grep -rn "Install" app/src/code/editor/`). + +The chip rendering branches on three states: + +1. Built-in server detected, not installed → existing "Install ``" chip. +2. Custom server defined, not enabled in workspace, not dismissed → new "Enable ``" chip. +3. Custom server defined, but `command[0]` not on PATH → "Configure ``" chip with `Open settings` action (variant of state 2). + +Click handlers: + +- `Enable` → mutate `WorkspaceLspState.enabled_custom_servers`, persist, trigger `manager::start_for_workspace` for the new candidate. +- `Dismiss` → mutate `dismissed_custom_servers`, persist, no spawn. +- `Open settings` → existing settings-open-with-search action, scoped to `lsp.servers`. + +### 7. Settings reload handling + +**File:** `app/src/settings/initializer.rs` — extend the existing reload path. + +On settings change: + +1. Compute diff of `[lsp.servers]` between old and new config (by `name`). +2. For added entries: nothing immediate; chip will appear on next matching file open. +3. For removed entries: shut down any running instance for that name, clear from `enabled_custom_servers` in all workspaces. +4. For changed entries (config differs but `name` matches): if currently running, restart with new config. + +The diff mechanism reuses existing settings-event hooks; no new infra needed. + +### 8. Lifecycle: spawn, initialize, timeout, shutdown + +**File:** `crates/lsp/src/manager.rs`. + +The existing `start_for_workspace` (or whatever the entry-point is named — verify) spawns built-in servers. Extending it for user-configured servers means: + +- Build the `Command` from `config.command[0]` + `config.command[1..]`. +- `stdin/stdout` piped, `stderr` captured to a per-server ring buffer (last 200 bytes for error toasts). +- Wrap the `initialize` request in `warpui::r#async::FutureExt::with_timeout(Duration::from_millis(config.start_timeout_ms))`. Three outcomes (matches the pattern from `feat/9168-php-lsp-intelephense` commit `31285c4`): + - `Ok(Ok(_))` — server initialized; route LSP traffic. + - `Ok(Err(err))` — JSON-RPC error; surface notification with err message. + - `Err(timeout)` — kill child via `Drop`, surface timeout notification. +- Forward `config.initialization_options` (TOML → `serde_json::Value`) into `InitializeParams.initialization_options` on the request. +- On Warp shutdown: existing `shutdown` + `exit` flow already handles all running servers; user-configured servers ride the same path. + +## Testing and validation + +Each invariant from `product.md` maps to a test at this layer: + +| Invariant | Test layer | File | +|---|---|---| +| 1, 2 (parse / validate) | unit | `app/src/settings/lsp_tests.rs` (new) — TOML strings → `LspSettings` parse outcomes (success cases + each validation error). | +| 4, 5, 6, 7 (spawn outcomes) | unit | `crates/lsp/src/servers/user_configured_tests.rs` (new) — mock `CommandBuilder` returning success / error / hang. | +| 4 (`initialization_options` forwarded) | unit | same file — assert the `InitializeParams` JSON contains the configured options byte-equivalent. | +| 7 (timeout via `with_timeout`) | unit | same file — wire a future that never resolves, assert the timeout branch fires within `start_timeout_ms + 100`. | +| 9 (settings reload restarts running servers) | unit | `crates/lsp/src/manager_tests.rs` extension — pre-populate a running server, swap config, assert restart. | +| 10 (built-in + user-configured coexist) | integration | `crates/integration/tests/` — open a `.py` file in a workspace where both pyright (built-in) and a user-configured "ruff" server match; assert both are in the active candidate list. | +| 12 (per-workspace enablement persists across restart) | unit | workspace-state serialization round-trip. | +| 3, 8, 11, 14 (chip behavior) | integration | UI integration test under `crates/integration/`. Stub the file-open event, assert chip presence/absence based on enablement+dismissal state. | +| 13 (TOML→JSON shape preservation) | unit | parse a TOML with nested table + array, assert resulting `serde_json::Value` is structurally equivalent. | +| 15 (graceful shutdown on quit) | unit | shutdown handler test — running user-configured server receives `shutdown` then `exit` then has 1s window before SIGKILL. | + +### Cross-platform constraints (lessons from #9562/#9568) + +- Tests building `PATH` strings must use `std::env::join_paths`, not `:`. Reuse the `make_path_var` helper introduced in #9562's tests. +- On Windows, `command[0]` may need `.exe` / `.cmd` resolution. Reuse `binary_filename` helper (also from #9562's tests). +- `std::process::Stdio::null()` for stdin during `is_installed_on_path` check is a no-op for user-configured servers in this design (we don't probe-spawn), but if we ever do, the same pattern from `intelephense.rs:113` applies. + +## End-to-end flow + +``` +User edits settings.toml + └─> [LspSettings::reload] (settings/lsp.rs) + ├─> validate() (rejects malformed) + └─> emit SettingsChanged event + └─> [manager::on_settings_change] (lsp/src/manager.rs) + ├─> diff old vs new custom_servers + ├─> stop removed entries + └─> restart changed entries that are running + +User opens .lua file in workspace W + └─> [editor::on_file_open] (app/src/code/editor/) + └─> [chip_renderer] + ├─> get LspSettings.custom_servers + ├─> filter where file_types contains "lua" + ├─> filter where W.enabled_custom_servers does NOT contain name + ├─> filter where W.dismissed_custom_servers does NOT contain name + └─> render "Enable " chip per remaining entry + +User clicks Enable + └─> [chip_handler::on_enable_clicked] + ├─> W.enabled_custom_servers.insert(name) + ├─> persist W + └─> [manager::start_candidate] (lsp/src/manager.rs) + ├─> construct UserConfiguredLanguageServer (servers/user_configured.rs) + ├─> is_installed_on_path → if false, surface "Configure " toast + ├─> spawn Command with stderr-buffered + ├─> initialize.with_timeout(start_timeout_ms) + │ ├─> Ok(Ok(_)) → register, route LSP traffic + │ ├─> Ok(Err(err)) → surface error toast, drop child + │ └─> Err(_timeout) → kill child via Drop, surface timeout toast + └─> on subsequent file opens, no chip — server already running +``` + +## Risks + +- **`initialization_options` is a security surface.** Some servers (e.g. JSON via vscode-json-languageserver, see #9568) default to fetching remote schemas. The user controls this knob, but they may not realize it. **Mitigation:** ship `docs/custom-lsp-examples.md` with annotated examples that explicitly set network-related options to safe defaults where applicable. +- **A configured server that crashes on every spawn could loop forever.** **Mitigation:** the chip's `Enable` action is one-shot per click. We do not auto-restart on crash; the user re-enables manually. If exit happens after `initialize` succeeded, we surface a "server crashed" toast and the chip returns to the disabled-but-defined state. +- **`Custom(String)` propagation in `LanguageId`.** Adding a `Custom` variant to a closed exhaustive enum touches every match site. **Mitigation:** WARP.md prohibits wildcard matches, so the compiler will surface every site at code-write time. Audit during implementation. +- **Per-workspace state migration.** Existing workspaces have no `enabled_custom_servers` field. **Mitigation:** `#[serde(default)]` on the new fields; absence parses as empty set. + +## Follow-ups (out of this spec) + +- `nix flake check`-validated dev shell with all referenced LSP binaries pre-installed (would help testing). +- Settings UI: a "Custom language servers" page under Settings → Code intelligence that lists configured servers + workspace-enablement state with `Enable`/`Disable` buttons (currently described only in product.md user-experience section). +- `~` and `$VAR` expansion in `command[0]`. Recommend yes for `~` (consistency with `/open-file`); defer `$VAR`. +- Document `network_access` semantics if we later add a generic per-server flag. The JSON-server-specific `handledSchemaProtocols` discovery from #9568's review is the model.