Skip to content
6 changes: 6 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5904,6 +5904,12 @@ def cmd_tools(args):
mcp_cfg_p = mcp_sub.add_parser("configure", aliases=["config"], help="Toggle tool selection")
mcp_cfg_p.add_argument("name", help="Server name to configure")

mcp_login_p = mcp_sub.add_parser(
"login",
help="Force re-authentication for an OAuth-based MCP server",
)
mcp_login_p.add_argument("name", help="Server name to re-authenticate")

def cmd_mcp(args):
from hermes_cli.mcp_config import mcp_command
mcp_command(args)
Expand Down
71 changes: 66 additions & 5 deletions hermes_cli/mcp_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,8 @@ def cmd_mcp_add(args):
_info(f"Starting OAuth flow for '{name}'...")
oauth_ok = False
try:
from tools.mcp_oauth import build_oauth_auth
oauth_auth = build_oauth_auth(name, url)
from tools.mcp_oauth_manager import get_manager
oauth_auth = get_manager().get_or_build_provider(name, url, None)
if oauth_auth:
server_config["auth"] = "oauth"
_success("OAuth configured (tokens will be acquired on first connection)")
Expand Down Expand Up @@ -428,10 +428,12 @@ def cmd_mcp_remove(args):
_remove_mcp_server(name)
_success(f"Removed '{name}' from config")

# Clean up OAuth tokens if they exist
# Clean up OAuth tokens if they exist — route through MCPOAuthManager so
# any provider instance cached in the current process (e.g. from an
# earlier `hermes mcp test` in the same session) is evicted too.
try:
from tools.mcp_oauth import remove_oauth_tokens
remove_oauth_tokens(name)
from tools.mcp_oauth_manager import get_manager
get_manager().remove(name)
_success("Cleaned up OAuth tokens")
except Exception:
pass
Expand Down Expand Up @@ -577,6 +579,63 @@ def _replace(m):
return re.sub(r"\$\{(\w+)\}", _replace, value)


# ─── hermes mcp login ────────────────────────────────────────────────────────

def cmd_mcp_login(args):
"""Force re-authentication for an OAuth-based MCP server.

Deletes cached tokens (both on disk and in the running process's
MCPOAuthManager cache) and triggers a fresh OAuth flow via the
existing probe path.

Use this when:
- Tokens are stuck in a bad state (server revoked, refresh token
consumed by an external process, etc.)
- You want to re-authenticate to change scopes or account
- A tool call returned ``needs_reauth: true``
"""
name = args.name
servers = _get_mcp_servers()

if name not in servers:
_error(f"Server '{name}' not found in config.")
if servers:
_info(f"Available servers: {', '.join(servers)}")
return

server_config = servers[name]
url = server_config.get("url")
if not url:
_error(f"Server '{name}' has no URL — not an OAuth-capable server")
return
if server_config.get("auth") != "oauth":
_error(f"Server '{name}' is not configured for OAuth (auth={server_config.get('auth')})")
_info("Use `hermes mcp remove` + `hermes mcp add` to reconfigure auth.")
return

# Wipe both disk and in-memory cache so the next probe forces a fresh
# OAuth flow.
try:
from tools.mcp_oauth_manager import get_manager
mgr = get_manager()
mgr.remove(name)
except Exception as exc:
_warning(f"Could not clear existing OAuth state: {exc}")

print()
_info(f"Starting OAuth flow for '{name}'...")

# Probe triggers the OAuth flow (browser redirect + callback capture).
try:
tools = _probe_single_server(name, server_config)
if tools:
_success(f"Authenticated — {len(tools)} tool(s) available")
else:
_success("Authenticated (server reported no tools)")
except Exception as exc:
_error(f"Authentication failed: {exc}")


# ─── hermes mcp configure ────────────────────────────────────────────────────

def cmd_mcp_configure(args):
Expand Down Expand Up @@ -696,6 +755,7 @@ def mcp_command(args):
"test": cmd_mcp_test,
"configure": cmd_mcp_configure,
"config": cmd_mcp_configure,
"login": cmd_mcp_login,
}

