Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions tests/tools/test_mcp_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
_make_callback_handler,
_redirect_handler,
_paste_callback_reader,
_redirect_host,
)


Expand Down Expand Up @@ -214,6 +215,53 @@ def test_scope_passed_through(self, tmp_path, monkeypatch):
assert provider is not None
assert provider.context.client_metadata.scope == "read write admin"

def test_redirect_uri_defaults_to_localhost(self, tmp_path, monkeypatch):
"""redirect_uri must use the ``localhost`` hostname by default, not a
raw ``127.0.0.1`` IP literal. Some providers front the authorize
endpoint with a WAF (e.g. Motion's Azure Application Gateway) that
403s a loopback-IP redirect_uri but allows the hostname."""
try:
from mcp.client.auth import OAuthClientProvider # noqa: F401
except ImportError:
pytest.skip("MCP SDK auth not available")

monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("HERMES_MCP_OAUTH_REDIRECT_HOST", raising=False)
_set_interactive_stdin(monkeypatch)
provider = build_oauth_auth("wafhost", "https://example.com/mcp")
assert provider is not None
uris = [str(u) for u in provider.context.client_metadata.redirect_uris]
assert uris, "expected at least one redirect_uri"
for uri in uris:
assert "127.0.0.1" not in uri
assert "localhost" in uri


class TestRedirectHost:
def test_default_is_localhost(self, monkeypatch):
# Default (no per-server override) resolves to the module default,
# which ships as "localhost".
monkeypatch.setattr(
"tools.mcp_oauth._DEFAULT_OAUTH_REDIRECT_HOST", "localhost"
)
assert _redirect_host() == "localhost"
assert _redirect_host({}) == "localhost"
assert _redirect_host({"other": "x"}) == "localhost"

def test_per_server_override_wins(self):
assert _redirect_host({"redirect_host": "127.0.0.1"}) == "127.0.0.1"
assert _redirect_host({"redirect_host": "my.host"}) == "my.host"

def test_module_default_honours_env(self, monkeypatch):
# The module default is read from HERMES_MCP_OAUTH_REDIRECT_HOST at
# import. Simulate an operator override and confirm resolution + that
# per-server config still takes precedence over it.
monkeypatch.setattr(
"tools.mcp_oauth._DEFAULT_OAUTH_REDIRECT_HOST", "0.0.0.0"
)
assert _redirect_host() == "0.0.0.0"
assert _redirect_host({"redirect_host": "localhost"}) == "localhost"


# ---------------------------------------------------------------------------
# Utility functions
Expand Down
32 changes: 30 additions & 2 deletions tools/mcp_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,34 @@ class OAuthNonInteractiveError(RuntimeError):
)


# Loopback host used in the OAuth redirect_uri advertised to the provider.
# The callback HTTP server always binds 127.0.0.1 (see _find_free_port /
# _wait_for_callback); this only controls the *string* sent to the auth
# server. Some providers front their authorize endpoint with a WAF (observed
# with Motion's Azure Application Gateway) that 403s any redirect_uri holding
# a raw loopback IP literal (``127.0.0.1``) while allowing the ``localhost``
# hostname -- which resolves back to 127.0.0.1, so the local listener still
# receives the callback. Default to ``localhost`` for maximum compatibility;
# override per-server with ``oauth.redirect_host`` in config.yaml, or globally
# with HERMES_MCP_OAUTH_REDIRECT_HOST.
_DEFAULT_OAUTH_REDIRECT_HOST = os.getenv(
"HERMES_MCP_OAUTH_REDIRECT_HOST", "localhost"
)


def _redirect_host(cfg: "dict | None" = None) -> str:
"""Resolve the loopback host for the OAuth redirect_uri.

Precedence: per-server ``cfg['redirect_host']`` (from the ``oauth:`` block)
> HERMES_MCP_OAUTH_REDIRECT_HOST env > ``localhost`` default.
"""
if cfg:
host = cfg.get("redirect_host")
if host:
return str(host)
return _DEFAULT_OAUTH_REDIRECT_HOST


# Skip tokens accepted at the paste prompt — exit OAuth without auth.
_SKIP_TOKENS = frozenset({"skip", "cancel", "s", "n", "no", "q", "quit"})

Expand Down Expand Up @@ -846,7 +874,7 @@ def _build_client_metadata(cfg: dict) -> "OAuthClientMetadata":
)
client_name = cfg.get("client_name", "Hermes Agent")
scope = cfg.get("scope")
redirect_uri = f"http://127.0.0.1:{port}/callback"
redirect_uri = f"http://{_redirect_host(cfg)}:{port}/callback"

metadata_kwargs: dict[str, Any] = {
"client_name": client_name,
Expand All @@ -873,7 +901,7 @@ def _maybe_preregister_client(
if not client_id:
return
port = cfg["_resolved_port"]
redirect_uri = f"http://127.0.0.1:{port}/callback"
redirect_uri = f"http://{_redirect_host(cfg)}:{port}/callback"

info_dict: dict[str, Any] = {
"client_id": client_id,
Expand Down
13 changes: 13 additions & 0 deletions website/docs/user-guide/features/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,19 @@ Then run `hermes mcp login googledrive` — with the pre-registered client, Herm

**Pitfall — config auto-reload race.** When you edit `~/.hermes/config.yaml` from inside a running Hermes session, the CLI auto-reloads MCP connections with a 30s timeout. That's not enough for an interactive OAuth flow. Add the entry, then run `hermes mcp login <server>` from a fresh terminal — it waits the full 5 minutes for you to complete auth.

**Pitfall — provider WAF rejects the loopback-IP redirect (`403 Forbidden`).** The OAuth `redirect_uri` Hermes advertises defaults to `http://localhost:<port>/callback`. Some providers front their authorize endpoint with a Web Application Firewall (observed with Motion's `projects.motionapp.com`, served by Microsoft Azure Application Gateway) whose ruleset **403s any `redirect_uri` containing a raw loopback IP literal (`127.0.0.1`)** while allowing the `localhost` hostname. The symptom is a bare `403 Forbidden` page in the browser *before* any login/consent screen — it looks like a permissions problem but isn't. `localhost` is the default precisely because it clears these rules (it resolves back to `127.0.0.1`, so the local callback listener still receives the redirect). If a provider needs the opposite — the raw IP rather than the hostname — override it per server:

```yaml
mcp_servers:
motion:
url: "https://projects.motionapp.com/mcp"
auth: oauth
oauth:
redirect_host: "127.0.0.1" # default is "localhost"
```

Or globally with the `HERMES_MCP_OAUTH_REDIRECT_HOST` environment variable. After changing it, clear any stale cached client from a prior failed attempt (`rm -f ~/.hermes/mcp-tokens/<server>.client.json`) so the next `hermes mcp login <server>` re-registers with the new redirect URI.

## mTLS / client certificates

Remote HTTP MCP servers that require mutual TLS (client-certificate authentication) are supported via `client_cert` / `client_key`. Hermes passes the resolved certificate to the underlying HTTP client for the TLS handshake.
Expand Down