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