From ad00777f042d9c2ca23f1575ef1036a5b59d6195 Mon Sep 17 00:00:00 2001 From: EloquentBrush0x <283442588+EloquentBrush0x@users.noreply.github.com> Date: Sat, 16 May 2026 03:28:52 +0300 Subject: [PATCH] fix(mcp-oauth): print SSH tunnel hint in _redirect_handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Hermes runs on a remote host over SSH, MCP OAuth loopback flows silently fail: the OAuth provider redirects the user's browser to http://127.0.0.1:/callback, which reaches the callback server on the *remote* machine — not the local machine where the browser is running. _redirect_handler already detected SSH (via _can_open_browser) and printed "Headless environment detected — open the URL manually." but gave no guidance on how to actually reach the callback server. Users got silent timeouts or "Could not establish connection" errors. This is the same bug fixed for xAI-oauth and Spotify in #26592, which added _print_loopback_ssh_hint() in hermes_cli/auth.py. mcp_oauth.py uses the identical loopback callback pattern (http://127.0.0.1:/callback via _configure_callback_port / _wait_for_callback) but was missing the hint. Fix: when SSH_CLIENT or SSH_TTY is set and _oauth_port is available, print the ssh -N -L port-forward command and the OAuth-over-SSH guide URL to stderr, consistent with the rest of _redirect_handler's output. Tests: 4 new cases in TestRedirectHandlerSshHint covering SSH_CLIENT, SSH_TTY, local session (no hint), and missing _oauth_port (no hint). --- tests/tools/test_mcp_oauth.py | 61 +++++++++++++++++++++++++++++++++++ tools/mcp_oauth.py | 17 ++++++++++ 2 files changed, 78 insertions(+) diff --git a/tests/tools/test_mcp_oauth.py b/tests/tools/test_mcp_oauth.py index 2dfebd80b9c..e12149a45d3 100644 --- a/tests/tools/test_mcp_oauth.py +++ b/tests/tools/test_mcp_oauth.py @@ -10,6 +10,8 @@ from unittest.mock import patch, MagicMock, AsyncMock import pytest +import asyncio + from tools.mcp_oauth import ( HermesTokenStorage, OAuthNonInteractiveError, @@ -20,6 +22,7 @@ from tools.mcp_oauth import ( _is_interactive, _wait_for_callback, _make_callback_handler, + _redirect_handler, ) @@ -241,6 +244,64 @@ class TestUtilities: assert _can_open_browser() is True +class TestRedirectHandlerSshHint: + """_redirect_handler must print an SSH tunnel hint on remote sessions.""" + + def _run(self, coro): + return asyncio.get_event_loop().run_until_complete(coro) + + def test_ssh_hint_shown_on_ssh_session(self, monkeypatch, capsys): + import tools.mcp_oauth as mco + monkeypatch.setattr(mco, "_oauth_port", 49200) + monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 1234 22") + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.setattr(mco, "_can_open_browser", lambda: False) + + self._run(_redirect_handler("https://example.com/auth?foo=bar")) + + err = capsys.readouterr().err + assert "49200" in err + assert "ssh -N -L" in err + assert "Remote session detected" in err + + def test_ssh_hint_shown_via_ssh_tty(self, monkeypatch, capsys): + import tools.mcp_oauth as mco + monkeypatch.setattr(mco, "_oauth_port", 49201) + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.setenv("SSH_TTY", "/dev/pts/1") + monkeypatch.setattr(mco, "_can_open_browser", lambda: False) + + self._run(_redirect_handler("https://example.com/auth")) + + err = capsys.readouterr().err + assert "49201" in err + assert "ssh -N -L" in err + + def test_no_ssh_hint_on_local_session(self, monkeypatch, capsys): + import tools.mcp_oauth as mco + monkeypatch.setattr(mco, "_oauth_port", 49202) + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.setattr(mco, "_can_open_browser", lambda: True) + monkeypatch.setattr("webbrowser.open", lambda url, **kw: True) + + self._run(_redirect_handler("https://example.com/auth")) + + err = capsys.readouterr().err + assert "ssh -N -L" not in err + + def test_no_ssh_hint_when_port_not_set(self, monkeypatch, capsys): + import tools.mcp_oauth as mco + monkeypatch.setattr(mco, "_oauth_port", None) + monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 1234 22") + monkeypatch.setattr(mco, "_can_open_browser", lambda: False) + + self._run(_redirect_handler("https://example.com/auth")) + + err = capsys.readouterr().err + assert "ssh -N -L" not in err + + # --------------------------------------------------------------------------- # Path traversal protection # --------------------------------------------------------------------------- diff --git a/tools/mcp_oauth.py b/tools/mcp_oauth.py index d7bf135da47..8d48eedf0e8 100644 --- a/tools/mcp_oauth.py +++ b/tools/mcp_oauth.py @@ -401,6 +401,23 @@ async def _redirect_handler(authorization_url: str) -> None: ) print(msg, file=sys.stderr) + # On a remote SSH session the OAuth provider redirects to + # http://127.0.0.1:/callback, which reaches the callback server on + # the *remote* machine — not the user's local machine where the browser + # opened. Print a port-forward hint so the user knows to tunnel first. + if _oauth_port and (os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY")): + print( + f" Remote session detected. The OAuth provider will redirect your browser to\n" + f" http://127.0.0.1:{_oauth_port}/callback\n" + f" which the callback listener on THIS machine is waiting on. If your browser\n" + f" is on a different machine, forward the port first in a separate terminal:\n" + f"\n" + f" ssh -N -L {_oauth_port}:127.0.0.1:{_oauth_port} @\n" + f"\n" + f" Then open the URL above. See: https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh\n", + file=sys.stderr, + ) + if _can_open_browser(): try: opened = webbrowser.open(authorization_url)