handler = handlers.get(action)
Expand All @@ -713,4 +773,5 @@ def mcp_command(args):
_info("hermes mcp list List servers")
_info("hermes mcp test <name> Test connection")
_info("hermes mcp configure <name> Toggle tools")
_info("hermes mcp login <name> Re-authenticate OAuth")
print()
61 changes: 61 additions & 0 deletions tests/hermes_cli/test_mcp_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -539,3 +539,64 @@ def test_no_action_shows_list(self, tmp_path, capsys):
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))

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 "oauth-srv" in mgr._entries

from hermes_cli.mcp_config import cmd_mcp_remove
cmd_mcp_remove(_make_args(name="oauth-srv"))

assert "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_rejects_non_oauth_server(self, tmp_path, capsys):
_seed_config(tmp_path, {
"srv": {"url": "https://example.com/mcp", "auth": "header"},
})
from hermes_cli.mcp_config import cmd_mcp_login
cmd_mcp_login(_make_args(name="srv"))
out = capsys.readouterr().out
assert "not configured for OAuth" in out

def test_login_rejects_stdio_server(self, tmp_path, capsys):
_seed_config(tmp_path, {
"srv": {"command": "npx", "args": ["some-server"]},
})
from hermes_cli.mcp_config import cmd_mcp_login
cmd_mcp_login(_make_args(name="srv"))
out = capsys.readouterr().out
assert "no URL" in out or "not an OAuth" in out

68 changes: 68 additions & 0 deletions tests/tools/test_mcp_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,3 +431,71 @@ def test_noninteractive_with_cached_tokens_no_warning(self, tmp_path, monkeypatc

assert auth is not None
assert "no cached tokens found" not in caplog.text.lower()


# ---------------------------------------------------------------------------
# Extracted helper tests (Task 3 of MCP OAuth consolidation)
# ---------------------------------------------------------------------------


def test_build_client_metadata_basic():
"""_build_client_metadata returns metadata with expected defaults."""
from tools.mcp_oauth import _build_client_metadata, _configure_callback_port

cfg = {"client_name": "Test Client"}
_configure_callback_port(cfg)
md = _build_client_metadata(cfg)

assert md.client_name == "Test Client"
assert "authorization_code" in md.grant_types
assert "refresh_token" in md.grant_types


def test_build_client_metadata_without_secret_is_public():
"""Without client_secret, token endpoint auth is 'none' (public client)."""
from tools.mcp_oauth import _build_client_metadata, _configure_callback_port

cfg = {}
_configure_callback_port(cfg)
md = _build_client_metadata(cfg)
assert md.token_endpoint_auth_method == "none"


def test_build_client_metadata_with_secret_is_confidential():
"""With client_secret, token endpoint auth is 'client_secret_post'."""
from tools.mcp_oauth import _build_client_metadata, _configure_callback_port

cfg = {"client_secret": "shh"}
_configure_callback_port(cfg)
md = _build_client_metadata(cfg)
assert md.token_endpoint_auth_method == "client_secret_post"


def test_configure_callback_port_picks_free_port():
"""_configure_callback_port(0) picks a free port in the ephemeral range."""
from tools.mcp_oauth import _configure_callback_port

cfg = {"redirect_port": 0}
port = _configure_callback_port(cfg)
assert 1024 < port < 65536
assert cfg["_resolved_port"] == port


def test_configure_callback_port_uses_explicit_port():
"""An explicit redirect_port is preserved."""
from tools.mcp_oauth import _configure_callback_port

cfg = {"redirect_port": 54321}
port = _configure_callback_port(cfg)
assert port == 54321
assert cfg["_resolved_port"] == 54321


def test_parse_base_url_strips_path():
"""_parse_base_url drops path components for OAuth discovery."""
from tools.mcp_oauth import _parse_base_url

assert _parse_base_url("https://example.com/mcp/v1") == "https://example.com"
assert _parse_base_url("https://example.com") == "https://example.com"
assert _parse_base_url("https://host.example.com:8080/api") == "https://host.example.com:8080"

Loading
Loading