Files
hermes-agent/tests/tools/test_mcp_schema_cache.py
ethernet e8fcb007b9 Merge remote-tracking branch 'upstream/main' into ethie/pm-clean
# Conflicts:
#	AGENTS.md
#	acp_adapter/edit_approval.py
#	acp_adapter/server.py
#	agent/agent_init.py
#	agent/anthropic_adapter.py
#	agent/anthropic_credentials.py
#	agent/auxiliary_client.py
#	agent/azure_identity_adapter.py
#	agent/bedrock_adapter.py
#	agent/browser_registry.py
#	agent/chat_completion_helpers.py
#	agent/coding_context.py
#	agent/context_references.py
#	agent/conversation_loop.py
#	agent/copilot_acp_client.py
#	agent/credits_tracker.py
#	agent/curator.py
#	agent/curator_backup.py
#	agent/deadline.py
#	agent/display.py
#	agent/errors.py
#	agent/estop.py
#	agent/i18n.py
#	agent/image_gen_registry.py
#	agent/image_routing.py
#	agent/learning_graph.py
#	agent/learning_mutations.py
#	agent/lsp/servers.py
#	agent/model_metadata.py
#	agent/models_dev.py
#	agent/monitoring/gateway_health_export.py
#	agent/monitoring/otlp_exporter.py
#	agent/pet/store.py
#	agent/process_bootstrap.py
#	agent/prompt_builder.py
#	agent/proxy_sources/iron_proxy.py
#	agent/secret_sources/_cache.py
#	agent/secret_sources/bitwarden.py
#	agent/secret_sources/registry.py
#	agent/shell_hooks.py
#	agent/skill_bundles.py
#	agent/skill_commands.py
#	agent/skill_utils.py
#	agent/ssl_guard.py
#	agent/ssl_verify.py
#	agent/system_prompt.py
#	agent/terminal_env_registry.py
#	agent/trace_upload.py
#	agent/transcription_registry.py
#	agent/tts_registry.py
#	agent/verify/environment.py
#	agent/vertex_adapter.py
#	agent/video_gen_registry.py
#	agent/web_search_registry.py
#	cli.py
#	cron/jobs.py
#	cron/scheduler.py
#	gateway/agent_cache_pressure.py
#	gateway/cgroup_cleanup.py
#	gateway/channel_directory.py
#	gateway/config.py
#	gateway/control_socket.py
#	gateway/dead_targets.py
#	gateway/drain_control.py
#	gateway/hooks.py
#	gateway/kanban_watchers.py
#	gateway/lifecycle_ledger.py
#	gateway/mirror.py
#	gateway/pairing.py
#	gateway/platform_registry.py
#	gateway/platforms/helpers.py
#	gateway/platforms/weixin.py
#	gateway/readiness.py
#	gateway/restart_loop_guard.py
#	gateway/rich_sent_store.py
#	gateway/run.py
#	gateway/session.py
#	gateway/shutdown_flush.py
#	gateway/shutdown_forensics.py
#	gateway/slash_commands.py
#	gateway/status.py
#	gateway/sticker_cache.py
#	gateway/whatsapp_identity.py
#	hermes_bootstrap.py
#	hermes_cli/_early_recovery.py
#	hermes_cli/_install_repair.py
#	hermes_cli/_startup_fast.py
#	hermes_cli/_subprocess_compat.py
#	hermes_cli/agent_plugins.py
#	hermes_cli/auth.py
#	hermes_cli/backup.py
#	hermes_cli/banner.py
#	hermes_cli/browser_connect.py
#	hermes_cli/build_info.py
#	hermes_cli/cli_agent_setup_mixin.py
#	hermes_cli/cli_commands_mixin.py
#	hermes_cli/codex_models.py
#	hermes_cli/config.py
#	hermes_cli/config_defaults.py
#	hermes_cli/config_migrations.py
#	hermes_cli/container_boot.py
#	hermes_cli/dashboard_auth/registry.py
#	hermes_cli/debug.py
#	hermes_cli/dep_ensure.py
#	hermes_cli/doctor.py
#	hermes_cli/doctor_live.py
#	hermes_cli/dump.py
#	hermes_cli/env_loader.py
#	hermes_cli/foreign_sessions.py
#	hermes_cli/gateway.py
#	hermes_cli/gateway_windows.py
#	hermes_cli/gui_uninstall.py
#	hermes_cli/image_provenance.py
#	hermes_cli/install_identity.py
#	hermes_cli/kanban.py
#	hermes_cli/kanban_db.py
#	hermes_cli/linux_desktop_entry.py
#	hermes_cli/local_runtime/binaries.py
#	hermes_cli/local_runtime/endpoint.py
#	hermes_cli/local_runtime/growth.py
#	hermes_cli/local_runtime/supervisor.py
#	hermes_cli/logs.py
#	hermes_cli/macos_tcc_anchor.py
#	hermes_cli/main.py
#	hermes_cli/memory_setup.py
#	hermes_cli/model_catalog.py
#	hermes_cli/models.py
#	hermes_cli/nous_subscription.py
#	hermes_cli/npm_engine.py
#	hermes_cli/plugin_index.py
#	hermes_cli/plugins.py
#	hermes_cli/plugins_cmd.py
#	hermes_cli/profile_distribution.py
#	hermes_cli/profiles.py
#	hermes_cli/prompt_size.py
#	hermes_cli/psutil_android.py
#	hermes_cli/runtime_repair.py
#	hermes_cli/security_advisories.py
#	hermes_cli/security_audit.py
#	hermes_cli/security_audit_startup.py
#	hermes_cli/service_manager.py
#	hermes_cli/session_export_md.py
#	hermes_cli/setup.py
#	hermes_cli/skills_hub.py
#	hermes_cli/slack_cli.py
#	hermes_cli/status.py
#	hermes_cli/subcommands/gateway.py
#	hermes_cli/subcommands/uninstall.py
#	hermes_cli/tools_config.py
#	hermes_cli/uninstall.py
#	hermes_cli/update_cmd.py
#	hermes_cli/update_contract.py
#	hermes_cli/update_inventory.py
#	hermes_cli/update_lock.py
#	hermes_cli/update_receipt.py
#	hermes_cli/urllib_security.py
#	hermes_cli/web_routers/local_models.py
#	hermes_cli/web_routers/profiles.py
#	hermes_cli/web_routers/skills.py
#	hermes_cli/web_server.py
#	hermes_constants.py
#	hermes_state.py
#	plugins/disk-cleanup/__init__.py
#	plugins/disk-cleanup/disk_cleanup.py
#	plugins/google_meet/node/registry.py
#	plugins/google_meet/node/server.py
#	plugins/google_meet/process_manager.py
#	plugins/google_meet/realtime/openai_client.py
#	plugins/hermes-achievements/dashboard/plugin_api.py
#	plugins/memory/hindsight/__init__.py
#	plugins/memory/honcho/__init__.py
#	plugins/memory/honcho/cli.py
#	plugins/memory/honcho/client.py
#	plugins/memory/honcho/oauth.py
#	plugins/memory/honcho/session.py
#	plugins/memory/mem0/__init__.py
#	plugins/memory/mem0/_setup.py
#	plugins/memory/openviking/__init__.py
#	plugins/memory/retaindb/__init__.py
#	plugins/memory/supermemory/__init__.py
#	plugins/platforms/a2a/protocol.py
#	plugins/platforms/dingtalk/adapter.py
#	plugins/platforms/discord/adapter.py
#	plugins/platforms/feishu/adapter.py
#	plugins/platforms/google_chat/adapter.py
#	plugins/platforms/matrix/adapter.py
#	plugins/platforms/photon/adapter.py
#	plugins/platforms/photon/auth.py
#	plugins/platforms/photon/cli.py
#	plugins/platforms/slack/adapter.py
#	plugins/platforms/teams/adapter.py
#	plugins/platforms/telegram/adapter.py
#	plugins/platforms/wecom/callback_adapter.py
#	plugins/platforms/whatsapp/adapter.py
#	plugins/teams_pipeline/store.py
#	plugins/video_gen/fal/__init__.py
#	plugins/web/ddgs/provider.py
#	plugins/web/exa/provider.py
#	plugins/web/firecrawl/provider.py
#	plugins/web/parallel/provider.py
#	tests/agent/test_ssl_ca_guard.py
#	tests/hermes_cli/test_certifi_repair.py
#	tests/hermes_cli/test_cmd_update.py
#	tests/hermes_cli/test_cmd_update_apt.py
#	tests/hermes_cli/test_dashboard_unified_launch.py
#	tests/hermes_cli/test_dep_ensure.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_doctor_live.py
#	tests/hermes_cli/test_gui_command.py
#	tests/hermes_cli/test_kanban_boards.py
#	tests/hermes_cli/test_kanban_db.py
#	tests/hermes_cli/test_lazy_refresh_venv_repair.py
#	tests/hermes_cli/test_memory_setup_provider_arg.py
#	tests/hermes_cli/test_nous_subscription.py
#	tests/hermes_cli/test_pip_install_detection.py
#	tests/hermes_cli/test_profile_export_credentials.py
#	tests/hermes_cli/test_psutil_android_extract.py
#	tests/hermes_cli/test_status.py
#	tests/hermes_cli/test_tui_npm_install.py
#	tests/hermes_cli/test_update_fleet_restart_pending.py
#	tests/hermes_cli/test_update_head_moved_gate.py
#	tests/hermes_cli/test_update_interrupted_recovery.py
#	tests/hermes_cli/test_web_server.py
#	tests/hermes_cli/test_web_ui_build.py
#	tests/test_hermes_logging.py
#	tests/test_managed_runtime_resolution.py
#	tests/tools/test_browser_chromium_autoinstall.py
#	tests/tools/test_browser_chromium_check.py
#	tests/tools/test_browser_homebrew_paths.py
#	tests/tools/test_browser_lightpanda.py
#	tests/tools/test_browser_npx_warmup.py
#	tests/tools/test_browser_open_timeout.py
#	tests/tools/test_browser_orphan_reaper.py
#	tests/tools/test_browser_real_profile.py
#	tests/tools/test_browser_suspect_recycle.py
#	tests/tools/test_find_shell.py
#	tests/tools/test_local_env_blocklist.py
#	tests/tools/test_macos_protected_search.py
#	tests/tui_gateway/test_compute_host.py
#	tools/approval.py
#	tools/blueprints.py
#	tools/bot_mode_dm.py
#	tools/bot_mode_probe.py
#	tools/bot_relay.py
#	tools/browser_tool.py
#	tools/browser_use_cli.py
#	tools/checkpoint_manager.py
#	tools/code_execution_tool.py
#	tools/code_kernel.py
#	tools/computer_use/cua_backend.py
#	tools/cronjob_tools.py
#	tools/discord_tool.py
#	tools/environments/base.py
#	tools/environments/daytona.py
#	tools/environments/local.py
#	tools/environments/modal.py
#	tools/environments/vercel_sandbox.py
#	tools/fal_common.py
#	tools/file_operations.py
#	tools/lazy_deps.py
#	tools/mcp_tool.py
#	tools/neutts_synth.py
#	tools/process_registry.py
#	tools/read_extract.py
#	tools/registry.py
#	tools/skill_ledger.py
#	tools/skill_linter.py
#	tools/skill_manager_tool.py
#	tools/skill_usage.py
#	tools/skills_ast_audit.py
#	tools/skills_guard.py
#	tools/skills_hub.py
#	tools/skills_sync.py
#	tools/skills_sync_client.py
#	tools/skills_tool.py
#	tools/terminal_scope.py
#	tools/terminal_tool.py
#	tools/tirith_security.py
#	tools/transcription_tools.py
#	tools/tts_tool.py
#	tools/vision_tools.py
#	tools/voice_mode.py
#	tools/wake_word.py
#	tools/web_result_cache.py
#	tools/website_policy.py
#	tools/working_diff.py
#	tools/write_approval.py
#	tui_gateway/entry.py
#	tui_gateway/methods_tools.py
#	tui_gateway/server.py
2026-09-04 13:03:39 -04:00

