feat: add MCP server visibility panel

This commit is contained in:
Michael Lam
2026-05-04 17:04:13 -07:00
committed by test
parent 2bbaad3135
commit 399326f923
8 changed files with 301 additions and 120 deletions

View File

@@ -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):

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

View File

@@ -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: '모두 접기',

View File

@@ -1007,42 +1007,9 @@
<!-- MCP Servers Section -->
<div class="settings-field" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
<label data-i18n="mcp_servers_title">MCP Servers</label>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px" data-i18n="mcp_servers_desc">Manage Model Context Protocol servers configured in config.yaml.</div>
<div style="font-size:11px;color:var(--muted);margin-bottom:8px" data-i18n="mcp_servers_desc">View Model Context Protocol servers configured in config.yaml.</div>
<div id="mcpServerList"></div>
<button class="sm-btn" onclick="showMcpAddForm()" style="margin-top:8px;width:100%;padding:7px;font-weight:600" data-i18n="mcp_add_server">+ Add Server</button>
</div>
<div id="mcpAddFormWrap" style="display:none;margin-top:10px">
<div class="settings-field">
<label data-i18n="mcp_field_name">Server Name</label>
<input type="text" id="mcpName" placeholder="my-server" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<div class="settings-field">
<label data-i18n="mcp_transport_label">Transport Type</label>
<select id="mcpTransport" onchange="mcpTransportChanged()" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
<option value="stdio">stdio (local command)</option>
<option value="http">HTTP (remote URL)</option>
</select>
</div>
<div class="settings-field" id="mcpCommandField">
<label data-i18n="mcp_field_command">Command</label>
<input type="text" id="mcpCommand" placeholder="npx" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<div class="settings-field" id="mcpArgsField">
<label data-i18n="mcp_field_args">Arguments (comma-separated)</label>
<input type="text" id="mcpArgs" placeholder="-y, @modelcontextprotocol/server-filesystem" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<div class="settings-field" id="mcpUrlField" style="display:none">
<label data-i18n="mcp_field_url">URL</label>
<input type="text" id="mcpUrl" placeholder="https://my-mcp-server.example.com/mcp" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<div class="settings-field" id="mcpTimeoutField">
<label data-i18n="mcp_field_timeout">Timeout (seconds)</label>
<input type="number" id="mcpTimeout" value="120" min="10" max="600" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
</div>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="sm-btn" onclick="saveMcpServer()" style="flex:1;padding:7px;font-weight:600" data-i18n="mcp_save">Save</button>
<button class="sm-btn" onclick="hideMcpAddForm()" style="flex:1;padding:7px;color:var(--muted)" data-i18n="mcp_cancel">Cancel</button>
</div>
<div class="mcp-restart-hint" data-i18n="mcp_restart_hint">Server changes are read-only here for now. Edit config.yaml and restart Hermes for changes to take effect.</div>
</div>
<button class="sm-btn" onclick="saveSettings()" style="margin-top:12px;width:100%;padding:8px;font-weight:600" data-i18n="settings_save_btn">Save Settings</button>
</div>

View File

@@ -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=`<div style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t('loading'))}</div>`;
api('/api/mcp/servers').then(r=>{
if(!r||!r.servers) return;
if(!r||!Array.isArray(r.servers)) return;
if(!r.servers.length){
list.innerHTML=`<div style="color:var(--muted);font-size:12px;padding:6px 0">${t('mcp_no_servers')}</div>`;
list.innerHTML=`<div class="mcp-empty-state" style="color:var(--muted);font-size:12px;padding:6px 0">${esc(t('mcp_no_servers'))}</div>`;
return;
}
const toggleNote=r.toggle_supported?'':'<div class="mcp-readonly-note">'+esc(t('mcp_toggle_followup'))+'</div>';
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=`<span class="mcp-transport-badge ${transportClass}">${esc(transportLabel)}</span>`;
const detail=s.transport==='http'?s.url:`${s.command} ${s.args?s.args.join(' '):''}`;
const transportBadge=`<span class="mcp-transport-badge ${transportClass}">${esc(transportLabel)}</span>`;
const status=s.status||'configured';
const statusBadge=`<span class="mcp-status-badge mcp-status-${esc(status)}">${esc(_mcpStatusLabel(status))}</span>`;
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 `<div class="mcp-server-row">
<div style="display:flex;align-items:center;gap:8px">
<span class="mcp-server-name">${esc(s.name)}</span>${badge}
<div class="mcp-server-row-head">
<span class="mcp-server-name">${esc(s.name)}</span>
${transportBadge}
${statusBadge}
</div>
<div class="mcp-server-detail">${esc(detail)}${envInfo?' | '+esc(envInfo):''}</div>
<button class="mcp-delete-btn" data-mcp-name="${esc(s.name)}" title="Delete">&times;</button>
<div class="mcp-server-detail">${esc(detail)}${secretInfo?' | '+esc(secretInfo):''}</div>
<div class="mcp-server-meta"><span class="mcp-tool-count">${esc(t('mcp_tool_count',toolCount))}</span><span>${esc(t(s.enabled===false?'mcp_enabled_no':'mcp_enabled_yes'))}</span></div>
</div>`;
}).join('');
}).catch(()=>{list.innerHTML=`<div style="color:#ef4444;font-size:12px;padding:6px 0">${t('mcp_load_failed')}</div>`});
// 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=`<div class="mcp-error-state" style="color:#ef4444;font-size:12px;padding:6px 0">${esc(t('mcp_load_failed'))}</div>`});
}
function loadGatewayStatus(){
const card=$('gatewayStatusCard');

View File

@@ -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. */

View File

@@ -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/<name> — add or update."""

View File

@@ -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