diff --git a/api/routes.py b/api/routes.py
index 592431a1..6c3568de 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -7167,33 +7167,111 @@ def _mask_secrets(obj):
return masked
-def _server_summary(name, cfg):
+def _parse_mcp_enabled(value) -> bool:
+ """Parse Hermes MCP ``enabled`` values without raising on bad config."""
+ if value is None:
+ return True
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, (int, float)):
+ return value != 0
+ if isinstance(value, str):
+ normalized = value.strip().lower()
+ if normalized in {"true", "1", "yes", "on"}:
+ return True
+ if normalized in {"false", "0", "no", "off"}:
+ return False
+ return True
+
+
+def _mcp_runtime_status_by_name() -> dict[str, dict]:
+ """Return already-known MCP runtime status without starting servers.
+
+ ``tools.mcp_tool.get_mcp_status()`` only reads the existing MCP registry and
+ configuration; it does not probe or spawn MCP subprocesses. If Hermes Agent
+ is unavailable, fall back to an empty map so the API remains safe.
+ """
+ try:
+ from tools.mcp_tool import get_mcp_status
+ statuses = get_mcp_status()
+ except Exception:
+ return {}
+ if not isinstance(statuses, list):
+ return {}
+ return {
+ str(entry.get("name")): entry
+ for entry in statuses
+ if isinstance(entry, dict) and entry.get("name")
+ }
+
+
+def _server_summary(name, cfg, runtime_status=None):
"""Return a safe summary of an MCP server config."""
+ runtime_status = runtime_status if isinstance(runtime_status, dict) else {}
out = {"name": name}
+ if not isinstance(cfg, dict):
+ out.update({
+ "transport": "invalid",
+ "timeout": 120,
+ "connect_timeout": 60,
+ "enabled": False,
+ "active": False,
+ "status": "invalid_config",
+ "tool_count": None,
+ })
+ return out
+
+ enabled = _parse_mcp_enabled(cfg.get("enabled", True))
+ connected = bool(runtime_status.get("connected")) if enabled else False
if "url" in cfg:
out["transport"] = "http"
# Mask auth headers
if "headers" in cfg:
out["headers"] = _mask_secrets(cfg["headers"])
out["url"] = cfg["url"]
- else:
+ elif "command" in cfg:
out["transport"] = "stdio"
out["command"] = cfg.get("command", "")
out["args"] = cfg.get("args", [])
if "env" in cfg:
out["env"] = _mask_secrets(cfg["env"])
+ else:
+ out["transport"] = "invalid"
+ enabled = False
+ connected = False
+
out["timeout"] = cfg.get("timeout", 120)
+ out["connect_timeout"] = cfg.get("connect_timeout", 60)
+ out["enabled"] = enabled
+ out["active"] = connected
+ if out["transport"] == "invalid":
+ out["status"] = "invalid_config"
+ elif not enabled:
+ out["status"] = "disabled"
+ elif connected:
+ out["status"] = "active"
+ else:
+ out["status"] = "configured"
+ out["tool_count"] = runtime_status.get("tools") if runtime_status else None
return out
def _handle_mcp_servers_list(handler):
- """List all configured MCP servers."""
+ """List configured MCP servers with safe, read-only runtime visibility."""
cfg = get_config()
servers = cfg.get("mcp_servers", {})
if not isinstance(servers, dict):
servers = {}
- result = [_server_summary(name, scfg) for name, scfg in servers.items()]
- return j(handler, {"servers": result})
+ runtime = _mcp_runtime_status_by_name()
+ result = [
+ _server_summary(name, scfg, runtime.get(str(name)))
+ for name, scfg in servers.items()
+ ]
+ return j(handler, {
+ "servers": result,
+ "toggle_supported": False,
+ "reload_required": True,
+ })
def _handle_mcp_server_delete(handler, name):
diff --git a/docs/pr-media/696/mcp-servers-system-panel.png b/docs/pr-media/696/mcp-servers-system-panel.png
new file mode 100644
index 00000000..32c8789a
Binary files /dev/null and b/docs/pr-media/696/mcp-servers-system-panel.png differ
diff --git a/static/i18n.js b/static/i18n.js
index 94625d78..685374aa 100644
--- a/static/i18n.js
+++ b/static/i18n.js
@@ -46,7 +46,7 @@ const LOCALES = {
parse_failed_note: 'parse failed',
you: 'You',
mcp_servers_title: 'MCP Servers',
- mcp_servers_desc: 'Manage MCP servers configured in config.yaml.',
+ mcp_servers_desc: 'View MCP servers configured in config.yaml.',
mcp_no_servers: 'No MCP servers configured.',
mcp_add_server: '+ Add Server',
mcp_field_name: 'Server Name',
@@ -67,6 +67,16 @@ const LOCALES = {
mcp_deleted: 'MCP server deleted.',
mcp_delete_failed: 'Failed to delete MCP server.',
mcp_load_failed: 'Failed to load MCP servers.',
+ mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.',
+ mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.',
+ mcp_status_active: 'Active',
+ mcp_status_configured: 'Configured',
+ mcp_status_disabled: 'Disabled',
+ mcp_status_invalid_config: 'Invalid config',
+ mcp_status_unknown: 'Unknown',
+ mcp_tool_count: '{0} tools',
+ mcp_enabled_yes: 'Enabled',
+ mcp_enabled_no: 'Disabled',
// PDF preview (#480)
pdf_loading: 'Loading PDF {0}…',
pdf_too_large: 'PDF too large for inline preview',
@@ -1027,6 +1037,16 @@ const LOCALES = {
mcp_deleted: 'MCPサーバーを削除しました。',
mcp_delete_failed: 'MCPサーバーの削除に失敗しました。',
mcp_load_failed: 'MCPサーバーの読み込みに失敗しました。',
+ mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.',
+ mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.',
+ mcp_status_active: 'Active',
+ mcp_status_configured: 'Configured',
+ mcp_status_disabled: 'Disabled',
+ mcp_status_invalid_config: 'Invalid config',
+ mcp_status_unknown: 'Unknown',
+ mcp_tool_count: '{0} tools',
+ mcp_enabled_yes: 'Enabled',
+ mcp_enabled_no: 'Disabled',
// PDF preview (#480)
pdf_loading: 'PDF {0} を読み込み中…',
pdf_too_large: 'PDF が大きすぎてインラインプレビューできません',
@@ -1984,6 +2004,16 @@ const LOCALES = {
mcp_deleted: 'MCP 伺服器已刪除。',
mcp_delete_failed: '刪除 MCP 伺服器失敗。',
mcp_load_failed: '載入 MCP 伺服器失敗。',
+ mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.',
+ mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.',
+ mcp_status_active: 'Active',
+ mcp_status_configured: 'Configured',
+ mcp_status_disabled: 'Disabled',
+ mcp_status_invalid_config: 'Invalid config',
+ mcp_status_unknown: 'Unknown',
+ mcp_tool_count: '{0} tools',
+ mcp_enabled_yes: 'Enabled',
+ mcp_enabled_no: 'Disabled',
thinking: 'Думаю',
expand_all: 'Развернуть всё',
collapse_all: 'Свернуть всё',
@@ -2875,6 +2905,16 @@ const LOCALES = {
mcp_deleted: 'MCP 服务器已删除。',
mcp_delete_failed: '删除 MCP 服务器失败。',
mcp_load_failed: '加载 MCP 服务器失败。',
+ mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.',
+ mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.',
+ mcp_status_active: 'Active',
+ mcp_status_configured: 'Configured',
+ mcp_status_disabled: 'Disabled',
+ mcp_status_invalid_config: 'Invalid config',
+ mcp_status_unknown: 'Unknown',
+ mcp_tool_count: '{0} tools',
+ mcp_enabled_yes: 'Enabled',
+ mcp_enabled_no: 'Disabled',
thinking: 'Pensando',
expand_all: 'Expandir todo',
collapse_all: 'Contraer todo',
@@ -3769,6 +3809,16 @@ const LOCALES = {
mcp_deleted: 'MCP-Server gelöscht.',
mcp_delete_failed: 'Fehler beim Löschen.',
mcp_load_failed: 'Fehler beim Laden.',
+ mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.',
+ mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.',
+ mcp_status_active: 'Active',
+ mcp_status_configured: 'Configured',
+ mcp_status_disabled: 'Disabled',
+ mcp_status_invalid_config: 'Invalid config',
+ mcp_status_unknown: 'Unknown',
+ mcp_tool_count: '{0} tools',
+ mcp_enabled_yes: 'Enabled',
+ mcp_enabled_no: 'Disabled',
thinking: 'Nachdenken',
expand_all: 'Alle ausklappen',
collapse_all: 'Alle einklappen',
@@ -4667,6 +4717,16 @@ const LOCALES = {
mcp_deleted: 'MCP 服务器已删除。',
mcp_delete_failed: 'MCP 服务器删除失败。',
mcp_load_failed: 'MCP 服务器加载失败。',
+ mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.',
+ mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.',
+ mcp_status_active: 'Active',
+ mcp_status_configured: 'Configured',
+ mcp_status_disabled: 'Disabled',
+ mcp_status_invalid_config: 'Invalid config',
+ mcp_status_unknown: 'Unknown',
+ mcp_tool_count: '{0} tools',
+ mcp_enabled_yes: 'Enabled',
+ mcp_enabled_no: 'Disabled',
thinking: '\u601d\u8003\u8fc7\u7a0b',
expand_all: '\u5168\u90e8\u5c55\u5f00',
collapse_all: '\u5168\u90e8\u6298\u53e0',
@@ -5560,6 +5620,16 @@ const LOCALES = {
mcp_deleted: 'MCP 伺服器已刪除。',
mcp_delete_failed: '刪除 MCP 伺服器失敗。',
mcp_load_failed: '載入 MCP 伺服器失敗。',
+ mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.',
+ mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.',
+ mcp_status_active: 'Active',
+ mcp_status_configured: 'Configured',
+ mcp_status_disabled: 'Disabled',
+ mcp_status_invalid_config: 'Invalid config',
+ mcp_status_unknown: 'Unknown',
+ mcp_tool_count: '{0} tools',
+ mcp_enabled_yes: 'Enabled',
+ mcp_enabled_no: 'Disabled',
thinking: '\u601d\u8003\u904e\u7a0b',
expand_all: '\u5168\u90e8\u5c55\u958b',
collapse_all: '\u5168\u90e8\u6298\u758a',
@@ -7315,6 +7385,16 @@ const LOCALES = {
mcp_deleted: 'MCP server deleted.',
mcp_delete_failed: 'Failed to delete MCP server.',
mcp_load_failed: 'Failed to load MCP servers.',
+ mcp_restart_hint: 'Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.',
+ mcp_toggle_followup: 'Enable/disable controls are intentionally deferred until MCP reload semantics are explicit.',
+ mcp_status_active: 'Active',
+ mcp_status_configured: 'Configured',
+ mcp_status_disabled: 'Disabled',
+ mcp_status_invalid_config: 'Invalid config',
+ mcp_status_unknown: 'Unknown',
+ mcp_tool_count: '{0} tools',
+ mcp_enabled_yes: 'Enabled',
+ mcp_enabled_no: 'Disabled',
thinking: '생각 중',
expand_all: '모두 펼치기',
collapse_all: '모두 접기',
diff --git a/static/index.html b/static/index.html
index 860d5d63..17a1050c 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1007,42 +1007,9 @@
-
Manage Model Context Protocol servers configured in config.yaml.
+
View Model Context Protocol servers configured in config.yaml.
-
-
-
diff --git a/static/panels.js b/static/panels.js
index 767c4f0e..d522714c 100644
--- a/static/panels.js
+++ b/static/panels.js
@@ -4885,93 +4885,50 @@ function dismissErrorBanner(){
// ── MCP Server Management ──
+function _mcpStatusLabel(status){
+ const key={
+ active:'mcp_status_active',
+ configured:'mcp_status_configured',
+ disabled:'mcp_status_disabled',
+ invalid_config:'mcp_status_invalid_config',
+ }[status]||'mcp_status_unknown';
+ return t(key);
+}
function loadMcpServers(){
const list=$('mcpServerList');
if(!list) return;
+ list.innerHTML=`${esc(t('loading'))}
`;
api('/api/mcp/servers').then(r=>{
- if(!r||!r.servers) return;
+ if(!r||!Array.isArray(r.servers)) return;
if(!r.servers.length){
- list.innerHTML=`${t('mcp_no_servers')}
`;
+ list.innerHTML=`${esc(t('mcp_no_servers'))}
`;
return;
}
+ const toggleNote=r.toggle_supported?'':''+esc(t('mcp_toggle_followup'))+'
';
list.innerHTML=r.servers.map(s=>{
- const transportLabel=s.transport==='http'?'HTTP':s.transport==='stdio'?'stdio':(''+s.transport);
+ const transportLabel=s.transport==='http'?'HTTP':s.transport==='stdio'?'stdio':(''+(s.transport||'unknown'));
const transportClass=s.transport==='http'?'mcp-http':s.transport==='stdio'?'mcp-stdio':'mcp-unknown';
- const badge=`${esc(transportLabel)}`;
- const detail=s.transport==='http'?s.url:`${s.command} ${s.args?s.args.join(' '):''}`;
+ const transportBadge=`${esc(transportLabel)}`;
+ const status=s.status||'configured';
+ const statusBadge=`${esc(_mcpStatusLabel(status))}`;
+ const toolCount=s.tool_count===null||typeof s.tool_count==='undefined'?'—':String(s.tool_count);
+ const detail=s.transport==='http'
+ ? (s.url||'')
+ : (s.transport==='stdio'?`${s.command||''} ${Array.isArray(s.args)?s.args.join(' '):''}`:t('mcp_status_invalid_config'));
const envInfo=s.env?Object.entries(s.env).map(([k,v])=>`${k}=${v}`).join(', '):'';
+ const headersInfo=s.headers?Object.entries(s.headers).map(([k,v])=>`${k}=${v}`).join(', '):'';
+ const secretInfo=[envInfo,headersInfo].filter(Boolean).join(' | ');
return `
-
-
${esc(s.name)}${badge}
+
+ ${esc(s.name)}
+ ${transportBadge}
+ ${statusBadge}
-
${esc(detail)}${envInfo?' | '+esc(envInfo):''}
-
+
${esc(detail)}${secretInfo?' | '+esc(secretInfo):''}
+
${esc(t('mcp_tool_count',toolCount))}${esc(t(s.enabled===false?'mcp_enabled_no':'mcp_enabled_yes'))}
`;
- }).join('');
- }).catch(()=>{list.innerHTML=`
${t('mcp_load_failed')}
`});
- // Delegate delete-button clicks — uses data-mcp-name to avoid inline onclick XSS
- if(list&&!list._mcpDeleteBound){
- list._mcpDeleteBound=true;
- list.addEventListener('click',function(e){
- const btn=e.target.closest('.mcp-delete-btn');
- if(!btn) return;
- const name=btn.getAttribute('data-mcp-name');
- if(name) deleteMcpServer(name);
- });
- }
-}
-
-function showMcpAddForm(){
- const wrap=$('mcpAddFormWrap');
- if(wrap) wrap.style.display='block';
-}
-function hideMcpAddForm(){
- const wrap=$('mcpAddFormWrap');
- if(wrap) wrap.style.display='none';
- ['mcpName','mcpCommand','mcpArgs','mcpUrl','mcpTimeout'].forEach(id=>{
- const el=$(id);if(el)el.value=id==='mcpTimeout'?'120':'';
- });
- const tr=$('mcpTransport');if(tr)tr.value='stdio';
- mcpTransportChanged();
-}
-function mcpTransportChanged(){
- const tr=$('mcpTransport');
- const isHttp=tr&&tr.value==='http';
- const cmdF=$('mcpCommandField');if(cmdF)cmdF.style.display=isHttp?'none':'';
- const argsF=$('mcpArgsField');if(argsF)argsF.style.display=isHttp?'none':'';
- const urlF=$('mcpUrlField');if(urlF)urlF.style.display=isHttp?'block':'none';
-}
-function saveMcpServer(){
- const name=($('mcpName')||{}).value||'';
- if(!name.trim()){showToast(t('mcp_name_required'));return;}
- const tr=($('mcpTransport')||{}).value||'stdio';
- const timeout=parseInt(($('mcpTimeout')||{}).value)||120;
- const body={timeout};
- if(tr==='http'){
- body.url=($('mcpUrl')||{}).value||'';
- if(!body.url.trim()){showToast(t('mcp_url_required'));return;}
- }else{
- body.command=($('mcpCommand')||{}).value||'';
- if(!body.command.trim()){showToast(t('mcp_command_required'));return;}
- const argsStr=($('mcpArgs')||{}).value||'';
- if(argsStr.trim()) body.args=argsStr.split(',').map(a=>a.trim()).filter(Boolean);
- }
- const encName=encodeURIComponent(name.trim());
- api(`/api/mcp/servers/${encName}`,{method:'PUT',body:JSON.stringify(body)})
- .then(r=>{
- if(r&&r.ok){showToast(t('mcp_saved'));hideMcpAddForm();loadMcpServers();}
- else{showToast((r&&r.error)||t('mcp_save_failed'));}
- }).catch(()=>{showToast(t('mcp_save_failed'));});
-}
-async function deleteMcpServer(name){
- const _ok=await showConfirmDialog({title:t('mcp_delete_confirm_title'),message:t('mcp_delete_confirm_message',name),confirmLabel:t('delete_title'),danger:true,focusCancel:true});
- if(!_ok) return;
- const encName=encodeURIComponent(name);
- api(`/api/mcp/servers/${encName}`,{method:'DELETE'})
- .then(r=>{
- if(r&&r.ok){showToast(t('mcp_deleted'));loadMcpServers();}
- else{showToast((r&&r.error)||t('mcp_delete_failed'));}
- }).catch(()=>{showToast(t('mcp_delete_failed'));});
+ }).join('')+toggleNote;
+ }).catch(()=>{list.innerHTML=`
${esc(t('mcp_load_failed'))}
`});
}
function loadGatewayStatus(){
const card=$('gatewayStatusCard');
diff --git a/static/style.css b/static/style.css
index fe5cbcee..f86d7e0d 100644
--- a/static/style.css
+++ b/static/style.css
@@ -2273,16 +2273,22 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
#mainSettings #btnSignOut:hover{color:var(--accent-text)!important;border-color:var(--accent-bg-strong)!important;}
/* MCP Server Management */
-.mcp-server-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border:1px solid var(--border);border-radius:6px;margin-bottom:4px;position:relative;font-size:12px;}
+.mcp-server-row{display:flex;flex-direction:column;gap:4px;padding:8px 10px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;position:relative;font-size:12px;background:var(--surface);}
.mcp-server-row:hover{background:var(--code-bg);}
+.mcp-server-row-head{display:flex;align-items:center;gap:8px;min-width:0;flex-wrap:wrap;}
.mcp-server-name{font-weight:600;color:var(--text);}
-.mcp-server-detail{flex:1;color:var(--muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
-.mcp-transport-badge{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:2px 6px;border-radius:4px;flex-shrink:0;}
+.mcp-server-detail{color:var(--muted);font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:100%;}
+.mcp-server-meta{display:flex;gap:10px;color:var(--muted);font-size:11px;}
+.mcp-transport-badge,.mcp-status-badge{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:2px 6px;border-radius:999px;flex-shrink:0;}
.mcp-stdio{background:rgba(99,102,241,.12);color:#818cf8;}
.mcp-unknown{background:rgba(161,161,170,.12);color:#a1a1aa;}
.mcp-http{background:rgba(34,197,94,.12);color:#4ade80;}
-.mcp-delete-btn{background:none;border:none;color:var(--muted);font-size:16px;cursor:pointer;padding:2px 4px;border-radius:4px;flex-shrink:0;}
-.mcp-delete-btn:hover{color:#ef4444;background:rgba(239,68,68,.1);}
+.mcp-status-active{background:rgba(34,197,94,.12);color:#4ade80;}
+.mcp-status-configured{background:rgba(245,158,11,.12);color:#f59e0b;}
+.mcp-status-disabled{background:rgba(161,161,170,.12);color:#a1a1aa;}
+.mcp-status-invalid_config,.mcp-status-unknown{background:rgba(239,68,68,.12);color:#f87171;}
+.mcp-tool-count{color:var(--text);}
+.mcp-readonly-note,.mcp-restart-hint{margin-top:8px;color:var(--muted);font-size:11px;line-height:1.45;background:var(--code-bg);border:1px solid var(--border2);border-radius:6px;padding:8px 10px;}
/* Picker grids (theme / skin / font-size): make the card chrome use
tokens so all skins flip correctly. */
diff --git a/tests/test_issue538_mcp_management.py b/tests/test_issue538_mcp_management.py
index 0a1c735c..758eff1a 100644
--- a/tests/test_issue538_mcp_management.py
+++ b/tests/test_issue538_mcp_management.py
@@ -6,6 +6,7 @@ from api.routes import (
_handle_mcp_server_update,
_handle_mcp_server_delete,
_mask_secrets,
+ _parse_mcp_enabled,
_server_summary,
_strip_masked_values,
)
@@ -18,6 +19,11 @@ def _make_handler():
return h
+def _json_payload(handler):
+ body = handler.wfile.write.call_args[0][0]
+ return json.loads(body.decode('utf-8'))
+
+
SAMPLE_MCP = {
"searxng": {
"command": "mcp-searxng",
@@ -52,6 +58,43 @@ class TestMcpList:
assert h.send_response.called
status = h.send_response.call_args[0][0]
assert status == 200
+ payload = _json_payload(h)
+ assert payload['servers'] == []
+ assert payload['toggle_supported'] is False
+ assert payload['reload_required'] is True
+
+ @patch('api.routes._mcp_runtime_status_by_name')
+ @patch('api.routes.get_config')
+ def test_list_payload_includes_status_tool_counts_and_safe_invalid_config(self, mock_cfg, mock_runtime):
+ mock_cfg.return_value = {
+ 'mcp_servers': {
+ 'searxng': {'command': 'mcp-searxng', 'args': ['--port', '8888']},
+ 'web-reader': {
+ 'url': 'http://localhost:3001/mcp',
+ 'headers': {'Authorization': 'Bearer secret123'},
+ },
+ 'disabled': {'command': 'disabled-cmd', 'enabled': 0},
+ 'broken': 'not-a-dict',
+ }
+ }
+ mock_runtime.return_value = {
+ 'searxng': {'connected': True, 'tools': 3},
+ 'web-reader': {'connected': False, 'tools': 0},
+ }
+ h = _make_handler()
+ _handle_mcp_servers_list(h)
+ payload = _json_payload(h)
+ by_name = {s['name']: s for s in payload['servers']}
+ assert by_name['searxng']['status'] == 'active'
+ assert by_name['searxng']['active'] is True
+ assert by_name['searxng']['tool_count'] == 3
+ assert by_name['web-reader']['status'] == 'configured'
+ assert '••••' in by_name['web-reader']['headers']['Authorization']
+ assert by_name['disabled']['enabled'] is False
+ assert by_name['disabled']['active'] is False
+ assert by_name['disabled']['status'] == 'disabled'
+ assert by_name['broken']['transport'] == 'invalid'
+ assert by_name['broken']['status'] == 'invalid_config'
def test_secrets_are_masked(self):
"""_mask_secrets hides API keys in headers and env."""
@@ -75,6 +118,10 @@ class TestMcpList:
summary = _server_summary('minimal', {'command': 'x'})
assert summary['timeout'] == 120
+ def test_numeric_zero_enabled_flag_is_disabled(self):
+ """YAML numeric false-y values should not show a disabled server as enabled."""
+ assert _parse_mcp_enabled(0) is False
+
class TestMcpSave:
"""PUT /api/mcp/servers/
— add or update."""
diff --git a/tests/test_issue696_mcp_visibility_panel.py b/tests/test_issue696_mcp_visibility_panel.py
new file mode 100644
index 00000000..999192e5
--- /dev/null
+++ b/tests/test_issue696_mcp_visibility_panel.py
@@ -0,0 +1,46 @@
+"""Regression tests for issue #696 — MCP server visibility panel MVP."""
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def read(relpath: str) -> str:
+ return (ROOT / relpath).read_text(encoding="utf-8")
+
+
+def test_settings_system_panel_contains_readonly_mcp_visibility_section():
+ html = read("static/index.html")
+ assert 'data-i18n="mcp_servers_title"' in html
+ assert 'id="mcpServerList"' in html
+ assert 'class="mcp-restart-hint"' in html
+ assert 'id="mcpAddFormWrap"' not in html
+ assert 'onclick="showMcpAddForm()"' not in html
+
+
+def test_mcp_panel_renders_status_badges_tool_counts_and_empty_error_states():
+ js = read("static/panels.js")
+ assert "function _mcpStatusLabel" in js
+ assert "mcp-status-badge" in js
+ assert "mcp-tool-count" in js
+ assert "mcp-empty-state" in js
+ assert "mcp-error-state" in js
+ assert "mcp_toggle_followup" in js
+ assert "api('/api/mcp/servers')" in js
+ assert "mcp-delete-btn" not in js
+ assert "showMcpAddForm" not in js
+ assert "saveMcpServer" not in js
+
+
+def test_mcp_i18n_includes_visibility_status_labels():
+ i18n = read("static/i18n.js")
+ for key in [
+ "mcp_status_active",
+ "mcp_status_configured",
+ "mcp_status_disabled",
+ "mcp_status_invalid_config",
+ "mcp_tool_count",
+ "mcp_enabled_yes",
+ "mcp_enabled_no",
+ "mcp_toggle_followup",
+ ]:
+ assert key in i18n