diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index 0a1ca336193..378de219a9a 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -205,6 +205,22 @@ def _probe_single_server( return tools_found +def _oauth_tokens_present(name: str) -> bool: + """Return True if an OAuth token file exists on disk for ``name``. + + Used after ``hermes mcp login`` to distinguish a genuine authentication + from a probe that succeeded only because the server allowed + initialize/tools-list without auth (so no token was ever acquired). + """ + try: + from tools.mcp_oauth import HermesTokenStorage + return HermesTokenStorage(name).has_cached_tokens() + except Exception as exc: # pragma: no cover — defensive + logger.debug("Could not check OAuth tokens for '%s': %s", name, exc) + # Be permissive on unexpected errors: don't block a real success. + return True + + def _unwrap_exception_group(exc: BaseException) -> Exception: """Extract the root-cause exception from anyio TaskGroup wrappers. @@ -631,6 +647,36 @@ def cmd_mcp_login(args): # Probe triggers the OAuth flow (browser redirect + callback capture). try: tools = _probe_single_server(name, server_config) + # A clean probe is NOT proof of authentication. Some MCP servers + # (notably Google's official Drive server) serve initialize + + # tools/list WITHOUT auth, so the probe lists tools even when the + # OAuth flow never completed — e.g. dynamic client registration + # 400'd because the provider doesn't support RFC 7591. Reporting + # "Authenticated — N tools" in that case is a false success: every + # real tool call later hangs until timeout because there's no token. + # Verify a token actually landed on disk before claiming success. + if not _oauth_tokens_present(name): + _warning( + "Server responded, but no OAuth token was obtained — " + "authentication did not complete." + ) + print() + _info( + "Some providers (e.g. Google Drive, Atlassian) do not support " + "automatic client registration. For those you must create an " + "OAuth client yourself and add its credentials to config.yaml:" + ) + print() + print(color(f" mcp_servers:", Colors.DIM)) + print(color(f" {name}:", Colors.DIM)) + print(color(f" url: {url}", Colors.DIM)) + print(color(f" auth: oauth", Colors.DIM)) + print(color(f" oauth:", Colors.DIM)) + print(color(f" client_id: \"\"", Colors.DIM)) + print(color(f" client_secret: \"\"", Colors.DIM)) + print() + _info("Then re-run `hermes mcp login " + name + "`.") + return if tools: _success(f"Authenticated — {len(tools)} tool(s) available") else: diff --git a/tests/hermes_cli/test_mcp_config.py b/tests/hermes_cli/test_mcp_config.py index ac080afd028..d52d3ed0e14 100644 --- a/tests/hermes_cli/test_mcp_config.py +++ b/tests/hermes_cli/test_mcp_config.py @@ -595,3 +595,58 @@ class TestMcpLogin: out = capsys.readouterr().out assert "no URL" in out or "not an OAuth" 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: [("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. + def mock_probe(name, cfg): + token_dir.mkdir(exist_ok=True) + (token_dir / "realserver.json").write_text('{"access_token": "x"}') + 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 + diff --git a/website/docs/user-guide/features/mcp.md b/website/docs/user-guide/features/mcp.md index 071a97c3194..c2232f11c1a 100644 --- a/website/docs/user-guide/features/mcp.md +++ b/website/docs/user-guide/features/mcp.md @@ -229,6 +229,20 @@ On first connect, Hermes prints an authorize URL, opens your browser when possib See [OAuth over SSH / Remote Hosts](../../guides/oauth-over-ssh.md#mcp-servers) for the full walkthrough, including DCR-less servers (e.g. Slack), pre-registered `client_id`/`client_secret`, scope customization, and re-auth via `hermes mcp login `. +**Pitfall — providers that don't support automatic registration (Google Drive, Atlassian).** Some servers reject the dynamic client registration step (RFC 7591) that bare `auth: oauth` relies on — Google's official Drive server (`https://drivemcp.googleapis.com/mcp/v1`) returns a `400 Bad Request`, so no OAuth client is created and no token is acquired. The symptom is subtle: these servers also serve `tools/list` *without* auth, so `hermes mcp login` can list the tools and look like it worked, but every real tool call later times out. `hermes mcp login` now detects this (it checks that a token actually landed on disk) and tells you to supply your own OAuth client. Create one in the provider's console and add it to config: + +```yaml +mcp_servers: + googledrive: + url: "https://drivemcp.googleapis.com/mcp/v1" + auth: oauth + oauth: + client_id: "" + client_secret: "" +``` + +Then run `hermes mcp login googledrive` — with the pre-registered client, Hermes skips registration and runs the normal browser authorization flow. + **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 ` from a fresh terminal — it waits the full 5 minutes for you to complete auth. ## Basic configuration reference