feat(#88): detect & migrate removed CLI flags into env (ssh-mcp v2)
CI / Lint (ruff) (pull_request) Successful in 11s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 38s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 12s
CI / Catalog signature (pull_request) Successful in 8s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 12s
CI / Lint (ruff) (pull_request) Successful in 11s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 38s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 12s
CI / Catalog signature (pull_request) Successful in 8s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 12s
ssh-mcp v2 removed --password from the command line and reads
SSH_MCP_PASSWORD instead, so an old config crashes on startup. Add a
data-driven FLAG_ENV_MIGRATIONS registry plus detect_migratable_package,
migrate_removed_flags and removed_flag_warnings in bcc_core, and a
'Fix: move to environment variables' one-click action + warning in the
stdio server editor, with a matching main-window lint line. The literal
value lands in env{} (the only form Claude Desktop honours). 14 tests.
This commit is contained in:
+199
@@ -1964,6 +1964,205 @@ def _looks_like_multiple_args(a: str) -> bool:
|
||||
return len(toks) > 1 and any(t.startswith("-") for t in toks)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Removed-flag → env migration
|
||||
#
|
||||
# Some MCP servers moved credential CLI flags into environment variables across
|
||||
# a major version (secrets on the command line are visible to any local user in
|
||||
# process listings). A config written for the old version then fails hard on the
|
||||
# new one — e.g. ssh-mcp v2 exits with "These flags were removed in v2:
|
||||
# --password". This registry lets BCC recognise that shape, warn about it, and
|
||||
# offer a one-click migration that lifts the value out of `args` into `env`.
|
||||
#
|
||||
# Per package:
|
||||
# "env" removed flag -> env var that now supplies it. BCC auto-migrates
|
||||
# these: the flag (and its value) leave `args`, the value lands in
|
||||
# `env` under the mapped name.
|
||||
# "removed" flags that no longer exist but have no confirmed 1:1 env
|
||||
# replacement (e.g. a boolean, or an env name not documented here).
|
||||
# BCC only warns; the user resolves these by hand.
|
||||
#
|
||||
# Extending it: add a package key and its flag maps. Keep "env" limited to
|
||||
# mappings whose env-var name is verified, so the auto-fix stays trustworthy.
|
||||
# --------------------------------------------------------------------------- #
|
||||
FLAG_ENV_MIGRATIONS: dict[str, dict[str, dict[str, str]]] = {
|
||||
"ssh-mcp": {
|
||||
"env": {
|
||||
"--password": "SSH_MCP_PASSWORD",
|
||||
},
|
||||
"removed": {
|
||||
"--sudoPassword": (
|
||||
"removed in ssh-mcp v2 — supply it via an environment variable "
|
||||
"instead (see the ssh-mcp README's 'Migrating from v1' section)"
|
||||
),
|
||||
"--suPassword": (
|
||||
"removed in ssh-mcp v2 — supply it via an environment variable "
|
||||
"instead (see the ssh-mcp README's 'Migrating from v1' section)"
|
||||
),
|
||||
"--disableSudo": (
|
||||
"removed in ssh-mcp v2 — sudo is now governed by the server "
|
||||
"config/policy rather than a CLI flag"
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _normalize_pkg_token(token: str) -> str:
|
||||
"""Reduce an argv token to a bare npm package name for registry lookup.
|
||||
|
||||
Strips any leading path (``/usr/local/bin/ssh-mcp`` -> ``ssh-mcp``) and a
|
||||
trailing ``@version`` (``ssh-mcp@2.0.1`` -> ``ssh-mcp``), while preserving a
|
||||
leading scope (``@acme/ssh-mcp`` stays intact).
|
||||
"""
|
||||
t = str(token or "").strip()
|
||||
if not t or t.startswith("-"):
|
||||
return ""
|
||||
# Drop a path prefix but keep an npm scope (leading '@' with no earlier '/').
|
||||
if "/" in t and not t.startswith("@"):
|
||||
t = t.rsplit("/", 1)[-1]
|
||||
# Strip a trailing @version. For scoped names, only the version '@' counts:
|
||||
# split on the LAST '@' when it isn't the scope's leading one.
|
||||
at = t.rfind("@")
|
||||
if at > 0: # >0 so a scope's leading '@' at index 0 is untouched
|
||||
t = t[:at]
|
||||
return t
|
||||
|
||||
|
||||
def detect_migratable_package(data: dict) -> str | None:
|
||||
"""Return the FLAG_ENV_MIGRATIONS key this stdio server runs, or None.
|
||||
|
||||
Scans the command and every arg (so ``npx -y ssh-mcp`` and a direct
|
||||
``command: ssh-mcp`` both resolve), matching on the normalised package name.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
tokens = [data.get("command", "")]
|
||||
tokens.extend(data.get("args") or [])
|
||||
for tok in tokens:
|
||||
name = _normalize_pkg_token(tok)
|
||||
if name in FLAG_ENV_MIGRATIONS:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def migrate_removed_flags(data: dict) -> tuple[dict, list[str]]:
|
||||
"""Move known removed credential flags out of `args` and into `env`.
|
||||
|
||||
Returns ``(new_data, notes)``. When there is nothing to migrate the original
|
||||
dict is returned unchanged with an empty notes list, so callers can cheaply
|
||||
treat an empty notes list as "no change".
|
||||
|
||||
Only flags in the package's ``env`` map are rewritten. Both spellings are
|
||||
handled: ``--password secret`` (value on the next token) and
|
||||
``--password=secret`` (inline). An existing ``env`` value for the target
|
||||
name is never overwritten — the redundant flag is dropped and noted instead.
|
||||
A ``${VAR}`` reference is migrated verbatim (env is the right home for it).
|
||||
Flags listed under ``removed`` are left untouched here; see
|
||||
``removed_flag_warnings`` for those.
|
||||
"""
|
||||
pkg = detect_migratable_package(data)
|
||||
if pkg is None:
|
||||
return data, []
|
||||
env_map = FLAG_ENV_MIGRATIONS[pkg]["env"]
|
||||
args = [str(a) for a in (data.get("args") or [])]
|
||||
if not args:
|
||||
return data, []
|
||||
|
||||
new_args: list[str] = []
|
||||
new_env: dict = dict(data.get("env") or {})
|
||||
notes: list[str] = []
|
||||
i = 0
|
||||
n = len(args)
|
||||
changed = False
|
||||
while i < n:
|
||||
a = args[i]
|
||||
# Inline form: --flag=value
|
||||
if a.startswith("-") and "=" in a and a.split("=", 1)[0] in env_map:
|
||||
flag, value = a.split("=", 1)
|
||||
env_name = env_map[flag]
|
||||
changed = True
|
||||
if env_name in new_env and new_env[env_name] != value:
|
||||
notes.append(f"{flag} dropped from args ({env_name} is already set in env)")
|
||||
else:
|
||||
new_env[env_name] = value
|
||||
notes.append(f"moved {flag} into env as {env_name}")
|
||||
i += 1
|
||||
continue
|
||||
# Separate form: --flag value
|
||||
if a in env_map:
|
||||
flag = a
|
||||
env_name = env_map[flag]
|
||||
has_value = i + 1 < n and not args[i + 1].startswith("-")
|
||||
if not has_value:
|
||||
# No value to move (unexpected for a credential flag). Drop the
|
||||
# bare flag so the server can start, and say so.
|
||||
changed = True
|
||||
notes.append(f"removed {flag} from args (no value found to move)")
|
||||
i += 1
|
||||
continue
|
||||
value = args[i + 1]
|
||||
changed = True
|
||||
if env_name in new_env and new_env[env_name] != value:
|
||||
notes.append(f"{flag} dropped from args ({env_name} is already set in env)")
|
||||
else:
|
||||
new_env[env_name] = value
|
||||
notes.append(f"moved {flag} into env as {env_name}")
|
||||
i += 2
|
||||
continue
|
||||
new_args.append(a)
|
||||
i += 1
|
||||
|
||||
if not changed:
|
||||
return data, []
|
||||
|
||||
new_data = dict(data)
|
||||
if new_args:
|
||||
new_data["args"] = new_args
|
||||
else:
|
||||
new_data.pop("args", None)
|
||||
if new_env:
|
||||
new_data["env"] = new_env
|
||||
return new_data, notes
|
||||
|
||||
|
||||
def removed_flag_warnings(data: dict) -> list[str]:
|
||||
"""Advisory lines for every removed-in-a-major-version flag still present.
|
||||
|
||||
Covers both the auto-migratable flags (reported as a one-click fix being
|
||||
available) and the manual-only ones. Empty list == nothing to flag.
|
||||
"""
|
||||
pkg = detect_migratable_package(data)
|
||||
if pkg is None:
|
||||
return []
|
||||
spec = FLAG_ENV_MIGRATIONS[pkg]
|
||||
env_map = spec["env"]
|
||||
removed_map = spec.get("removed", {})
|
||||
present = set()
|
||||
for a in data.get("args") or []:
|
||||
s = str(a)
|
||||
present.add(s.split("=", 1)[0] if s.startswith("-") and "=" in s else s)
|
||||
|
||||
out: list[str] = []
|
||||
migratable = [f for f in env_map if f in present]
|
||||
if migratable:
|
||||
flags = ", ".join(sorted(migratable))
|
||||
out.append(
|
||||
f"{pkg}: {flags} was removed from the command line — its value should "
|
||||
f"live in an environment variable. Use “Fix” to move it into env."
|
||||
)
|
||||
for flag in sorted(removed_map):
|
||||
if flag in present:
|
||||
out.append(f"{pkg}: {flag} {removed_map[flag]}")
|
||||
return out
|
||||
|
||||
|
||||
def removed_flag_warning(data: dict) -> str | None:
|
||||
"""First removed-flag advisory for a single-line UI label, or None."""
|
||||
warnings = removed_flag_warnings(data)
|
||||
return warnings[0] if warnings else None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Validation
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user