mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 23:11:37 +08:00
142 lines
4.8 KiB
Python
142 lines
4.8 KiB
Python
|
|
"""Tests for the MCP OAuth manager (tools/mcp_oauth_manager.py).
|
||
|
|
|
||
|
|
The manager consolidates the eight scattered MCP-OAuth call sites into a
|
||
|
|
single object with disk-mtime watch, dedup'd 401 handling, and a provider
|
||
|
|
cache. See `tools/mcp_oauth_manager.py` for design rationale.
|
||
|
|
"""
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import time
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
pytest.importorskip(
|
||
|
|
"mcp.client.auth.oauth2",
|
||
|
|
reason="MCP SDK 1.26.0+ required for OAuth support",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def test_manager_is_singleton():
|
||
|
|
"""get_manager() returns the same instance across calls."""
|
||
|
|
from tools.mcp_oauth_manager import get_manager, reset_manager_for_tests
|
||
|
|
reset_manager_for_tests()
|
||
|
|
m1 = get_manager()
|
||
|
|
m2 = get_manager()
|
||
|
|
assert m1 is m2
|
||
|
|
|
||
|
|
|
||
|
|
def test_manager_get_or_build_provider_caches(tmp_path, monkeypatch):
|
||
|
|
"""Calling get_or_build_provider twice with same name returns same provider."""
|
||
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||
|
|
from tools.mcp_oauth_manager import MCPOAuthManager
|
||
|
|
|
||
|
|
mgr = MCPOAuthManager()
|
||
|
|
p1 = mgr.get_or_build_provider("srv", "https://example.com/mcp", None)
|
||
|
|
p2 = mgr.get_or_build_provider("srv", "https://example.com/mcp", None)
|
||
|
|
assert p1 is p2
|
||
|
|
|
||
|
|
|
||
|
|
def test_manager_get_or_build_rebuilds_on_url_change(tmp_path, monkeypatch):
|
||
|
|
"""Changing the URL discards the cached provider."""
|
||
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||
|
|
from tools.mcp_oauth_manager import MCPOAuthManager
|
||
|
|
|
||
|
|
mgr = MCPOAuthManager()
|
||
|
|
p1 = mgr.get_or_build_provider("srv", "https://a.example.com/mcp", None)
|
||
|
|
p2 = mgr.get_or_build_provider("srv", "https://b.example.com/mcp", None)
|
||
|
|
assert p1 is not p2
|
||
|
|
|
||
|
|
|
||
|
|
def test_manager_remove_evicts_cache(tmp_path, monkeypatch):
|
||
|
|
"""remove(name) evicts the provider from cache AND deletes disk files."""
|
||
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||
|
|
from tools.mcp_oauth_manager import MCPOAuthManager
|
||
|
|
|
||
|
|
# Pre-seed tokens on disk
|
||
|
|
token_dir = tmp_path / "mcp-tokens"
|
||
|
|
token_dir.mkdir(parents=True)
|
||
|
|
(token_dir / "srv.json").write_text(json.dumps({
|
||
|
|
"access_token": "TOK",
|
||
|
|
"token_type": "Bearer",
|
||
|
|
}))
|
||
|
|
|
||
|
|
mgr = MCPOAuthManager()
|
||
|
|
p1 = mgr.get_or_build_provider("srv", "https://example.com/mcp", None)
|
||
|
|
assert p1 is not None
|
||
|
|
assert (token_dir / "srv.json").exists()
|
||
|
|
|
||
|
|
mgr.remove("srv")
|
||
|
|
|
||
|
|
assert not (token_dir / "srv.json").exists()
|
||
|
|
p2 = mgr.get_or_build_provider("srv", "https://example.com/mcp", None)
|
||
|
|
assert p1 is not p2
|
||
|
|
|
||
|
|
|
||
|
|
def test_hermes_provider_subclass_exists():
|
||
|
|
"""HermesMCPOAuthProvider is defined and subclasses OAuthClientProvider."""
|
||
|
|
from tools.mcp_oauth_manager import _HERMES_PROVIDER_CLS
|
||
|
|
from mcp.client.auth.oauth2 import OAuthClientProvider
|
||
|
|
|
||
|
|
assert _HERMES_PROVIDER_CLS is not None
|
||
|
|
assert issubclass(_HERMES_PROVIDER_CLS, OAuthClientProvider)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_disk_watch_invalidates_on_mtime_change(tmp_path, monkeypatch):
|
||
|
|
"""When the tokens file mtime changes, provider._initialized flips False.
|
||
|
|
|
||
|
|
This is the behaviour Claude Code ships as
|
||
|
|
invalidateOAuthCacheIfDiskChanged (CC-1096 / GH#24317) and is the core
|
||
|
|
fix for Cthulhu's external-cron refresh workflow.
|
||
|
|
"""
|
||
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||
|
|
from tools.mcp_oauth_manager import MCPOAuthManager, reset_manager_for_tests
|
||
|
|
|
||
|
|
reset_manager_for_tests()
|
||
|
|
|
||
|
|
token_dir = tmp_path / "mcp-tokens"
|
||
|
|
token_dir.mkdir(parents=True)
|
||
|
|
tokens_file = token_dir / "srv.json"
|
||
|
|
tokens_file.write_text(json.dumps({
|
||
|
|
"access_token": "OLD",
|
||
|
|
"token_type": "Bearer",
|
||
|
|
}))
|
||
|
|
|
||
|
|
mgr = MCPOAuthManager()
|
||
|
|
provider = mgr.get_or_build_provider("srv", "https://example.com/mcp", None)
|
||
|
|
assert provider is not None
|
||
|
|
|
||
|
|
# First call: records mtime (zero -> real) -> returns True
|
||
|
|
changed1 = await mgr.invalidate_if_disk_changed("srv")
|
||
|
|
assert changed1 is True
|
||
|
|
|
||
|
|
# No file change -> False
|
||
|
|
changed2 = await mgr.invalidate_if_disk_changed("srv")
|
||
|
|
assert changed2 is False
|
||
|
|
|
||
|
|
# Touch file with a newer mtime
|
||
|
|
future_mtime = time.time() + 10
|
||
|
|
os.utime(tokens_file, (future_mtime, future_mtime))
|
||
|
|
|
||
|
|
changed3 = await mgr.invalidate_if_disk_changed("srv")
|
||
|
|
assert changed3 is True
|
||
|
|
# _initialized flipped — next async_auth_flow will re-read from disk
|
||
|
|
assert provider._initialized is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_manager_builds_hermes_provider_subclass(tmp_path, monkeypatch):
|
||
|
|
"""get_or_build_provider returns HermesMCPOAuthProvider, not plain OAuthClientProvider."""
|
||
|
|
from tools.mcp_oauth_manager import (
|
||
|
|
MCPOAuthManager, _HERMES_PROVIDER_CLS, reset_manager_for_tests,
|
||
|
|
)
|
||
|
|
reset_manager_for_tests()
|
||
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||
|
|
|
||
|
|
mgr = MCPOAuthManager()
|
||
|
|
provider = mgr.get_or_build_provider("srv", "https://example.com/mcp", None)
|
||
|
|
|
||
|
|
assert _HERMES_PROVIDER_CLS is not None
|
||
|
|
assert isinstance(provider, _HERMES_PROVIDER_CLS)
|
||
|
|
assert provider._hermes_server_name == "srv"
|
||
|
|
|