feat(mcp): expose profile-scoped cached connection health
This commit is contained in:
@@ -4,9 +4,11 @@ All tests use mocks -- no real MCP servers or subprocesses are started.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -272,6 +274,19 @@ class TestMCPStatus:
|
||||
import tools.mcp_tool as mcp_tool
|
||||
from tools import mcp_tool_config as _mcp_config
|
||||
from tools import mcp_tool_discovery as _mcp_discovery
|
||||
from tools import mcp_tool_errors as _mcp_errors
|
||||
from tools.mcp_oauth import OAuthNonInteractiveError
|
||||
|
||||
assert _mcp_errors._connect_failure_reason(RuntimeError("network")) == "check_failed"
|
||||
assert _mcp_errors._connect_failure_reason(
|
||||
OAuthNonInteractiveError("browser required")
|
||||
) == "auth_required"
|
||||
tagged = _mcp_errors._connect_error_text(
|
||||
"Connection closed", OAuthNonInteractiveError("browser required")
|
||||
)
|
||||
for cloned in (copy.copy(tagged), copy.deepcopy(tagged), pickle.loads(pickle.dumps(tagged))):
|
||||
assert cloned == "Connection closed"
|
||||
assert getattr(cloned, "reason") == "auth_required"
|
||||
|
||||
monkeypatch.setattr(
|
||||
_mcp_config, "_load_mcp_config",
|
||||
@@ -284,13 +299,17 @@ class TestMCPStatus:
|
||||
)
|
||||
with mcp_tool._lock:
|
||||
saved_servers = dict(mcp_tool._servers)
|
||||
saved_scopes = dict(mcp_tool._server_scope_keys)
|
||||
saved_connecting = set(mcp_tool._server_connecting)
|
||||
saved_errors = dict(mcp_tool._server_connect_errors)
|
||||
mcp_tool._servers.clear()
|
||||
mcp_tool._server_scope_keys.clear()
|
||||
mcp_tool._server_connecting.clear()
|
||||
mcp_tool._server_connect_errors.clear()
|
||||
mcp_tool._server_connecting.add("connecting")
|
||||
mcp_tool._server_connect_errors["failed"] = "Connection closed"
|
||||
mcp_tool._server_connect_errors["failed"] = _mcp_errors._connect_error_text(
|
||||
"Connection closed", OAuthNonInteractiveError("browser required")
|
||||
)
|
||||
|
||||
try:
|
||||
statuses = {
|
||||
@@ -301,6 +320,8 @@ class TestMCPStatus:
|
||||
with mcp_tool._lock:
|
||||
mcp_tool._servers.clear()
|
||||
mcp_tool._servers.update(saved_servers)
|
||||
mcp_tool._server_scope_keys.clear()
|
||||
mcp_tool._server_scope_keys.update(saved_scopes)
|
||||
mcp_tool._server_connecting.clear()
|
||||
mcp_tool._server_connecting.update(saved_connecting)
|
||||
mcp_tool._server_connect_errors.clear()
|
||||
@@ -310,11 +331,122 @@ class TestMCPStatus:
|
||||
assert statuses["configured"]["connected"] is False
|
||||
assert statuses["configured"]["disabled"] is False
|
||||
assert statuses["connecting"]["status"] == "connecting"
|
||||
assert statuses["connecting"]["reason"] == "stale"
|
||||
assert statuses["failed"]["status"] == "failed"
|
||||
assert statuses["failed"]["error"] == "Connection closed"
|
||||
assert statuses["failed"]["reason"] == "auth_required"
|
||||
assert statuses["disabled"]["status"] == "disabled"
|
||||
assert statuses["disabled"]["disabled"] is True
|
||||
|
||||
def test_status_ignores_a_runtime_owned_by_another_profile(self, monkeypatch):
|
||||
import tools.mcp_tool as mcp_tool
|
||||
from tools import mcp_tool_config as _mcp_config
|
||||
from tools import mcp_tool_discovery as _mcp_discovery
|
||||
|
||||
monkeypatch.setattr(
|
||||
_mcp_config, "_load_mcp_config",
|
||||
lambda: {"shared": {"command": "shared-mcp"}},
|
||||
)
|
||||
monkeypatch.setattr(mcp_tool, "_mcp_registry_scope", lambda: "profile:work")
|
||||
foreign_server = MagicMock(spec=mcp_tool.MCPServerTask)
|
||||
foreign_server.session = object()
|
||||
foreign_server._registered_tool_names = ["secret_tool"]
|
||||
foreign_server._tools = []
|
||||
foreign_server._sampling = None
|
||||
with mcp_tool._lock:
|
||||
saved_servers = dict(mcp_tool._servers)
|
||||
saved_scopes = dict(mcp_tool._server_scope_keys)
|
||||
mcp_tool._servers["shared"] = foreign_server
|
||||
mcp_tool._server_scope_keys["shared"] = "profile:other"
|
||||
|
||||
try:
|
||||
[status] = _mcp_discovery.get_mcp_status()
|
||||
with mcp_tool._lock:
|
||||
mcp_tool._server_scope_keys.pop("shared", None)
|
||||
[unscoped_status] = _mcp_discovery.get_mcp_status()
|
||||
finally:
|
||||
with mcp_tool._lock:
|
||||
mcp_tool._servers.clear()
|
||||
mcp_tool._servers.update(saved_servers)
|
||||
mcp_tool._server_scope_keys.clear()
|
||||
mcp_tool._server_scope_keys.update(saved_scopes)
|
||||
|
||||
assert status["status"] == "configured"
|
||||
assert status["reason"] == "stale"
|
||||
assert status["tools"] == 0
|
||||
assert unscoped_status["status"] == "configured"
|
||||
assert unscoped_status["reason"] == "stale"
|
||||
|
||||
|
||||
def test_scoped_shutdown_clears_only_its_connection_status(self, monkeypatch):
|
||||
import tools.mcp_tool as mcp_tool
|
||||
from tools import mcp_tool_lifecycle, mcp_tool_loop, mcp_tool_discovery
|
||||
|
||||
monkeypatch.setattr(mcp_tool_loop, "_stop_mcp_loop", lambda **_kwargs: None)
|
||||
with mcp_tool._lock:
|
||||
saved_servers = dict(mcp_tool._servers)
|
||||
saved_scopes = dict(mcp_tool._server_scope_keys)
|
||||
saved_connecting = set(mcp_tool._server_connecting)
|
||||
saved_errors = dict(mcp_tool._server_connect_errors)
|
||||
saved_retry_after = dict(mcp_tool._server_connect_retry_after)
|
||||
saved_failures = dict(mcp_tool._server_connect_failures)
|
||||
mcp_tool._servers.clear()
|
||||
mcp_tool._server_scope_keys.clear()
|
||||
mcp_tool._server_scope_keys.update({
|
||||
"work-connecting": "profile:work",
|
||||
"work-failed": "profile:work",
|
||||
"other-failed": "profile:other",
|
||||
})
|
||||
mcp_tool._server_connecting.clear()
|
||||
mcp_tool._server_connecting.add("work-connecting")
|
||||
mcp_tool._server_connect_errors.clear()
|
||||
mcp_tool._server_connect_errors.update({
|
||||
"work-failed": "work error",
|
||||
"other-failed": "other error",
|
||||
})
|
||||
mcp_tool._server_connect_retry_after.clear()
|
||||
mcp_tool._server_connect_retry_after.update({
|
||||
"work-failed": 1.0,
|
||||
"other-failed": 2.0,
|
||||
})
|
||||
mcp_tool._server_connect_failures.clear()
|
||||
mcp_tool._server_connect_failures.update({
|
||||
"work-failed": 1,
|
||||
"other-failed": 2,
|
||||
})
|
||||
|
||||
try:
|
||||
mcp_tool_lifecycle.shutdown_mcp_servers(scope="profile:work")
|
||||
|
||||
with mcp_tool._lock:
|
||||
assert mcp_tool._server_connecting == set()
|
||||
assert mcp_tool._server_connect_errors == {
|
||||
"other-failed": "other error"
|
||||
}
|
||||
assert mcp_tool._server_scope_keys == {
|
||||
"other-failed": "profile:other"
|
||||
}
|
||||
assert mcp_tool._server_connect_retry_after == {
|
||||
"other-failed": 2.0
|
||||
}
|
||||
assert mcp_tool._server_connect_failures == {
|
||||
"other-failed": 2
|
||||
}
|
||||
finally:
|
||||
with mcp_tool._lock:
|
||||
mcp_tool._servers.clear()
|
||||
mcp_tool._servers.update(saved_servers)
|
||||
mcp_tool._server_scope_keys.clear()
|
||||
mcp_tool._server_scope_keys.update(saved_scopes)
|
||||
mcp_tool._server_connecting.clear()
|
||||
mcp_tool._server_connecting.update(saved_connecting)
|
||||
mcp_tool._server_connect_errors.clear()
|
||||
mcp_tool._server_connect_errors.update(saved_errors)
|
||||
mcp_tool._server_connect_retry_after.clear()
|
||||
mcp_tool._server_connect_retry_after.update(saved_retry_after)
|
||||
mcp_tool._server_connect_failures.clear()
|
||||
mcp_tool._server_connect_failures.update(saved_failures)
|
||||
|
||||
|
||||
class TestLifecycleConfig:
|
||||
def test_get_lifecycle_seconds_accepts_top_level_and_nested_values(self):
|
||||
@@ -2688,6 +2820,53 @@ class TestSanitizeMcpNameComponent:
|
||||
|
||||
|
||||
class TestRegisterMcpServers:
|
||||
def test_records_profile_scope_before_connect(self):
|
||||
import tools.mcp_tool as mcp_tool
|
||||
from tools import mcp_tool_lifecycle, mcp_tool_loop, mcp_tool_discovery
|
||||
|
||||
seen = []
|
||||
|
||||
async def fake_register(name, _cfg):
|
||||
with mcp_tool._lock:
|
||||
seen.append((
|
||||
name in mcp_tool._server_connecting,
|
||||
mcp_tool._server_scope_keys.get(name),
|
||||
))
|
||||
raise RuntimeError("expected connect failure")
|
||||
|
||||
with mcp_tool._lock:
|
||||
saved_servers = dict(mcp_tool._servers)
|
||||
saved_scopes = dict(mcp_tool._server_scope_keys)
|
||||
saved_connecting = set(mcp_tool._server_connecting)
|
||||
saved_errors = dict(mcp_tool._server_connect_errors)
|
||||
mcp_tool._servers.clear()
|
||||
mcp_tool._server_scope_keys.clear()
|
||||
mcp_tool._server_connecting.clear()
|
||||
mcp_tool._server_connect_errors.clear()
|
||||
|
||||
try:
|
||||
with patch("tools.mcp_tool._MCP_AVAILABLE", True), \
|
||||
patch("tools.mcp_tool_config._filter_suspicious_mcp_servers", side_effect=lambda value: value), \
|
||||
patch("tools.mcp_tool_discovery._connect_cooldown_active", return_value=False), \
|
||||
patch("tools.mcp_tool._mcp_registry_scope", return_value="profile:work"), \
|
||||
patch("tools.mcp_tool_discovery._discover_and_register_server", side_effect=fake_register):
|
||||
mcp_tool_discovery.register_mcp_servers({"scoped": {"command": "test"}})
|
||||
|
||||
assert seen == [(True, "profile:work")]
|
||||
with mcp_tool._lock:
|
||||
assert mcp_tool._server_connect_errors["scoped"].reason == "check_failed"
|
||||
assert mcp_tool._server_scope_keys["scoped"] == "profile:work"
|
||||
finally:
|
||||
with mcp_tool._lock:
|
||||
mcp_tool._servers.clear()
|
||||
mcp_tool._servers.update(saved_servers)
|
||||
mcp_tool._server_scope_keys.clear()
|
||||
mcp_tool._server_scope_keys.update(saved_scopes)
|
||||
mcp_tool._server_connecting.clear()
|
||||
mcp_tool._server_connecting.update(saved_connecting)
|
||||
mcp_tool._server_connect_errors.clear()
|
||||
mcp_tool._server_connect_errors.update(saved_errors)
|
||||
|
||||
"""Verify the new register_mcp_servers() public API."""
|
||||
|
||||
def test_mcp_not_available_returns_empty(self):
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -110,6 +111,176 @@ def test_list_reflects_the_scoped_profile(hermes_root):
|
||||
assert work_server["command"] == "svc-a-bin"
|
||||
|
||||
|
||||
def test_status_is_profile_scoped_and_credential_safe(hermes_root):
|
||||
_result(
|
||||
_call(
|
||||
"mcp.servers.add",
|
||||
{"profile": "work", "name": "svc-a", "config": {"command": "svc-a-bin"}},
|
||||
)
|
||||
)
|
||||
_result(
|
||||
_call(
|
||||
"mcp.servers.add",
|
||||
{"profile": "other", "name": "svc-b", "config": {"command": "svc-b-bin"}},
|
||||
)
|
||||
)
|
||||
|
||||
payload = _result(_call("mcp.servers.status", {"profile": "work"}))
|
||||
|
||||
assert payload["checked_at"] > 0
|
||||
assert payload["servers"] == [
|
||||
{
|
||||
"name": "svc-a",
|
||||
"transport": "stdio",
|
||||
"tools": 0,
|
||||
"connected": False,
|
||||
"disabled": False,
|
||||
"status": "configured",
|
||||
"reason": "stale",
|
||||
}
|
||||
]
|
||||
assert "error" not in str(payload)
|
||||
|
||||
|
||||
def test_status_redacts_internal_failures(hermes_root, monkeypatch):
|
||||
from tools import mcp_tool_discovery
|
||||
|
||||
def fail(*args, **kwargs):
|
||||
raise RuntimeError("token=must-not-leak")
|
||||
|
||||
monkeypatch.setattr(mcp_tool_discovery, "get_mcp_status", fail)
|
||||
response = _call("mcp.servers.status", {"profile": "work"})
|
||||
|
||||
assert response["error"]["message"] == "MCP status unavailable"
|
||||
assert "must-not-leak" not in str(response)
|
||||
|
||||
|
||||
def test_status_omits_raw_server_errors(hermes_root, monkeypatch):
|
||||
from tools import mcp_tool_discovery
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_tool_discovery,
|
||||
"get_mcp_status",
|
||||
lambda configured, include_runtime: [{
|
||||
"name": "svc",
|
||||
"transport": "stdio",
|
||||
"tools": 0,
|
||||
"connected": False,
|
||||
"disabled": False,
|
||||
"status": "failed",
|
||||
"reason": "auth_required",
|
||||
"error": "token=must-not-leak",
|
||||
}],
|
||||
)
|
||||
|
||||
payload = _result(_call("mcp.servers.status"))
|
||||
assert payload["servers"][0]["reason"] == "auth_required"
|
||||
assert "error" not in payload["servers"][0]
|
||||
assert "must-not-leak" not in str(payload)
|
||||
|
||||
|
||||
def test_status_does_not_run_the_runtime_config_loader(hermes_root, monkeypatch):
|
||||
from tools import mcp_tool_config
|
||||
|
||||
_result(
|
||||
_call(
|
||||
"mcp.servers.add",
|
||||
{"profile": "work", "name": "svc-a", "config": {"command": "svc-a-bin"}},
|
||||
)
|
||||
)
|
||||
|
||||
def forbidden():
|
||||
raise AssertionError("runtime config loader executed")
|
||||
|
||||
monkeypatch.setattr(mcp_tool_config, "_load_mcp_config", forbidden)
|
||||
|
||||
payload = _result(_call("mcp.servers.status", {"profile": "work"}))
|
||||
assert [server["name"] for server in payload["servers"]] == ["svc-a"]
|
||||
|
||||
|
||||
def test_status_does_not_mix_launch_runtime_into_another_profile(hermes_root):
|
||||
import tools.mcp_tool as mcp_tool
|
||||
|
||||
_result(
|
||||
_call(
|
||||
"mcp.servers.add",
|
||||
{"profile": "work", "name": "shared", "config": {"command": "work-bin"}},
|
||||
)
|
||||
)
|
||||
launch_server = SimpleNamespace(
|
||||
session=object(),
|
||||
_registered_tool_names=["launch_secret_tool"],
|
||||
_tools=[],
|
||||
_sampling=None,
|
||||
)
|
||||
with mcp_tool._lock:
|
||||
saved_servers = dict(mcp_tool._servers)
|
||||
saved_scopes = dict(mcp_tool._server_scope_keys)
|
||||
mcp_tool._servers["shared"] = launch_server
|
||||
mcp_tool._server_scope_keys.pop("shared", None)
|
||||
|
||||
try:
|
||||
payload = _result(_call("mcp.servers.status", {"profile": "work"}))
|
||||
finally:
|
||||
with mcp_tool._lock:
|
||||
mcp_tool._servers.clear()
|
||||
mcp_tool._servers.update(saved_servers)
|
||||
mcp_tool._server_scope_keys.clear()
|
||||
mcp_tool._server_scope_keys.update(saved_scopes)
|
||||
|
||||
assert payload["servers"][0]["status"] == "configured"
|
||||
assert payload["servers"][0]["tools"] == 0
|
||||
|
||||
|
||||
def test_status_includes_named_profile_runtime_in_multiplex(hermes_root):
|
||||
from agent.secret_scope import is_multiplex_active, set_multiplex_active
|
||||
from hermes_constants import (
|
||||
hermes_home_key,
|
||||
reset_hermes_home_override,
|
||||
set_hermes_home_override,
|
||||
)
|
||||
import tools.mcp_tool as mcp_tool
|
||||
|
||||
_result(
|
||||
_call(
|
||||
"mcp.servers.add",
|
||||
{"profile": "work", "name": "shared", "config": {"command": "work-bin"}},
|
||||
)
|
||||
)
|
||||
work_token = set_hermes_home_override(hermes_root / "profiles" / "work")
|
||||
try:
|
||||
work_scope = hermes_home_key()
|
||||
finally:
|
||||
reset_hermes_home_override(work_token)
|
||||
|
||||
work_server = SimpleNamespace(
|
||||
session=object(),
|
||||
_registered_tool_names=["work_tool"],
|
||||
_tools=[],
|
||||
_sampling=None,
|
||||
)
|
||||
previous_multiplex = is_multiplex_active()
|
||||
with mcp_tool._lock:
|
||||
saved_servers = dict(mcp_tool._servers)
|
||||
saved_scopes = dict(mcp_tool._server_scope_keys)
|
||||
mcp_tool._servers["shared"] = work_server # type: ignore[assignment]
|
||||
mcp_tool._server_scope_keys["shared"] = work_scope
|
||||
|
||||
set_multiplex_active(True)
|
||||
try:
|
||||
payload = _result(_call("mcp.servers.status", {"profile": "work"}))
|
||||
finally:
|
||||
set_multiplex_active(previous_multiplex)
|
||||
with mcp_tool._lock:
|
||||
mcp_tool._servers.clear()
|
||||
mcp_tool._servers.update(saved_servers)
|
||||
mcp_tool._server_scope_keys.clear()
|
||||
mcp_tool._server_scope_keys.update(saved_scopes)
|
||||
|
||||
assert payload["servers"][0]["status"] == "connected"
|
||||
assert payload["servers"][0]["tools"] == 1
|
||||
|
||||
|
||||
def test_set_api_key_writes_env_and_header_to_right_profile(hermes_root):
|
||||
root = hermes_root
|
||||
_result(
|
||||
|
||||
+46
-11
@@ -112,7 +112,7 @@ def _note_connect_failure(name: str, exc: BaseException) -> str:
|
||||
message = _errors._format_connect_error(exc)
|
||||
with _core._lock:
|
||||
_core._server_connecting.discard(name)
|
||||
_core._server_connect_errors[name] = message
|
||||
_core._server_connect_errors[name] = _errors._connect_error_text(message, exc)
|
||||
_record_connect_failure(name)
|
||||
return message
|
||||
|
||||
@@ -245,7 +245,9 @@ def _select_new_servers(servers: Dict[str, dict]) -> Dict[str, dict]:
|
||||
stale_cached = [_core._servers[k] for k in servers
|
||||
if k in _core._servers and getattr(_core._servers[k], "session", None) is None]
|
||||
_core._server_connecting.update(new_servers)
|
||||
current_scope = _core._mcp_registry_scope()
|
||||
for srv_name in new_servers:
|
||||
_core._server_scope_keys[srv_name] = current_scope
|
||||
_core._server_connect_errors.pop(srv_name, None)
|
||||
# Track which servers opt-in to parallel tool calls (idempotent).
|
||||
for srv_name, srv_cfg in servers.items():
|
||||
@@ -327,7 +329,9 @@ def _run_discovery_pass(new_servers: Dict[str, dict]) -> None:
|
||||
"connecting set: %s", how, len(stale), ", ".join(stale))
|
||||
_core._server_connecting.difference_update(stale)
|
||||
for _sn in stale:
|
||||
_core._server_connect_errors.setdefault(_sn, f"Connection attempt {how} during discovery")
|
||||
message = f"Connection attempt {how} during discovery"
|
||||
_core._server_connect_errors.setdefault(
|
||||
_sn, _errors._MCPConnectErrorText(message, "check_failed"))
|
||||
raise
|
||||
finally:
|
||||
if _was_interrupted:
|
||||
@@ -453,16 +457,36 @@ def is_mcp_tool_parallel_safe(tool_name: str) -> bool:
|
||||
return bool(server_name and server_name in _core._parallel_safe_servers)
|
||||
|
||||
|
||||
def get_mcp_status() -> List[dict]:
|
||||
"""Per-server status dicts for banner/TUI: name, transport, tools, connected, disabled,
|
||||
status (connected / disabled / connecting / failed / configured) and error for failed."""
|
||||
configured = _config._load_mcp_config()
|
||||
def get_mcp_status(
|
||||
configured: Optional[Dict[str, dict]] = None,
|
||||
*,
|
||||
include_runtime: bool = True,
|
||||
) -> List[dict]:
|
||||
"""Per-server cached status without initiating connections."""
|
||||
configured = _config._load_mcp_config() if configured is None else dict(configured)
|
||||
if not configured:
|
||||
return []
|
||||
current_scope = _core._mcp_registry_scope()
|
||||
with _core._lock:
|
||||
active_servers = dict(_core._servers)
|
||||
connecting = set(_core._server_connecting)
|
||||
connect_errors = dict(_core._server_connect_errors)
|
||||
scope_keys = dict(_core._server_scope_keys) if include_runtime else {}
|
||||
|
||||
def visible_in_current_scope(name: str) -> bool:
|
||||
if name in scope_keys:
|
||||
return scope_keys[name] == current_scope
|
||||
return current_scope is None
|
||||
|
||||
active_servers = {
|
||||
name: server for name, server in _core._servers.items()
|
||||
if include_runtime and visible_in_current_scope(name)
|
||||
}
|
||||
connecting = {
|
||||
name for name in _core._server_connecting
|
||||
if include_runtime and visible_in_current_scope(name)
|
||||
}
|
||||
connect_errors = {
|
||||
name: error for name, error in _core._server_connect_errors.items()
|
||||
if include_runtime and visible_in_current_scope(name)
|
||||
}
|
||||
|
||||
result: List[dict] = []
|
||||
for name, cfg in configured.items():
|
||||
@@ -471,8 +495,13 @@ def get_mcp_status() -> List[dict]:
|
||||
live = server is not None and server.session is not None
|
||||
status = ("connected" if live else "disabled" if not enabled else "connecting" if name in connecting
|
||||
else "failed" if name in connect_errors else "configured")
|
||||
reason = {
|
||||
"connected": "healthy", "disabled": "not_configured",
|
||||
"connecting": "stale", "configured": "stale",
|
||||
}.get(status, "check_failed")
|
||||
entry = {"name": name, "transport": cfg.get("transport", "http") if "url" in cfg else "stdio",
|
||||
"tools": 0, "connected": False, "disabled": status == "disabled", "status": status}
|
||||
"tools": 0, "connected": False, "disabled": status == "disabled",
|
||||
"status": status, "reason": reason}
|
||||
if live:
|
||||
entry["connected"] = True
|
||||
entry["tools"] = (len(server._registered_tool_names) if hasattr(server, "_registered_tool_names")
|
||||
@@ -480,7 +509,13 @@ def get_mcp_status() -> List[dict]:
|
||||
if server._sampling:
|
||||
entry["sampling"] = dict(server._sampling.metrics)
|
||||
elif status == "failed":
|
||||
entry["error"] = connect_errors[name]
|
||||
stored_error = connect_errors[name]
|
||||
failure = getattr(server, "_error", None) if server is not None else None
|
||||
failure_reason = getattr(stored_error, "reason", None)
|
||||
if failure_reason is None and isinstance(failure, BaseException):
|
||||
failure_reason = _errors._connect_failure_reason(failure)
|
||||
entry["reason"] = failure_reason or "check_failed"
|
||||
entry["error"] = str(stored_error)
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
@@ -283,6 +283,29 @@ def _is_auth_error(exc: BaseException) -> bool:
|
||||
return getattr(exc.response, "status_code", None) == 401 if isinstance(exc, http_types) else True
|
||||
|
||||
|
||||
def _connect_failure_reason(exc: BaseException) -> str:
|
||||
"""Reduce an MCP connect exception to a credential-safe health reason."""
|
||||
return "auth_required" if _is_auth_error(_unwrap_exception_group(exc)) else "check_failed"
|
||||
|
||||
|
||||
class _MCPConnectErrorText(str):
|
||||
"""Backward-compatible error text carrying a credential-safe reason."""
|
||||
|
||||
reason: str
|
||||
|
||||
def __new__(cls, message: str, reason: str):
|
||||
value = super().__new__(cls, message)
|
||||
value.reason = reason
|
||||
return value
|
||||
|
||||
def __reduce__(self):
|
||||
return type(self), (str(self), self.reason)
|
||||
|
||||
|
||||
def _connect_error_text(message: str, exc: BaseException) -> str:
|
||||
return _MCPConnectErrorText(message, _connect_failure_reason(exc))
|
||||
|
||||
|
||||
# Lower-cased substrings meaning the transport session expired / was GC'd (OAuth token still valid).
|
||||
# Substrings (lower-cased match) that indicate the MCP server rejected the request because its server-side
|
||||
# transport session expired / was garbage-collected. See #13383.
|
||||
|
||||
@@ -84,11 +84,16 @@ def _filter_mcp_children(pids: set) -> set:
|
||||
return kept
|
||||
|
||||
|
||||
def _clear_connect_cooldowns() -> None:
|
||||
def _clear_connect_cooldowns(names=None) -> None:
|
||||
"""Drop connect-retry cooldowns: a restart must re-attempt every server immediately, not
|
||||
honour a stale per-server backoff. Caller holds ``_core._lock``."""
|
||||
_core._server_connect_retry_after.clear()
|
||||
_core._server_connect_failures.clear()
|
||||
if names is None:
|
||||
_core._server_connect_retry_after.clear()
|
||||
_core._server_connect_failures.clear()
|
||||
else:
|
||||
for name in names:
|
||||
_core._server_connect_retry_after.pop(name, None)
|
||||
_core._server_connect_failures.pop(name, None)
|
||||
|
||||
|
||||
def shutdown_mcp_servers(*, scope: Optional[str] = None):
|
||||
@@ -100,6 +105,19 @@ def shutdown_mcp_servers(*, scope: Optional[str] = None):
|
||||
with _core._lock:
|
||||
selected = [name for name in _core._servers if scope is None or _core._server_scope_keys.get(name) == scope]
|
||||
servers_snapshot = [_core._servers[name] for name in selected]
|
||||
selected_status = (
|
||||
set(_core._servers) | set(_core._server_scope_keys)
|
||||
| set(_core._server_connecting) | set(_core._server_connect_errors)
|
||||
if scope is None else {
|
||||
name for name, owner in _core._server_scope_keys.items() if owner == scope
|
||||
}
|
||||
)
|
||||
|
||||
def clear_selected_status():
|
||||
_core._server_connecting.difference_update(selected_status)
|
||||
for name in selected_status:
|
||||
_core._server_connect_errors.pop(name, None)
|
||||
_core._server_scope_keys.pop(name, None)
|
||||
|
||||
# Fast path: nothing to shut down. The connect-cooldown maps can still be populated here — a server that
|
||||
# failed to connect is never recorded in ``_servers`` (that is the very premise of the #50394 cooldown),
|
||||
@@ -115,7 +133,8 @@ def shutdown_mcp_servers(*, scope: Optional[str] = None):
|
||||
for name in selected:
|
||||
_core._servers.pop(name, None)
|
||||
_core._server_scope_keys.pop(name, None)
|
||||
_clear_connect_cooldowns()
|
||||
clear_selected_status()
|
||||
_clear_connect_cooldowns(None if scope is None else selected_status)
|
||||
|
||||
with _core._lock:
|
||||
loop = _core._mcp_loop
|
||||
@@ -132,7 +151,9 @@ def shutdown_mcp_servers(*, scope: Optional[str] = None):
|
||||
# (a server that failed to connect is never in ``_servers`` — the most likely state for
|
||||
# stale backoff entries), no connect-cooldown state may survive shutdown.
|
||||
with _core._lock:
|
||||
_clear_connect_cooldowns()
|
||||
if not servers_snapshot:
|
||||
clear_selected_status()
|
||||
_clear_connect_cooldowns(None if scope is None else selected_status)
|
||||
_loop._stop_mcp_loop(only_if_idle=scope is not None)
|
||||
|
||||
|
||||
|
||||
@@ -1171,6 +1171,39 @@ def _(rid, params: dict) -> dict:
|
||||
return _ok(rid, {"servers": [_mcp_summarize_server(name, cfg) for name, cfg in sorted(servers.items())]})
|
||||
|
||||
|
||||
@method("mcp.servers.status")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Return credential-safe cached MCP runtime state without connecting."""
|
||||
hc = _tools_mod("hermes_constants")
|
||||
runtime_home_key = hc.hermes_home_key()
|
||||
token = None
|
||||
try:
|
||||
if profile := _str_arg(params, "profile"):
|
||||
profile_dir = _tools_mod("hermes_cli.profiles").get_profile_dir(profile)
|
||||
if not profile_dir or not profile_dir.is_dir():
|
||||
return _err(rid, 4064, f"profile '{profile}' not found")
|
||||
token = hc.set_hermes_home_override(str(profile_dir))
|
||||
|
||||
import time
|
||||
from agent.secret_scope import is_multiplex_active
|
||||
|
||||
configured = _tools_mod("hermes_cli.mcp_config")._get_mcp_servers()
|
||||
get_status = _tools_mod("tools.mcp_tool_discovery").get_mcp_status
|
||||
safe_fields = ("name", "transport", "tools", "connected", "disabled", "status", "reason")
|
||||
servers = get_status(
|
||||
configured,
|
||||
include_runtime=is_multiplex_active() or hc.hermes_home_key() == runtime_home_key,
|
||||
)
|
||||
return _ok(rid, {
|
||||
"servers": [{key: entry[key] for key in safe_fields if key in entry} for entry in servers],
|
||||
"checked_at": int(time.time() * 1000),
|
||||
})
|
||||
except Exception:
|
||||
return _err(rid, 5024, "MCP status unavailable")
|
||||
finally:
|
||||
_mcp_reset_profile(token)
|
||||
|
||||
|
||||
@_mcp_rpc("add")
|
||||
def _(rid, params: dict) -> dict:
|
||||
"""Add ``name`` from ``preset`` (catalog id) and/or ``config`` (url/command/args/env/headers/auth/
|
||||
|
||||
Reference in New Issue
Block a user