195 lines
8.6 KiB
Python

"""Unit tests for the on-disk MCP schema cache (tools/mcp_schema_cache.py).
The module landed in #56832's extraction without its tests; these cover the
fingerprint keying, read/write round-trip, and invalidation behavior.
"""
import pytest
import tools.mcp_schema_cache as msc
from tools import mcp_tool_registration as _mcp_registration
class TestConfigFingerprint:
def test_stable_for_same_config(self):
cfg = {"command": "npx", "args": ["-y", "@playwright/mcp"]}
assert msc.config_fingerprint(cfg) == msc.config_fingerprint(dict(cfg))
def test_changes_when_connection_config_changes(self):
base = {"command": "npx", "args": ["-y", "@playwright/mcp"]}
assert msc.config_fingerprint(base) != msc.config_fingerprint(
{**base, "args": ["-y", "@playwright/mcp", "--headless"]}
)
assert msc.config_fingerprint(base) != msc.config_fingerprint(
{**base, "command": "uvx"}
)
assert msc.config_fingerprint(base) != msc.config_fingerprint(
{**base, "tools": {"include": ["a"]}}
)
def test_ignores_non_connection_keys(self):
base = {"command": "npx", "args": []}
assert msc.config_fingerprint(base) == msc.config_fingerprint(
{**base, "timeout": 5, "enabled": True, "lazy": True}
)
class TestCacheRoundTrip:
def _isolate(self, monkeypatch, tmp_path):
monkeypatch.setattr(msc, "_cache_path", lambda: tmp_path / "cache.json")
def test_write_then_read_with_matching_fingerprint(self, monkeypatch, tmp_path):
self._isolate(monkeypatch, tmp_path)
tools = [{"name": "t1", "description": "d", "inputSchema": {"type": "object"}}]
msc.write_cache_entry("srv", "fp1", tools=tools, utility_tools=[])
entry = msc.get_cached_entry("srv", "fp1")
assert entry is not None
assert msc.tools_from_cache_entry(entry) == tools
assert msc.utility_tools_from_cache_entry(entry) == []
def test_fingerprint_mismatch_returns_none(self, monkeypatch, tmp_path):
self._isolate(monkeypatch, tmp_path)
msc.write_cache_entry("srv", "fp1", tools=[], utility_tools=[])
assert msc.get_cached_entry("srv", "OTHER") is None
def test_missing_server_returns_none(self, monkeypatch, tmp_path):
self._isolate(monkeypatch, tmp_path)
assert msc.get_cached_entry("nope", "fp") is None
def test_corrupt_cache_file_is_tolerated(self, monkeypatch, tmp_path):
self._isolate(monkeypatch, tmp_path)
(tmp_path / "cache.json").write_text("{not json", encoding="utf-8")
assert msc.get_cached_entry("srv", "fp") is None
# And writes recover the file.
msc.write_cache_entry("srv", "fp", tools=[], utility_tools=[])
assert msc.get_cached_entry("srv", "fp") is not None
def test_malformed_entry_shapes_are_tolerated(self):
assert msc.tools_from_cache_entry({"tools": "nope"}) == []
assert msc.utility_tools_from_cache_entry({}) == []
class TestCacheFileLocation:
@pytest.mark.platforms("linux")
def test_cache_lives_under_hermes_home_cache_dir_with_0600(
self, monkeypatch, tmp_path
):
# Real path (no _cache_path monkeypatch): HERMES_HOME/cache/…, 0o600,
# matching the discovery-cache precedent in tools/registry.py.
import hermes_constants
monkeypatch.setattr(hermes_constants, "get_hermes_home", lambda: tmp_path)
path = msc._cache_path()
assert path == tmp_path / "cache" / "mcp_schema_cache.json"
msc.write_cache_entry("srv", "fp", tools=[], utility_tools=[])
assert path.exists()
assert (path.stat().st_mode & 0o777) == 0o600
class TestWriteSkip:
def test_identical_payload_skips_rewrite(self, monkeypatch, tmp_path):
monkeypatch.setattr(msc, "_cache_path", lambda: tmp_path / "cache.json")
saves = []
real_save = msc._save_all
def _counting_save(data):
saves.append(1)
real_save(data)
monkeypatch.setattr(msc, "_save_all", _counting_save)
tools = [{"name": "t1", "description": "d", "inputSchema": {}}]
msc.write_cache_entry("srv", "fp1", tools=tools, utility_tools=[])
assert len(saves) == 1
# Identical payload (reconnect / list_changed refresh) → no rewrite.
msc.write_cache_entry("srv", "fp1", tools=list(tools), utility_tools=[])
assert len(saves) == 1
# Changed payload → rewrite.
msc.write_cache_entry("srv", "fp2", tools=tools, utility_tools=[])
assert len(saves) == 2
class TestWriteThroughPreservesSchema:
"""Regression: the write-through path must persist real tool parameters.
``mcp`` 2.0 renamed ``Tool.inputSchema`` to ``input_schema``, keeping the
camelCase spelling only as a *serialization* alias — pydantic aliases do
not apply to attribute access, so ``getattr(tool, "inputSchema")`` returns
None on 2.x instead of raising. The cache-write path used exactly that
bare read, so every entry landed on disk with ``"inputSchema": {}``. A
server later registered from that cache (``lazy: true``) was advertised to
the model with every parameter stripped, which makes required-argument
tools such as zhihu's ``zhida`` (``query`` + ``model`` both required)
uncallable.
These tests drive the live ``_register_server_tools`` write-through with a
genuine SDK ``Tool`` so the field-rename is actually exercised — the mock
fixtures elsewhere build ``SimpleNamespace`` objects and cannot catch it.
(Salvaged from #91451 / #102129.)
"""
_SCHEMA = {
"type": "object",
"properties": {
"query": {"type": "string"},
"model": {"type": "string"},
},
"required": ["query", "model"],
}
def _cache_write_through(self, tmp_path, monkeypatch):
import json
from unittest.mock import MagicMock, patch
from mcp.types import Tool
import tools.mcp_tool as mt
from tools.registry import ToolRegistry
monkeypatch.setattr(msc, "_cache_path", lambda: tmp_path / "cache.json")
# Registration records per-server state in module globals (lazy tool names, trust
# levels, read-only hints...); isolate them so the probe server never leaks into
# later tests such as ``discover_mcp_tools() == []`` assertions.
for attr in ("_lazy_server_tool_names", "_lazy_server_configs", "_lazy_server_fingerprints",
"_mcp_tool_server_names", "_server_trust_levels", "_tool_read_only_hints"):
monkeypatch.setattr(mt, attr, {})
server = mt.MCPServerTask("probe_srv")
server._tools = [
Tool(name="zhida", description="知乎直答", inputSchema=self._SCHEMA)
]
server.session = MagicMock()
with patch("tools.registry.registry", ToolRegistry()):
registered = _mcp_registration._register_server_tools("probe_srv", server, {})
assert registered, "tool was not registered; write-through never fired"
entry = json.loads((tmp_path / "cache.json").read_text(encoding="utf-8"))["probe_srv"]
return entry
def test_cached_schema_keeps_properties(self, tmp_path, monkeypatch):
cached = self._cache_write_through(tmp_path, monkeypatch)["tools"][0]["inputSchema"]
assert set(cached.get("properties", {})) == {"query", "model"}, (
"write-through persisted an empty schema — the SDK field rename "
"was read with a bare camelCase getattr"
)
def test_cached_schema_keeps_required(self, tmp_path, monkeypatch):
cached = self._cache_write_through(tmp_path, monkeypatch)["tools"][0]["inputSchema"]
assert cached.get("required") == ["query", "model"]
def test_cache_round_trip_reaches_agent_schema(self, tmp_path, monkeypatch):
"""The whole point of the cache: a lazy server re-advertises params."""
from unittest.mock import patch
import tools.mcp_tool as mt
from tools import mcp_tool_registration as _mcp_registration
from tools.registry import ToolRegistry
entry = self._cache_write_through(tmp_path, monkeypatch)
lazy_reg = ToolRegistry()
with patch("tools.registry.registry", lazy_reg):
names = _mcp_registration._register_from_cache_sync("probe_srv", {}, entry)
assert names, "lazy registration produced no tools"
schema = lazy_reg.get_schema("mcp__probe_srv__zhida")
assert schema is not None, "lazy path did not register the tool"
assert set(schema["parameters"].get("properties", {})) == {"query", "model"}
assert schema["parameters"].get("required") == ["query", "model"]