# Conflicts: # apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts # apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts # apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts # apps/desktop/e2e/bot-mode-roster-localized.spec.ts # apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts # apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts # apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts # apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts # apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts # apps/desktop/e2e/bot-roster-user-sections.spec.ts # apps/desktop/e2e/bot-routines-pane-narrow.spec.ts # apps/desktop/e2e/bot-row-open-recent-session.spec.ts # apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts # apps/desktop/e2e/group-composer-auto-grow.spec.ts # apps/desktop/e2e/group-create-gate-remote-roster.spec.ts # apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts # apps/desktop/e2e/hosted-room-backend-continuity.spec.ts # apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts # apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts # apps/desktop/e2e/worktree-branch-status.spec.ts # apps/desktop/electron/backend-probes.test.ts # apps/desktop/electron/connection-apply.test.ts # apps/desktop/electron/desktop-electron-pin.test.ts # apps/desktop/electron/desktop-uninstall.test.ts # apps/desktop/electron/gateway-file-download-transport.test.ts # apps/desktop/electron/gateway-stop-before-update.test.ts # apps/desktop/electron/github-api-auth.test.ts # apps/desktop/electron/registry-primary-profile-scope.test.ts # apps/desktop/electron/update-api-check.test.ts # apps/desktop/electron/update-handoff-marker.test.ts # apps/desktop/electron/venv-blocker-scan.test.ts # apps/desktop/scripts/after-extract.test.mjs # apps/desktop/scripts/local-pack-publish.test.mjs # apps/desktop/scripts/tasks-scroll.test.mjs # apps/desktop/src/app/settings/model-settings.test.tsx # apps/desktop/src/app/updates-overlay.blockers.test.tsx # apps/desktop/src/components/desktop-install-overlay.test.tsx # apps/desktop/src/lib/update-copy.test.ts # scripts/ci/check_os_marker_fakes.py # tests-js/desktop-mac-usage-descriptions.test.ts # tests-js/node-engine-alignment.test.ts # tests/agent/lsp/test_install_and_lint_fixes.py # tests/agent/test_command_token_source.py # tests/agent/test_compression_boundary_hook.py # tests/agent/test_create_openai_client_ssl_verify.py # tests/agent/test_custom_provider_ca_probes.py # tests/agent/test_endpoint_blackhole.py # tests/agent/test_estimator_parity.py # tests/agent/test_in_place_compaction.py # tests/agent/test_moa_loop_mode.py # tests/agent/test_model_metadata.py # tests/agent/test_skill_session_platform_gate.py # tests/agent/test_skill_utils.py # tests/agent/test_ssl_ca_guard.py # tests/computer_use/test_doctor.py # tests/cron/test_codex_execution_paths.py # tests/cron/test_cron_bot_chat_delivery.py # tests/cron/test_cron_script.py # tests/cron/test_media_delivery_parity.py # tests/cron/test_misfire_catchup.py # tests/cron/test_parallel_pool.py # tests/cron/test_recurring_eagain_redispatch.py # tests/gateway/test_choice_picker.py # tests/gateway/test_control_socket_windows_live.py # tests/gateway/test_dingtalk.py # tests/gateway/test_feishu.py # tests/gateway/test_feishu_onboard.py # tests/gateway/test_gateway_shutdown.py # tests/gateway/test_matrix.py # tests/gateway/test_model_command_custom_providers.py # tests/gateway/test_reasoning_command.py # tests/gateway/test_runtime_footer.py # tests/gateway/test_session.py # tests/gateway/test_session_hygiene.py # tests/gateway/test_status.py # tests/gateway/test_teams.py # tests/gateway/test_turn_lease.py # tests/gateway/test_whatsapp_connect.py # tests/hermes_cli/test_approvals_command.py # tests/hermes_cli/test_auth_store_lock_concurrent.py # tests/hermes_cli/test_backup.py # tests/hermes_cli/test_banner_git_state.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_compat_manifest_targets.py # tests/hermes_cli/test_computer_use_cli.py # tests/hermes_cli/test_cpr_local_leak.py # tests/hermes_cli/test_dashboard_auth_gate.py # tests/hermes_cli/test_dashboard_procs_kill_grace.py # tests/hermes_cli/test_desktop_lifecycle_windows_live.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_command_install.py # tests/hermes_cli/test_fleet_config_migration_windows_live.py # tests/hermes_cli/test_gateway.py # tests/hermes_cli/test_gateway_platform_gating.py # tests/hermes_cli/test_gateway_restart_loop.py # tests/hermes_cli/test_gateway_task_probe.py # tests/hermes_cli/test_gateway_wsl.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_install_cua_driver.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_command_exports.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_linux_desktop_entry.py # tests/hermes_cli/test_local_runtime.py # tests/hermes_cli/test_local_runtime_updates.py # tests/hermes_cli/test_managed_uv.py # tests/hermes_cli/test_mcp_reload_confirm_gate.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_npm_engine.py # tests/hermes_cli/test_personality_none.py # tests/hermes_cli/test_pet_toggle.py # tests/hermes_cli/test_plan_reconciliation_windows_live.py # tests/hermes_cli/test_plugin_event_bus.py # tests/hermes_cli/test_plugin_manifest_v2.py # tests/hermes_cli/test_plugin_packs.py # tests/hermes_cli/test_plugins_cmd.py # tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py # tests/hermes_cli/test_process_identity.py # tests/hermes_cli/test_profiles.py # tests/hermes_cli/test_profiles_sidebar_cache.py # tests/hermes_cli/test_pty_bridge.py # tests/hermes_cli/test_resolve_turn_limit.py # tests/hermes_cli/test_serve_runtime_inventory.py # tests/hermes_cli/test_session_vacuum_config.py # tests/hermes_cli/test_set_config_value.py # tests/hermes_cli/test_signal_handler_kanban_worker.py # tests/hermes_cli/test_slash_confirm_windows.py # tests/hermes_cli/test_stale_pid_guard.py # tests/hermes_cli/test_startup_fast_guards.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_telegram_managed_bot.py # tests/hermes_cli/test_tools_config.py # tests/hermes_cli/test_update_apply_shallow_count.py # tests/hermes_cli/test_update_autostash.py # tests/hermes_cli/test_update_concurrent_quarantine.py # tests/hermes_cli/test_update_fetch_failure_classifier.py # tests/hermes_cli/test_update_fleet_probe_resume_token.py # tests/hermes_cli/test_update_handoff_backend_reap.py # tests/hermes_cli/test_update_handoff_desktop_rebuild.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_host_obligation.py # tests/hermes_cli/test_update_import_guard.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_update_inventory.py # tests/hermes_cli/test_update_launchd_unloaded_gateway.py # tests/hermes_cli/test_update_missing_configured_deps.py # tests/hermes_cli/test_update_modified_notice.py # tests/hermes_cli/test_update_multiplex_migration_hook.py # tests/hermes_cli/test_update_no_gateway_restart.py # tests/hermes_cli/test_update_orphan_backend_reap.py # tests/hermes_cli/test_update_parked_branch_guard.py # tests/hermes_cli/test_update_post_pull_syntax_guard.py # tests/hermes_cli/test_update_receipt.py # tests/hermes_cli/test_update_self_lock.py # tests/hermes_cli/test_update_shim_fail_closed.py # tests/hermes_cli/test_update_shim_self_lock.py # tests/hermes_cli/test_update_sqlite_remediation.py # tests/hermes_cli/test_update_stale_dashboard.py # tests/hermes_cli/test_update_stale_virtualenv.py # tests/hermes_cli/test_update_venv_health.py # tests/hermes_cli/test_update_venv_ownership_preflight.py # tests/hermes_cli/test_update_wedged_gateway.py # tests/hermes_cli/test_update_yes_flag.py # tests/hermes_cli/test_update_zip_two_phase.py # tests/hermes_cli/test_urllib_security.py # tests/hermes_cli/test_ux_messages_auth_config.py # tests/hermes_cli/test_ux_messages_startup.py # tests/hermes_cli/test_venv_holder_classifier.py # tests/hermes_cli/test_verify_console_scripts.py # tests/hermes_cli/test_verify_core_dependencies.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_server_console_ws.py # tests/hermes_cli/test_web_server_ws_ping.py # tests/hermes_cli/test_web_ui_build.py # tests/hermes_state/test_fts_rebuild_admission.py # tests/hermes_state/test_hermes_state.py # tests/plugins/memory/test_memory_lazy_install.py # tests/plugins/test_google_meet_plugin.py # tests/plugins/test_langfuse_plugin.py # tests/plugins/test_security_guidance_plugin.py # tests/plugins/test_transform_llm_output_hook.py # tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py # tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py # tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py # tests/scripts/install/test_install_clone_throttle_fallback.py # tests/scripts/install/test_install_lockfile_churn.py # tests/scripts/install/test_install_no_initial_commit.py # tests/scripts/install/test_install_sh_browser_install.py # tests/scripts/install/test_install_sh_node_prerelease.py # tests/scripts/install/test_install_sh_symlink_stomp.py # tests/scripts/install/test_install_sh_uv_lock_config.py # tests/scripts/install/test_install_unmerged_index.py # tests/scripts/test_contributor_map.py # tests/scripts/test_run_tests_parallel.py # tests/skills/test_competitor_news_monitor_skill.py # tests/skills/test_document_to_action_items_skill.py # tests/skills/test_google_workspace_setup.py # tests/skills/test_google_workspace_setup_deps.py # tests/skills/test_grounded_citations_skill.py # tests/skills/test_ip_as_logo_skill.py # tests/skills/test_live_dashboard_skill.py # tests/skills/test_mcp_oauth_remote_gateway_skill.py # tests/skills/test_office_document_skills.py # tests/skills/test_openclaw_migration.py # tests/skills/test_product_price_monitor_skill.py # tests/skills/test_scrollcraft_skill.py # tests/skills/test_setup_wizard_generator_skill.py # tests/skills/test_weekly_review_planning_skill.py # tests/test_engines_satisfiable.py # tests/test_fast_safe_load.py # tests/test_hermes_bootstrap.py # tests/test_hermes_constants.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/test_model_tools_async_bridge.py # tests/test_packaging_build_guard.py # tests/test_packaging_metadata.py # tests/test_yaml_indent_consistency.py # tests/tools/test_approval_timeout_overflow.py # tests/tools/test_base_environment.py # tests/tools/test_bot_mode_dm.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_hardening.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_use_cli.py # tests/tools/test_clipboard.py # tests/tools/test_code_execution.py # tests/tools/test_code_execution_modes.py # tests/tools/test_code_execution_windows_env.py # tests/tools/test_computer_use.py # tests/tools/test_delegate_liveness_timeout.py # tests/tools/test_execute_code_approval_cluster.py # tests/tools/test_execution_flag_detection.py # tests/tools/test_fal_common.py # tests/tools/test_file_operations.py # tests/tools/test_file_tools.py # tests/tools/test_file_tools_cwd_resolution.py # tests/tools/test_file_tools_live.py # tests/tools/test_lazy_deps.py # tests/tools/test_lazy_deps_durable_target.py # tests/tools/test_lazy_deps_managed.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_local_tempdir.py # tests/tools/test_macos_protected_search.py # tests/tools/test_mcp_npx_cached_bin.py # tests/tools/test_oneshot_completion_linger.py # tests/tools/test_process_registry.py # tests/tools/test_read_file_schema_gating.py # tests/tools/test_skill_improvements.py # tests/tools/test_skills_sync.py # tests/tools/test_termux_api_detection.py # tests/tools/test_tirith_security.py # tests/tools/test_transcription_tools.py # tests/tools/test_tts_streaming.py # tests/tools/test_wake_word.py # tests/tui_gateway/test_compute_host_borrowed_lease.py # tests/tui_gateway/test_compute_host_turn_protocol.py # tests/tui_gateway/test_isolated_orphan_activity.py # tests/tui_gateway/test_protocol.py # tests/tui_gateway/test_slash_worker_profile_home.py # tests/tui_gateway/test_subprocess_encoding.py # tests/tui_gateway/test_tui_gateway_server.py # ui-tui/src/__tests__/terminalParity.test.ts # ui-tui/src/__tests__/termuxComposerLayout.test.ts # ui-tui/src/__tests__/textInputFastEcho.test.ts
920 lines
34 KiB
Python
920 lines
34 KiB
Python
"""
|
|
Tests for hermes_cli.mcp_config — ``hermes mcp`` subcommands.
|
|
|
|
These tests mock the MCP server connection layer so they run without
|
|
any actual MCP servers or API keys.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from tools import mcp_tool_config as _mcp_config
|
|
|
|
|
|
def _set_interactive_stdin(monkeypatch, *, is_tty: bool = True) -> None:
|
|
from unittest.mock import MagicMock
|
|
|
|
mock_stdin = MagicMock()
|
|
mock_stdin.isatty.return_value = is_tty
|
|
monkeypatch.setattr("tools.mcp_oauth.sys.stdin", mock_stdin)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_config(tmp_path, monkeypatch):
|
|
"""Redirect all config I/O to a temp directory."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.get_hermes_home", lambda: tmp_path
|
|
)
|
|
config_path = tmp_path / "config.yaml"
|
|
env_path = tmp_path / ".env"
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.get_config_path", lambda: config_path
|
|
)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.get_env_path", lambda: env_path
|
|
)
|
|
return tmp_path
|
|
|
|
|
|
def _make_args(**kwargs):
|
|
"""Build a minimal argparse.Namespace."""
|
|
defaults = {
|
|
"name": "test-server",
|
|
"url": None,
|
|
"mcp_command": None,
|
|
"args": None,
|
|
"auth": None,
|
|
"preset": None,
|
|
"env": None,
|
|
"mcp_action": None,
|
|
}
|
|
defaults.update(kwargs)
|
|
return argparse.Namespace(**defaults)
|
|
|
|
|
|
def _seed_config(tmp_path: Path, mcp_servers: dict):
|
|
"""Write a config.yaml with the given mcp_servers."""
|
|
import hermes_yaml as yaml
|
|
|
|
config = {"mcp_servers": mcp_servers, "_config_version": 9}
|
|
config_path = tmp_path / "config.yaml"
|
|
with open(config_path, "w", encoding="utf-8") as f:
|
|
yaml.safe_dump(config, f)
|
|
|
|
|
|
class FakeTool:
|
|
"""Mimics an MCP tool object returned by the SDK."""
|
|
|
|
def __init__(self, name: str, description: str = ""):
|
|
self.name = name
|
|
self.description = description
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: cmd_mcp_list
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMcpList:
|
|
|
|
def test_list_with_servers(self, tmp_path, capsys):
|
|
_seed_config(tmp_path, {
|
|
"ink": {
|
|
"url": "https://mcp.ml.ink/mcp",
|
|
"enabled": True,
|
|
"tools": {"include": ["create_service", "get_service"]},
|
|
},
|
|
"github": {
|
|
"command": "npx",
|
|
"args": ["@mcp/github"],
|
|
"enabled": False,
|
|
},
|
|
})
|
|
from hermes_cli.mcp_config import cmd_mcp_list
|
|
|
|
cmd_mcp_list()
|
|
out = capsys.readouterr().out
|
|
assert "ink" in out
|
|
assert "github" in out
|
|
assert "2 selected" in out # ink has 2 in include
|
|
assert "disabled" in out # github is disabled
|
|
|
|
def test_list_enabled_default_true(self, tmp_path, capsys):
|
|
"""Server without explicit enabled key defaults to enabled."""
|
|
_seed_config(tmp_path, {
|
|
"myserver": {"url": "https://example.com/mcp"},
|
|
})
|
|
from hermes_cli.mcp_config import cmd_mcp_list
|
|
|
|
cmd_mcp_list()
|
|
out = capsys.readouterr().out
|
|
assert "myserver" in out
|
|
assert "enabled" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: cmd_mcp_remove
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMcpRemove:
|
|
def test_remove_existing_server(self, tmp_path, capsys, monkeypatch):
|
|
_seed_config(tmp_path, {
|
|
"myserver": {"url": "https://example.com/mcp"},
|
|
})
|
|
monkeypatch.setattr("builtins.input", lambda _: "y")
|
|
from hermes_cli.mcp_config import cmd_mcp_remove
|
|
|
|
cmd_mcp_remove(_make_args(name="myserver"))
|
|
|
|
out = capsys.readouterr().out
|
|
assert "Removed" in out
|
|
|
|
# Verify config updated
|
|
from hermes_cli.config import load_config
|
|
|
|
config = load_config()
|
|
assert "myserver" not in config.get("mcp_servers", {})
|
|
|
|
|
|
def test_remove_cleans_oauth_tokens(self, tmp_path, capsys, monkeypatch):
|
|
_seed_config(tmp_path, {
|
|
"oauth-srv": {"url": "https://example.com/mcp", "auth": "oauth"},
|
|
})
|
|
monkeypatch.setattr("builtins.input", lambda _: "y")
|
|
# Also patch get_hermes_home in the mcp_config module namespace
|
|
monkeypatch.setattr(
|
|
"hermes_cli.mcp_config.get_hermes_home", lambda: tmp_path
|
|
)
|
|
|
|
# Create a fake token file
|
|
token_dir = tmp_path / "mcp-tokens"
|
|
token_dir.mkdir()
|
|
token_file = token_dir / "oauth-srv.json"
|
|
token_file.write_text("{}", encoding="utf-8")
|
|
|
|
from hermes_cli.mcp_config import cmd_mcp_remove
|
|
|
|
cmd_mcp_remove(_make_args(name="oauth-srv"))
|
|
assert not token_file.exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: cmd_mcp_add
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMcpAdd:
|
|
|
|
def test_add_http_server_all_tools(self, tmp_path, capsys, monkeypatch):
|
|
"""Add an HTTP server, accept all tools."""
|
|
fake_tools = [
|
|
FakeTool("create_service", "Deploy from repo"),
|
|
FakeTool("list_services", "List all services"),
|
|
]
|
|
|
|
def mock_probe(name, config, **kw):
|
|
return [(t.name, t.description) for t in fake_tools]
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.mcp_config._probe_single_server", mock_probe
|
|
)
|
|
# No auth, accept all tools
|
|
inputs = iter(["n", ""]) # no auth needed, enable all
|
|
monkeypatch.setattr("builtins.input", lambda _: next(inputs))
|
|
|
|
from hermes_cli.mcp_config import cmd_mcp_add
|
|
|
|
cmd_mcp_add(_make_args(name="ink", url="https://mcp.ml.ink/mcp"))
|
|
out = capsys.readouterr().out
|
|
assert "Saved" in out
|
|
assert "2/2 tools" in out
|
|
|
|
# Verify config written
|
|
from hermes_cli.config import load_config
|
|
|
|
config = load_config()
|
|
assert "ink" in config.get("mcp_servers", {})
|
|
assert config["mcp_servers"]["ink"]["url"] == "https://mcp.ml.ink/mcp"
|
|
|
|
|
|
def test_add_stdio_server_with_env(self, tmp_path, capsys, monkeypatch):
|
|
"""Stdio servers can persist explicit environment variables."""
|
|
fake_tools = [FakeTool("search", "Search repos")]
|
|
|
|
def mock_probe(name, config, **kw):
|
|
assert config["env"] == {
|
|
"MY_API_KEY": "secret123",
|
|
"DEBUG": "true",
|
|
}
|
|
return [(t.name, t.description) for t in fake_tools]
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.mcp_config._probe_single_server", mock_probe
|
|
)
|
|
monkeypatch.setattr("builtins.input", lambda _: "")
|
|
|
|
from hermes_cli.mcp_config import cmd_mcp_add
|
|
|
|
cmd_mcp_add(_make_args(
|
|
name="github",
|
|
mcp_command="npx",
|
|
args=["@mcp/github"],
|
|
env=["MY_API_KEY=secret123", "DEBUG=true"],
|
|
))
|
|
out = capsys.readouterr().out
|
|
assert "Saved" in out
|
|
|
|
from hermes_cli.config import load_config
|
|
|
|
config = load_config()
|
|
srv = config["mcp_servers"]["github"]
|
|
assert srv["env"] == {
|
|
"MY_API_KEY": "secret123",
|
|
"DEBUG": "true",
|
|
}
|
|
|
|
|
|
def test_add_preset_fills_transport(self, tmp_path, capsys, monkeypatch):
|
|
"""A preset fills in command/args when no explicit transport given."""
|
|
monkeypatch.setattr(
|
|
"hermes_cli.mcp_config._MCP_PRESETS",
|
|
{"testmcp": {"command": "npx", "args": ["-y", "test-mcp-server"], "display_name": "Test MCP"}},
|
|
)
|
|
fake_tools = [FakeTool("do_thing", "Does a thing")]
|
|
|
|
def mock_probe(name, config, **kw):
|
|
assert name == "myserver"
|
|
assert config["command"] == "npx"
|
|
assert config["args"] == ["-y", "test-mcp-server"]
|
|
assert "env" not in config
|
|
return [(t.name, t.description) for t in fake_tools]
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.mcp_config._probe_single_server", mock_probe
|
|
)
|
|
monkeypatch.setattr("builtins.input", lambda _: "")
|
|
|
|
from hermes_cli.mcp_config import cmd_mcp_add
|
|
from hermes_cli.config import read_raw_config
|
|
|
|
cmd_mcp_add(_make_args(name="myserver", preset="testmcp"))
|
|
out = capsys.readouterr().out
|
|
assert "Saved" in out
|
|
|
|
config = read_raw_config()
|
|
srv = config["mcp_servers"]["myserver"]
|
|
assert srv["command"] == "npx"
|
|
assert srv["args"] == ["-y", "test-mcp-server"]
|
|
assert "env" not in srv
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: cmd_mcp_test
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMcpTest:
|
|
|
|
|
|
def test_exit_codes_distinguish_failure_from_unknown_server(self, tmp_path, capsys, monkeypatch):
|
|
"""0 connected, 1 connection failed, 3 not in config — never argparse's 2, never a silent 0."""
|
|
_seed_config(tmp_path, {"ink": {"url": "https://mcp.ml.ink/mcp"}})
|
|
from hermes_cli.mcp_config import cmd_mcp_test
|
|
|
|
monkeypatch.setattr("hermes_cli.mcp_config._probe_single_server", lambda name, cfg, **kw: [])
|
|
assert cmd_mcp_test(_make_args(name="ink")) == 0
|
|
|
|
def failing_probe(name, cfg, **kw):
|
|
raise RuntimeError("Server returned an error response")
|
|
|
|
monkeypatch.setattr("hermes_cli.mcp_config._probe_single_server", failing_probe)
|
|
assert cmd_mcp_test(_make_args(name="ink")) == 1
|
|
assert cmd_mcp_test(_make_args(name="doesnotexist")) == 3
|
|
assert "not found in config" in capsys.readouterr().out
|
|
|
|
def test_cli_dispatcher_forwards_test_exit_code(self, tmp_path, monkeypatch):
|
|
"""``hermes mcp test`` reaches ``main()`` with the handler's code (the dispatcher used to drop it)."""
|
|
_seed_config(tmp_path, {"ink": {"url": "https://mcp.ml.ink/mcp"}})
|
|
from hermes_cli.main import cmd_mcp
|
|
|
|
def failing_probe(name, cfg, **kw):
|
|
raise RuntimeError("boom")
|
|
|
|
monkeypatch.setattr("hermes_cli.mcp_config._probe_single_server", failing_probe)
|
|
assert cmd_mcp(_make_args(name="ink", mcp_action="test")) == 1
|
|
assert cmd_mcp(_make_args(name="doesnotexist", mcp_action="test")) == 3
|
|
assert cmd_mcp(_make_args(mcp_action="list")) is None
|
|
|
|
def test_probe_uses_configured_connect_timeout(self, monkeypatch):
|
|
"""OAuth-capable probes must not hard-code a short 30s timeout."""
|
|
import asyncio
|
|
from hermes_cli import mcp_config
|
|
from tools import mcp_tool_discovery as _mcp_discovery
|
|
from tools import mcp_tool_lifecycle as _mcp_lifecycle
|
|
from tools import mcp_tool_loop as _mcp_loop
|
|
|
|
captured = {}
|
|
|
|
class FakeServer:
|
|
_tools = []
|
|
|
|
async def shutdown(self):
|
|
captured["shutdown"] = True
|
|
|
|
async def fake_connect(name, config):
|
|
return FakeServer()
|
|
|
|
def fake_run_on_mcp_loop(coro, timeout):
|
|
captured["outer_timeout"] = timeout
|
|
return asyncio.run(coro)
|
|
|
|
async def fake_wait_for(awaitable, timeout):
|
|
captured["inner_timeout"] = timeout
|
|
return await awaitable
|
|
|
|
monkeypatch.setattr(_mcp_loop, "_ensure_mcp_loop", lambda: None)
|
|
monkeypatch.setattr(_mcp_lifecycle, "_stop_mcp_loop_if_idle", lambda: None)
|
|
monkeypatch.setattr(_mcp_discovery, "_connect_server", fake_connect)
|
|
monkeypatch.setattr(_mcp_loop, "_run_on_mcp_loop", fake_run_on_mcp_loop)
|
|
monkeypatch.setattr(mcp_config.asyncio, "wait_for", fake_wait_for)
|
|
|
|
assert mcp_config._probe_single_server(
|
|
"supabase", {"connect_timeout": 300}
|
|
) == []
|
|
assert captured["inner_timeout"] == 300.0
|
|
assert captured["outer_timeout"] == 310.0
|
|
assert captured["shutdown"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: env var interpolation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestEnvVarInterpolation:
|
|
|
|
|
|
def test_interpolate_cursor_env_prefix(self, monkeypatch):
|
|
"""Cursor-style ${env:VAR} resolves the same secret as ${VAR}."""
|
|
monkeypatch.setenv("MY_KEY", "secret123")
|
|
from tools.mcp_tool_config import _interpolate_env_vars
|
|
|
|
assert _interpolate_env_vars("Bearer ${env:MY_KEY}") == "Bearer secret123"
|
|
|
|
|
|
def test_env_ref_name_strips_prefix(self):
|
|
from tools.mcp_tool_common import _env_ref_name
|
|
|
|
assert _env_ref_name("env:API_KEY") == "API_KEY"
|
|
assert _env_ref_name("API_KEY") == "API_KEY"
|
|
assert _env_ref_name(" env:API_KEY ") == "API_KEY"
|
|
|
|
|
|
class TestContextVarInterpolation:
|
|
"""Cursor-style context variables: ${userHome}, ${workspaceFolder},
|
|
${workspaceFolderBasename}, ${pathSeparator}, ${/}."""
|
|
|
|
def test_user_home(self):
|
|
import os
|
|
|
|
from tools.mcp_tool_config import _interpolate_env_vars
|
|
|
|
assert _interpolate_env_vars("${userHome}") == os.path.expanduser("~")
|
|
|
|
def test_path_separator_and_slash_shorthand(self):
|
|
import os
|
|
|
|
from tools.mcp_tool_config import _interpolate_env_vars
|
|
|
|
assert _interpolate_env_vars("${pathSeparator}") == os.sep
|
|
assert _interpolate_env_vars("${/}") == os.sep
|
|
|
|
def test_workspace_folder_and_basename(self, monkeypatch):
|
|
|
|
monkeypatch.setattr(
|
|
_mcp_config, "_workspace_folder", lambda: "/srv/projects/myapp"
|
|
)
|
|
assert _mcp_config._interpolate_env_vars("${workspaceFolder}") == (
|
|
"/srv/projects/myapp"
|
|
)
|
|
assert _mcp_config._interpolate_env_vars(
|
|
"${workspaceFolderBasename}"
|
|
) == "myapp"
|
|
|
|
def test_workspace_folder_falls_back_to_cwd(self, monkeypatch):
|
|
import os
|
|
|
|
import tools.file_tools_paths as file_tools_paths
|
|
from tools.mcp_tool_config import _workspace_folder
|
|
|
|
monkeypatch.setattr(
|
|
file_tools_paths, "_authoritative_workspace_root", lambda task_id="default": None
|
|
)
|
|
assert _workspace_folder() == os.getcwd()
|
|
|
|
def test_mixed_string_with_env_and_context_vars(self, monkeypatch):
|
|
import os
|
|
|
|
|
|
monkeypatch.setenv("MY_TOKEN", "tok-1")
|
|
monkeypatch.setattr(_mcp_config, "_workspace_folder", lambda: "/ws/app")
|
|
result = _mcp_config._interpolate_env_vars(
|
|
"${userHome}${/}.cache${/}${workspaceFolderBasename}-${MY_TOKEN}"
|
|
)
|
|
home = os.path.expanduser("~")
|
|
assert result == f"{home}{os.sep}.cache{os.sep}app-tok-1"
|
|
|
|
def test_context_names_are_case_sensitive(self, monkeypatch):
|
|
"""${USERHOME} is NOT a context var — it keeps env-var semantics
|
|
(literal placeholder when unset)."""
|
|
monkeypatch.delenv("USERHOME", raising=False)
|
|
from tools.mcp_tool_config import _interpolate_env_vars
|
|
|
|
assert _interpolate_env_vars("${USERHOME}") == "${USERHOME}"
|
|
|
|
def test_unknown_ref_keeps_literal_placeholder(self, monkeypatch):
|
|
monkeypatch.delenv("NOT_A_REAL_VAR_XYZ", raising=False)
|
|
from tools.mcp_tool_config import _interpolate_env_vars
|
|
|
|
assert _interpolate_env_vars("${NOT_A_REAL_VAR_XYZ}") == (
|
|
"${NOT_A_REAL_VAR_XYZ}"
|
|
)
|
|
|
|
def test_context_vars_in_nested_config(self, monkeypatch):
|
|
import os
|
|
|
|
from tools import mcp_tool_config as _mcp_config
|
|
|
|
monkeypatch.setattr(_mcp_config, "_workspace_folder", lambda: "/ws/app")
|
|
cfg = {
|
|
"command": "npx",
|
|
"args": ["-y", "server-fs", "${workspaceFolder}"],
|
|
"cwd": "${workspaceFolder}",
|
|
"env": {"CACHE": "${userHome}${/}.cache"},
|
|
"headers": {"X-Ws": "${workspaceFolderBasename}"},
|
|
}
|
|
out = _mcp_config._interpolate_env_vars(cfg)
|
|
home = os.path.expanduser("~")
|
|
assert out["args"][2] == "/ws/app"
|
|
assert out["cwd"] == "/ws/app"
|
|
assert out["env"]["CACHE"] == f"{home}{os.sep}.cache"
|
|
assert out["headers"]["X-Ws"] == "app"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: probe-path env resolution (#37792)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestProbeEnvResolution:
|
|
"""The probe path must resolve ``${ENV}`` before connecting, so the
|
|
discovery probe behaves like runtime tool loading. Regression for #37792
|
|
where `hermes mcp add --auth header` sent a literal
|
|
``Authorization: Bearer ${MCP_X_API_KEY}`` and got 401."""
|
|
|
|
def test_resolve_interpolates_header(self, monkeypatch):
|
|
from hermes_cli.mcp_config import _resolve_mcp_server_config
|
|
|
|
monkeypatch.setenv("MCP_N8N_API_KEY", "jwt-token-xyz")
|
|
resolved = _resolve_mcp_server_config({
|
|
"url": "http://localhost:5678/mcp-server/http",
|
|
"headers": {"Authorization": "Bearer ${MCP_N8N_API_KEY}"},
|
|
})
|
|
assert resolved["headers"]["Authorization"] == "Bearer jwt-token-xyz"
|
|
|
|
def test_active_secret_scope_does_not_load_dotenv_into_process_env(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
from agent.secret_scope import reset_secret_scope, set_secret_scope
|
|
from hermes_cli.mcp_config import _resolve_mcp_server_config
|
|
|
|
monkeypatch.setenv("MCP_SHARED_API_KEY", "default-secret")
|
|
token = set_secret_scope({"MCP_SHARED_API_KEY": "profile-secret"})
|
|
try:
|
|
resolved = _resolve_mcp_server_config({
|
|
"headers": {"Authorization": "Bearer ${MCP_SHARED_API_KEY}"},
|
|
})
|
|
finally:
|
|
reset_secret_scope(token)
|
|
|
|
assert resolved["headers"]["Authorization"] == "Bearer profile-secret"
|
|
assert os.environ["MCP_SHARED_API_KEY"] == "default-secret"
|
|
|
|
|
|
def test_probe_resolves_before_connect(self, monkeypatch):
|
|
"""_probe_single_server must pass the RESOLVED config to _connect_server."""
|
|
import hermes_cli.mcp_config as mc
|
|
|
|
monkeypatch.setenv("MCP_N8N_API_KEY", "jwt-token-xyz")
|
|
|
|
seen = {}
|
|
|
|
class _FakeTool:
|
|
name = "do_thing"
|
|
description = "a tool"
|
|
|
|
class _FakeServer:
|
|
_tools = [_FakeTool()]
|
|
|
|
async def shutdown(self):
|
|
return None
|
|
|
|
async def _fake_connect(name, config):
|
|
seen["config"] = config
|
|
return _FakeServer()
|
|
|
|
monkeypatch.setattr("tools.mcp_tool_discovery._connect_server", _fake_connect)
|
|
|
|
tools = mc._probe_single_server("n8n", {
|
|
"url": "http://localhost:5678/mcp-server/http",
|
|
"headers": {"Authorization": "Bearer ${MCP_N8N_API_KEY}"},
|
|
})
|
|
|
|
assert tools == [("do_thing", "a tool")]
|
|
assert seen["config"]["headers"]["Authorization"] == "Bearer jwt-token-xyz"
|
|
|
|
def test_probe_propagates_explicit_connect_timeout_to_config(self, monkeypatch):
|
|
"""An explicit `connect_timeout=` override (e.g. `hermes mcp login`'s 315s, extended so a
|
|
user has time to finish an OAuth browser flow) must reach `config["connect_timeout"]` —
|
|
that's what tools/mcp_tool_transport.py::_negotiate_session bounds session.initialize()
|
|
with. Left stale at its unrelated 60s default, the still-pending OAuth callback wait gets
|
|
cancelled mid-flow well before the caller's intended deadline."""
|
|
import hermes_cli.mcp_config as mc
|
|
|
|
seen = {}
|
|
|
|
class _FakeServer:
|
|
_tools = []
|
|
|
|
async def shutdown(self):
|
|
return None
|
|
|
|
async def _fake_connect(name, config):
|
|
seen["config"] = config
|
|
return _FakeServer()
|
|
|
|
monkeypatch.setattr("tools.mcp_tool_discovery._connect_server", _fake_connect)
|
|
|
|
mc._probe_single_server(
|
|
"travelermd", {"url": "https://mcp.traveler.md/mcp", "auth": "oauth"}, connect_timeout=315.0
|
|
)
|
|
|
|
assert seen["config"]["connect_timeout"] == 315.0
|
|
|
|
|
|
class TestProbeCapabilityGating:
|
|
"""The ``details`` probe must not fire prompts/list or resources/list at
|
|
servers that either disabled them in config or never advertised them.
|
|
|
|
Regression for the Unreal MCP server case: it answers
|
|
``Call to unknown method "prompts/list"``, so an unconditional probe logged
|
|
a hard error and ``tools.prompts: false`` (the documented workaround) had no
|
|
effect because the probe never consulted config or capabilities.
|
|
"""
|
|
|
|
class _FakeTool:
|
|
name = "do_thing"
|
|
description = "a tool"
|
|
|
|
class _Caps:
|
|
def __init__(self, prompts=None, resources=None):
|
|
self.prompts = prompts
|
|
self.resources = resources
|
|
|
|
class _InitResult:
|
|
def __init__(self, caps):
|
|
self.capabilities = caps
|
|
|
|
def _make_server(self, called, caps):
|
|
outer = self
|
|
|
|
class _Result(list):
|
|
@property
|
|
def prompts(self):
|
|
return self
|
|
|
|
@property
|
|
def resources(self):
|
|
return self
|
|
|
|
class _Session:
|
|
async def list_prompts(self_inner):
|
|
called.append("prompts")
|
|
return _Result()
|
|
|
|
async def list_resources(self_inner):
|
|
called.append("resources")
|
|
return _Result()
|
|
|
|
class _FakeServer:
|
|
_tools = [outer._FakeTool()]
|
|
session = _Session()
|
|
initialize_result = outer._InitResult(caps)
|
|
|
|
async def shutdown(self_inner):
|
|
return None
|
|
|
|
return _FakeServer()
|
|
|
|
def _run_probe(self, monkeypatch, config, caps):
|
|
import hermes_cli.mcp_config as mc
|
|
|
|
called: list[str] = []
|
|
|
|
async def _fake_connect(name, cfg):
|
|
return self._make_server(called, caps)
|
|
|
|
monkeypatch.setattr("tools.mcp_tool_discovery._connect_server", _fake_connect)
|
|
details: dict = {}
|
|
mc._probe_single_server("srv", config, details=details)
|
|
return called, details
|
|
|
|
def test_config_disables_prompts_probe(self, monkeypatch):
|
|
# Server advertises both, but user turned prompts off.
|
|
caps = self._Caps(prompts=object(), resources=object())
|
|
called, details = self._run_probe(
|
|
monkeypatch, {"url": "http://x/mcp", "tools": {"prompts": False}}, caps
|
|
)
|
|
assert "prompts" not in called
|
|
assert "resources" in called
|
|
|
|
|
|
def test_advertised_and_enabled_is_probed(self, monkeypatch):
|
|
caps = self._Caps(prompts=object(), resources=object())
|
|
called, details = self._run_probe(monkeypatch, {"url": "http://x/mcp"}, caps)
|
|
assert set(called) == {"prompts", "resources"}
|
|
|
|
|
|
class TestStripBearerPrefix:
|
|
"""Pasted tokens that already include ``Bearer `` would otherwise produce
|
|
``Bearer Bearer <jwt>`` once the header template adds its own prefix."""
|
|
|
|
def test_bare_token_unchanged(self):
|
|
from hermes_cli.mcp_config import _strip_bearer_prefix
|
|
|
|
assert _strip_bearer_prefix("eyJabc123") == "eyJabc123"
|
|
|
|
|
|
class TestBearerAuthPersistence:
|
|
def test_secret_and_header_are_persisted_separately(self):
|
|
from hermes_cli.config import get_env_value
|
|
from hermes_cli.mcp_config import _save_bearer_auth_token
|
|
|
|
headers = _save_bearer_auth_token("My Server", "Bearer secret-value")
|
|
|
|
assert headers == {
|
|
"Authorization": "Bearer ${MCP_MY_SERVER_API_KEY}",
|
|
}
|
|
assert get_env_value("MCP_MY_SERVER_API_KEY") == "secret-value"
|
|
|
|
def test_empty_token_is_rejected(self):
|
|
from hermes_cli.mcp_config import _save_bearer_auth_token
|
|
|
|
with pytest.raises(ValueError, match="Bearer token is required"):
|
|
_save_bearer_auth_token("empty", "Bearer ")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: config helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestConfigHelpers:
|
|
def test_save_and_load_mcp_server(self, tmp_path):
|
|
from hermes_cli.mcp_config import _save_mcp_server, _get_mcp_servers
|
|
|
|
_save_mcp_server("mysvr", {"url": "https://example.com/mcp"})
|
|
servers = _get_mcp_servers()
|
|
assert "mysvr" in servers
|
|
assert servers["mysvr"]["url"] == "https://example.com/mcp"
|
|
|
|
|
|
def test_env_key_for_server(self):
|
|
from hermes_cli.mcp_config import _env_key_for_server
|
|
|
|
assert _env_key_for_server("ink") == "MCP_INK_API_KEY"
|
|
assert _env_key_for_server("my-server") == "MCP_MY_SERVER_API_KEY"
|
|
assert _env_key_for_server("my.server") == "MCP_MY_SERVER_API_KEY"
|
|
assert _env_key_for_server("github/mcp") == "MCP_GITHUB_MCP_API_KEY"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: dispatcher
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDispatcher:
|
|
def test_no_action_shows_list(self, tmp_path, capsys):
|
|
from hermes_cli.mcp_config import mcp_command
|
|
|
|
_seed_config(tmp_path, {})
|
|
mcp_command(_make_args(mcp_action=None))
|
|
out = capsys.readouterr().out
|
|
assert "Commands:" in out or "No MCP servers" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: Task 7 consolidation — cmd_mcp_remove evicts manager cache,
|
|
# cmd_mcp_login forces re-auth
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMcpRemoveEvictsManager:
|
|
def test_remove_evicts_in_memory_provider(self, tmp_path, capsys, monkeypatch):
|
|
"""After cmd_mcp_remove, the MCPOAuthManager no longer caches the provider."""
|
|
_seed_config(tmp_path, {
|
|
"oauth-srv": {"url": "https://example.com/mcp", "auth": "oauth"},
|
|
})
|
|
monkeypatch.setattr("builtins.input", lambda _: "y")
|
|
monkeypatch.setattr(
|
|
"hermes_cli.mcp_config.get_hermes_home", lambda: tmp_path
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
_set_interactive_stdin(monkeypatch)
|
|
|
|
from tools.mcp_oauth_manager import get_manager, reset_manager_for_tests
|
|
reset_manager_for_tests()
|
|
|
|
mgr = get_manager()
|
|
mgr.get_or_build_provider(
|
|
"oauth-srv", "https://example.com/mcp", None,
|
|
)
|
|
assert mgr._key("oauth-srv") in mgr._entries
|
|
|
|
from hermes_cli.mcp_config import cmd_mcp_remove
|
|
cmd_mcp_remove(_make_args(name="oauth-srv"))
|
|
|
|
assert mgr._key("oauth-srv") not in mgr._entries
|
|
|
|
|
|
class TestMcpLogin:
|
|
def test_login_rejects_unknown_server(self, tmp_path, capsys):
|
|
_seed_config(tmp_path, {})
|
|
from hermes_cli.mcp_config import cmd_mcp_login
|
|
cmd_mcp_login(_make_args(name="ghost"))
|
|
out = capsys.readouterr().out
|
|
assert "not found" in out
|
|
|
|
|
|
def test_login_false_success_no_token(self, tmp_path, capsys, monkeypatch):
|
|
"""Probe lists tools without auth (Google Drive), but no token landed.
|
|
|
|
The server allows tools/list without auth (DCR 400'd), so the probe
|
|
succeeds yet no OAuth token exists. Login must NOT claim success — it
|
|
should warn and point the user at pre-registered client_id config.
|
|
"""
|
|
_seed_config(tmp_path, {
|
|
"googledrive": {
|
|
"url": "https://drivemcp.googleapis.com/mcp/v1",
|
|
"auth": "oauth",
|
|
},
|
|
})
|
|
# Probe returns tools even though auth never completed.
|
|
monkeypatch.setattr(
|
|
"hermes_cli.mcp_config._probe_single_server",
|
|
lambda name, cfg, connect_timeout=30: [
|
|
("search_files", "d"), ("read_file_content", "d"),
|
|
],
|
|
)
|
|
# No token file is created → _oauth_tokens_present() returns False.
|
|
from hermes_cli.mcp_config import cmd_mcp_login
|
|
|
|
cmd_mcp_login(_make_args(name="googledrive"))
|
|
out = capsys.readouterr().out
|
|
|
|
assert "no OAuth token was obtained" in out
|
|
assert "Authenticated" not in out
|
|
assert "client_id" in out
|
|
|
|
def test_login_genuine_success_with_token(self, tmp_path, capsys, monkeypatch):
|
|
"""Probe lists tools AND a token exists → report real success."""
|
|
_seed_config(tmp_path, {
|
|
"realserver": {"url": "https://mcp.example.com/mcp", "auth": "oauth"},
|
|
})
|
|
token_dir = tmp_path / "mcp-tokens"
|
|
|
|
# cmd_mcp_login wipes tokens before probing, then the real OAuth flow
|
|
# writes a fresh token during the probe. Simulate that: the mocked
|
|
# probe drops a token file, mirroring a successful authorization.
|
|
seen = {}
|
|
|
|
def mock_probe(name, cfg, connect_timeout=30):
|
|
seen["connect_timeout"] = connect_timeout
|
|
token_dir.mkdir(exist_ok=True)
|
|
(token_dir / "realserver.json").write_text('{"access_token": "x"}', encoding="utf-8")
|
|
return [("a", "d"), ("b", "d"), ("c", "d")]
|
|
|
|
monkeypatch.setattr(
|
|
"hermes_cli.mcp_config._probe_single_server", mock_probe
|
|
)
|
|
|
|
from hermes_cli.mcp_config import cmd_mcp_login
|
|
|
|
cmd_mcp_login(_make_args(name="realserver"))
|
|
out = capsys.readouterr().out
|
|
|
|
assert "Authenticated — 3 tool(s) available" in out
|
|
assert "no OAuth token" not in out
|
|
# The login path must grant a human enough time to finish the browser
|
|
# OAuth round-trip — far longer than the 30s probe default.
|
|
assert seen["connect_timeout"] >= 180
|
|
|
|
def test_login_clears_tokens_but_keeps_discovered_server_metadata(self, tmp_path, capsys, monkeypatch):
|
|
"""Re-login wipes the stale grant and client registration but spares ``.meta.json``: when the
|
|
authorization server's metadata document cannot be re-fetched (a WAF-fronted split-host
|
|
server), the cached ``authorization_endpoint`` is what keeps the announced authorize URL off
|
|
the SDK's ``{mcp-origin}/authorize`` guess (#115329)."""
|
|
_seed_config(tmp_path, {
|
|
"tv": {"url": "https://mcp.example.com/mcp", "auth": "oauth"},
|
|
})
|
|
token_dir = tmp_path / "mcp-tokens"
|
|
token_dir.mkdir()
|
|
(token_dir / "tv.json").write_text('{"access_token": "stale"}', encoding="utf-8")
|
|
(token_dir / "tv.client.json").write_text('{"client_id": "old"}', encoding="utf-8")
|
|
(token_dir / "tv.meta.json").write_text(
|
|
'{"issuer": "https://www.example.com", "authorization_endpoint": "https://www.example.com/oauth/authorize",'
|
|
' "token_endpoint": "https://www.example.com/oauth/token"}', encoding="utf-8")
|
|
state_at_probe = {}
|
|
|
|
def mock_probe(name, cfg, connect_timeout=30):
|
|
state_at_probe.update({p.name: p.exists() for p in token_dir.glob("tv*")})
|
|
state_at_probe["meta"] = (token_dir / "tv.meta.json").exists()
|
|
(token_dir / "tv.json").write_text('{"access_token": "fresh"}', encoding="utf-8")
|
|
return [("a", "d")]
|
|
|
|
monkeypatch.setattr("hermes_cli.mcp_config._probe_single_server", mock_probe)
|
|
from hermes_cli.mcp_config import cmd_mcp_login
|
|
|
|
cmd_mcp_login(_make_args(name="tv"))
|
|
|
|
assert state_at_probe["meta"] is True
|
|
assert state_at_probe.get("tv.json") is None and state_at_probe.get("tv.client.json") is None
|
|
assert "Authenticated — 1 tool(s) available" in capsys.readouterr().out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: cmd_mcp_reauth (GH#36767)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMcpReauth:
|
|
def test_reauth_all_visits_only_oauth_servers_in_order(
|
|
self, tmp_path, capsys, monkeypatch
|
|
):
|
|
"""--all re-auths every oauth server (skipping non-oauth), serially."""
|
|
_seed_config(tmp_path, {
|
|
"gh": {"url": "https://gh.example.com/mcp", "auth": "oauth"},
|
|
"jira": {"url": "https://jira.example.com/mcp", "auth": "oauth"},
|
|
"localstdio": {"command": "foo"}, # no url / no oauth → skipped
|
|
"apikey": {"url": "https://k.example.com/mcp", "headers": {"x": "y"}},
|
|
})
|
|
visited = []
|
|
monkeypatch.setattr(
|
|
"hermes_cli.mcp_config._reauth_oauth_server",
|
|
lambda name, cfg: visited.append(name) or True,
|
|
)
|
|
from hermes_cli.mcp_config import cmd_mcp_reauth
|
|
|
|
cmd_mcp_reauth(_make_args(name=None, all=True))
|
|
out = capsys.readouterr().out
|
|
|
|
assert visited == ["gh", "jira"]
|
|
assert "Re-authenticated 2/2 server(s)" in out
|
|
|
|
def test_reauth_all_reports_partial_failures(self, tmp_path, capsys, monkeypatch):
|
|
"""A server that fails to re-auth is counted but doesn't abort the rest."""
|
|
_seed_config(tmp_path, {
|
|
"a": {"url": "https://a.example.com/mcp", "auth": "oauth"},
|
|
"b": {"url": "https://b.example.com/mcp", "auth": "oauth"},
|
|
})
|
|
monkeypatch.setattr(
|
|
"hermes_cli.mcp_config._reauth_oauth_server",
|
|
lambda name, cfg: name == "a", # only 'a' succeeds
|
|
)
|
|
from hermes_cli.mcp_config import cmd_mcp_reauth
|
|
|
|
cmd_mcp_reauth(_make_args(name=None, all=True))
|
|
out = capsys.readouterr().out
|
|
|
|
assert "Re-authenticated 1/2 server(s)" in out
|
|
|
|
|
|
def test_reauth_unknown_server(self, tmp_path, capsys):
|
|
_seed_config(tmp_path, {
|
|
"gh": {"url": "https://gh.example.com/mcp", "auth": "oauth"},
|
|
})
|
|
from hermes_cli.mcp_config import cmd_mcp_reauth
|
|
|
|
cmd_mcp_reauth(_make_args(name="ghost", all=False))
|
|
out = capsys.readouterr().out
|
|
assert "not found" in out
|
|
|
|
|
|
def test_tool_filters_keeps_explicit_empty_include():
|
|
"""``include: []`` (block-all, as written by an all-unchecked picker) is a filter, not
|
|
"no filter"; only an absent/non-list key is None (#12865)."""
|
|
from hermes_cli.mcp_config import _tool_filters
|
|
|
|
assert _tool_filters({"tools": {"include": []}}) == ([], None)
|
|
assert _tool_filters({"tools": {"include": "bad", "exclude": ["x"]}}) == (None, ["x"])
|
|
assert _tool_filters({}) == (None, None)
|