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:
@@ -3059,3 +3059,150 @@ def test_discover_project_configs_skips_non_object_and_garbage(tmp_path):
|
||||
assert str(array / ".mcp.json") not in paths
|
||||
assert str(garbage / ".mcp.json") not in paths
|
||||
assert str(missing / ".mcp.json") not in paths
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Removed-flag -> env migration (ssh-mcp v2 and the general mechanism)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_detect_migratable_package_via_npx_args():
|
||||
data = {"command": "npx", "args": ["-y", "ssh-mcp", "--host=h", "--password=p"]}
|
||||
assert c.detect_migratable_package(data) == "ssh-mcp"
|
||||
|
||||
|
||||
def test_detect_migratable_package_direct_command_and_path_and_version():
|
||||
assert c.detect_migratable_package({"command": "ssh-mcp", "args": []}) == "ssh-mcp"
|
||||
assert (
|
||||
c.detect_migratable_package({"command": "/usr/local/bin/ssh-mcp", "args": []}) == "ssh-mcp"
|
||||
)
|
||||
assert (
|
||||
c.detect_migratable_package({"command": "npx", "args": ["-y", "ssh-mcp@2.0.1"]})
|
||||
== "ssh-mcp"
|
||||
)
|
||||
|
||||
|
||||
def test_detect_migratable_package_unknown_returns_none():
|
||||
assert c.detect_migratable_package({"command": "npx", "args": ["some-other"]}) is None
|
||||
assert c.detect_migratable_package({}) is None
|
||||
assert c.detect_migratable_package({"url": "https://x"}) is None
|
||||
|
||||
|
||||
def test_migrate_removed_flags_inline_form():
|
||||
data = {
|
||||
"command": "npx",
|
||||
"args": ["-y", "ssh-mcp", "--", "--host=1.2.3.4", "--password=hunter2"],
|
||||
}
|
||||
new, notes = c.migrate_removed_flags(data)
|
||||
assert new["args"] == ["-y", "ssh-mcp", "--", "--host=1.2.3.4"]
|
||||
assert new["env"] == {"SSH_MCP_PASSWORD": "hunter2"}
|
||||
assert any("SSH_MCP_PASSWORD" in n for n in notes)
|
||||
# Original untouched (pure function).
|
||||
assert "env" not in data
|
||||
|
||||
|
||||
def test_migrate_removed_flags_separate_form():
|
||||
data = {
|
||||
"command": "npx",
|
||||
"args": ["-y", "ssh-mcp", "--user", "root", "--password", "s3cret"],
|
||||
}
|
||||
new, _ = c.migrate_removed_flags(data)
|
||||
assert new["args"] == ["-y", "ssh-mcp", "--user", "root"]
|
||||
assert new["env"] == {"SSH_MCP_PASSWORD": "s3cret"}
|
||||
|
||||
|
||||
def test_migrate_removed_flags_preserves_existing_env_and_merges():
|
||||
data = {
|
||||
"command": "npx",
|
||||
"args": ["ssh-mcp", "--password=p"],
|
||||
"env": {"OTHER": "keep"},
|
||||
}
|
||||
new, _ = c.migrate_removed_flags(data)
|
||||
assert new["env"] == {"OTHER": "keep", "SSH_MCP_PASSWORD": "p"}
|
||||
|
||||
|
||||
def test_migrate_removed_flags_does_not_clobber_existing_secret():
|
||||
data = {
|
||||
"command": "npx",
|
||||
"args": ["ssh-mcp", "--password=fromargs"],
|
||||
"env": {"SSH_MCP_PASSWORD": "fromenv"},
|
||||
}
|
||||
new, notes = c.migrate_removed_flags(data)
|
||||
# env value wins; the redundant flag is still stripped so v2 can start.
|
||||
assert new["env"] == {"SSH_MCP_PASSWORD": "fromenv"}
|
||||
assert "--password=fromargs" not in new["args"]
|
||||
assert any("already set" in n for n in notes)
|
||||
|
||||
|
||||
def test_migrate_removed_flags_moves_env_ref_verbatim():
|
||||
data = {"command": "npx", "args": ["ssh-mcp", "--password", "${MY_PW}"]}
|
||||
new, _ = c.migrate_removed_flags(data)
|
||||
assert new["env"] == {"SSH_MCP_PASSWORD": "${MY_PW}"}
|
||||
assert "${MY_PW}" not in new["args"]
|
||||
|
||||
|
||||
def test_migrate_removed_flags_noop_returns_same_object():
|
||||
data = {"command": "npx", "args": ["ssh-mcp", "--host=h", "--user=u"]}
|
||||
new, notes = c.migrate_removed_flags(data)
|
||||
assert new is data
|
||||
assert notes == []
|
||||
|
||||
|
||||
def test_migrate_removed_flags_unknown_package_noop():
|
||||
data = {"command": "npx", "args": ["mystery", "--password=p"]}
|
||||
new, notes = c.migrate_removed_flags(data)
|
||||
assert new is data and notes == []
|
||||
|
||||
|
||||
def test_migrate_removed_flags_only_args_left_drops_args_key():
|
||||
data = {"command": "ssh-mcp", "args": ["--password=p"]}
|
||||
new, _ = c.migrate_removed_flags(data)
|
||||
assert "args" not in new
|
||||
assert new["env"] == {"SSH_MCP_PASSWORD": "p"}
|
||||
|
||||
|
||||
def test_removed_flag_warnings_covers_migratable_and_manual():
|
||||
data = {
|
||||
"command": "npx",
|
||||
"args": ["ssh-mcp", "--password=p", "--sudoPassword=s", "--disableSudo"],
|
||||
}
|
||||
warnings = c.removed_flag_warnings(data)
|
||||
text = " ".join(warnings)
|
||||
assert "--password" in text
|
||||
assert "sudoPassword" in text
|
||||
assert "disableSudo" in text
|
||||
# migrate only touches the confirmed --password mapping
|
||||
new, _ = c.migrate_removed_flags(data)
|
||||
assert new["env"] == {"SSH_MCP_PASSWORD": "p"}
|
||||
assert "--sudoPassword=s" in new["args"]
|
||||
assert "--disableSudo" in new["args"]
|
||||
|
||||
|
||||
def test_removed_flag_warnings_empty_when_clean():
|
||||
assert c.removed_flag_warnings({"command": "ssh-mcp", "args": ["--host=h"]}) == []
|
||||
assert c.removed_flag_warning({"command": "ssh-mcp", "args": ["--host=h"]}) is None
|
||||
|
||||
|
||||
def test_ssh_membermatters_end_to_end():
|
||||
# The exact shape from AJ's failing server.
|
||||
data = {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"ssh-mcp",
|
||||
"--",
|
||||
"--host=member.example.io",
|
||||
"--user=deploy",
|
||||
"--port=22",
|
||||
"--password=topsecret",
|
||||
],
|
||||
}
|
||||
new, notes = c.migrate_removed_flags(data)
|
||||
assert new["args"] == [
|
||||
"-y",
|
||||
"ssh-mcp",
|
||||
"--",
|
||||
"--host=member.example.io",
|
||||
"--user=deploy",
|
||||
"--port=22",
|
||||
]
|
||||
assert new["env"] == {"SSH_MCP_PASSWORD": "topsecret"}
|
||||
assert notes
|
||||
|
||||
Reference in New Issue
Block a user